diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38767975c1..6ae781c17d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,9 +18,12 @@ jobs: distribution: temurin - name: Clone Flutter SDK - # TODO(#1204) reinstate shallow clone with --depth=1000 and its corresponding comment here + # We can't do a depth-1 clone, because we need the most recent tag + # 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. run: | - git clone -b main https://github.com/flutter/flutter ~/flutter + git clone --depth=1000 -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 b0e934e287..1fc63e27c2 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -1,22 +1,56 @@ name: Update translations from Weblate +permissions: + contents: write + pull-requests: write on: + pull_request: schedule: - cron: "0 10 * * 1" workflow_dispatch: + jobs: update-translations: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Fetch and merge from Weblate # The commit message is generated in Weblate; see https://hosted.weblate.org/addon/17163/ run: | + git checkout -b main + git log --oneline --graph --all -n5 git remote add weblate https://hosted.weblate.org/git/zulip/zulip-flutter/ git fetch weblate - git merge --ff-only weblate/main + GIT_COMMITTER_NAME="Hosted Weblate" GIT_COMMITTER_EMAIL="hosted@weblate.org" \ + git cherry-pick weblate/main ^HEAD + + - name: Clone Flutter SDK + # TODO(#1204) reinstate shallow clone with --depth=1000 and its corresponding comment here + run: | + git clone -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" + + # The Flutter tool assumes the tip of tree is "origin/master" + # (or "upstream/master"): + # https://github.com/flutter/flutter/issues/160626 + # TODO(upstream): make workaround unneeded + git --git-dir ~/flutter/.git update-ref refs/remotes/origin/master origin/main + + - name: Update generated code + run: | + mkdir -p build + tools/check l10n --fix + git add lib/generated/l10n/ + GIT_COMMITTER_NAME="Hosted Weblate" GIT_COMMITTER_EMAIL="hosted@weblate.org" \ + git commit --amend -C HEAD + - name: Create Pull Request uses: peter-evans/create-pull-request@v7 with: branch: update-translations/weblate delete-branch: true title: Update translations from Weblate + base: ${{ github.head_ref }} diff --git a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt index 2fb36634e6..2070473b96 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v20.0.2), do not edit directly. +// Autogenerated from Pigeon (v22.7.2), do not edit directly. // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -7,7 +7,9 @@ package com.zulip.flutter import android.util.Log import io.flutter.plugin.common.BasicMessageChannel import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec import io.flutter.plugin.common.StandardMessageCodec import java.io.ByteArrayOutputStream import java.nio.ByteBuffer @@ -64,17 +66,16 @@ data class NotificationChannel ( val lightsEnabled: Boolean? = null, val soundUrl: String? = null, val vibrationPattern: LongArray? = null - -) { +) + { companion object { - @Suppress("LocalVariableName") - fun fromList(__pigeon_list: List): NotificationChannel { - val id = __pigeon_list[0] as String - val importance = __pigeon_list[1].let { num -> if (num is Int) num.toLong() else num as Long } - val name = __pigeon_list[2] as String? - val lightsEnabled = __pigeon_list[3] as Boolean? - val soundUrl = __pigeon_list[4] as String? - val vibrationPattern = __pigeon_list[5] as LongArray? + fun fromList(pigeonVar_list: List): NotificationChannel { + val id = pigeonVar_list[0] as String + val importance = pigeonVar_list[1] as Long + val name = pigeonVar_list[2] as String? + val lightsEnabled = pigeonVar_list[3] as Boolean? + val soundUrl = pigeonVar_list[4] as String? + val vibrationPattern = pigeonVar_list[5] as LongArray? return NotificationChannel(id, importance, name, lightsEnabled, soundUrl, vibrationPattern) } } @@ -104,14 +105,13 @@ data class AndroidIntent ( val dataUrl: String, /** A combination of flags from [IntentFlag]. */ val flags: Long - -) { +) + { companion object { - @Suppress("LocalVariableName") - fun fromList(__pigeon_list: List): AndroidIntent { - val action = __pigeon_list[0] as String - val dataUrl = __pigeon_list[1] as String - val flags = __pigeon_list[2].let { num -> if (num is Int) num.toLong() else num as Long } + fun fromList(pigeonVar_list: List): AndroidIntent { + val action = pigeonVar_list[0] as String + val dataUrl = pigeonVar_list[1] as String + val flags = pigeonVar_list[2] as Long return AndroidIntent(action, dataUrl, flags) } } @@ -139,14 +139,13 @@ data class PendingIntent ( * with `Intent`; see Android docs for `PendingIntent.getActivity`. */ val flags: Long - -) { +) + { companion object { - @Suppress("LocalVariableName") - fun fromList(__pigeon_list: List): PendingIntent { - val requestCode = __pigeon_list[0].let { num -> if (num is Int) num.toLong() else num as Long } - val intent = __pigeon_list[1] as AndroidIntent - val flags = __pigeon_list[2].let { num -> if (num is Int) num.toLong() else num as Long } + fun fromList(pigeonVar_list: List): PendingIntent { + val requestCode = pigeonVar_list[0] as Long + val intent = pigeonVar_list[1] as AndroidIntent + val flags = pigeonVar_list[2] as Long return PendingIntent(requestCode, intent, flags) } } @@ -168,12 +167,11 @@ data class PendingIntent ( */ data class InboxStyle ( val summaryText: String - -) { +) + { companion object { - @Suppress("LocalVariableName") - fun fromList(__pigeon_list: List): InboxStyle { - val summaryText = __pigeon_list[0] as String + fun fromList(pigeonVar_list: List): InboxStyle { + val summaryText = pigeonVar_list[0] as String return InboxStyle(summaryText) } } @@ -205,14 +203,13 @@ data class Person ( val iconBitmap: ByteArray? = null, val key: String, val name: String - -) { +) + { companion object { - @Suppress("LocalVariableName") - fun fromList(__pigeon_list: List): Person { - val iconBitmap = __pigeon_list[0] as ByteArray? - val key = __pigeon_list[1] as String - val name = __pigeon_list[2] as String + fun fromList(pigeonVar_list: List): Person { + val iconBitmap = pigeonVar_list[0] as ByteArray? + val key = pigeonVar_list[1] as String + val name = pigeonVar_list[2] as String return Person(iconBitmap, key, name) } } @@ -236,14 +233,13 @@ data class MessagingStyleMessage ( val text: String, val timestampMs: Long, val person: Person - -) { +) + { companion object { - @Suppress("LocalVariableName") - fun fromList(__pigeon_list: List): MessagingStyleMessage { - val text = __pigeon_list[0] as String - val timestampMs = __pigeon_list[1].let { num -> if (num is Int) num.toLong() else num as Long } - val person = __pigeon_list[2] as Person + fun fromList(pigeonVar_list: List): MessagingStyleMessage { + val text = pigeonVar_list[0] as String + val timestampMs = pigeonVar_list[1] as Long + val person = pigeonVar_list[2] as Person return MessagingStyleMessage(text, timestampMs, person) } } @@ -266,17 +262,16 @@ data class MessagingStyleMessage ( data class MessagingStyle ( val user: Person, val conversationTitle: String? = null, - val messages: List, + val messages: List, val isGroupConversation: Boolean - -) { +) + { companion object { - @Suppress("LocalVariableName") - fun fromList(__pigeon_list: List): MessagingStyle { - val user = __pigeon_list[0] as Person - val conversationTitle = __pigeon_list[1] as String? - val messages = __pigeon_list[2] as List - val isGroupConversation = __pigeon_list[3] as Boolean + fun fromList(pigeonVar_list: List): MessagingStyle { + val user = pigeonVar_list[0] as Person + val conversationTitle = pigeonVar_list[1] as String? + val messages = pigeonVar_list[2] as List + val isGroupConversation = pigeonVar_list[3] as Boolean return MessagingStyle(user, conversationTitle, messages, isGroupConversation) } } @@ -299,14 +294,13 @@ data class MessagingStyle ( */ data class Notification ( val group: String, - val extras: Map - -) { + val extras: Map +) + { companion object { - @Suppress("LocalVariableName") - fun fromList(__pigeon_list: List): Notification { - val group = __pigeon_list[0] as String - val extras = __pigeon_list[1] as Map + fun fromList(pigeonVar_list: List): Notification { + val group = pigeonVar_list[0] as String + val extras = pigeonVar_list[1] as Map return Notification(group, extras) } } @@ -329,14 +323,13 @@ data class StatusBarNotification ( val id: Long, val tag: String, val notification: Notification - -) { +) + { companion object { - @Suppress("LocalVariableName") - fun fromList(__pigeon_list: List): StatusBarNotification { - val id = __pigeon_list[0].let { num -> if (num is Int) num.toLong() else num as Long } - val tag = __pigeon_list[1] as String - val notification = __pigeon_list[2] as Notification + fun fromList(pigeonVar_list: List): StatusBarNotification { + val id = pigeonVar_list[0] as Long + val tag = pigeonVar_list[1] as String + val notification = pigeonVar_list[2] as Notification return StatusBarNotification(id, tag, notification) } } @@ -370,14 +363,13 @@ data class StoredNotificationSound ( val isOwned: Boolean, /** A `content://…` URL pointing to the sound file. */ val contentUrl: String - -) { +) + { companion object { - @Suppress("LocalVariableName") - fun fromList(__pigeon_list: List): StoredNotificationSound { - val fileName = __pigeon_list[0] as String - val isOwned = __pigeon_list[1] as Boolean - val contentUrl = __pigeon_list[2] as String + fun fromList(pigeonVar_list: List): StoredNotificationSound { + val fileName = pigeonVar_list[0] as String + val isOwned = pigeonVar_list[1] as Boolean + val contentUrl = pigeonVar_list[2] as String return StoredNotificationSound(fileName, isOwned, contentUrl) } } @@ -389,7 +381,7 @@ data class StoredNotificationSound ( ) } } -private object NotificationsPigeonCodec : StandardMessageCodec() { +private open class NotificationsPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { 129.toByte() -> { @@ -561,7 +553,7 @@ interface AndroidNotificationHostApi { * https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify * https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder */ - fun notify(tag: String?, id: Long, autoCancel: Boolean?, channelId: String, color: Long?, contentIntent: PendingIntent?, contentText: String?, contentTitle: String?, extras: Map?, groupKey: String?, inboxStyle: InboxStyle?, isGroupSummary: Boolean?, messagingStyle: MessagingStyle?, number: Long?, smallIconResourceName: String?) + fun notify(tag: String?, id: Long, autoCancel: Boolean?, channelId: String, color: Long?, contentIntent: PendingIntent?, contentText: String?, contentTitle: String?, extras: Map?, groupKey: String?, inboxStyle: InboxStyle?, isGroupSummary: Boolean?, messagingStyle: MessagingStyle?, number: Long?, smallIconResourceName: String?) /** * Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, * combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. @@ -597,7 +589,7 @@ interface AndroidNotificationHostApi { companion object { /** The codec used by AndroidNotificationHostApi. */ val codec: MessageCodec by lazy { - NotificationsPigeonCodec + NotificationsPigeonCodec() } /** Sets up an instance of `AndroidNotificationHostApi` to handle messages through the `binaryMessenger`. */ @JvmOverloads @@ -693,19 +685,19 @@ interface AndroidNotificationHostApi { channel.setMessageHandler { message, reply -> val args = message as List val tagArg = args[0] as String? - val idArg = args[1].let { num -> if (num is Int) num.toLong() else num as Long } + val idArg = args[1] as Long val autoCancelArg = args[2] as Boolean? val channelIdArg = args[3] as String - val colorArg = args[4].let { num -> if (num is Int) num.toLong() else num as Long? } + val colorArg = args[4] as Long? val contentIntentArg = args[5] as PendingIntent? val contentTextArg = args[6] as String? val contentTitleArg = args[7] as String? - val extrasArg = args[8] as Map? + val extrasArg = args[8] as Map? val groupKeyArg = args[9] as String? val inboxStyleArg = args[10] as InboxStyle? val isGroupSummaryArg = args[11] as Boolean? val messagingStyleArg = args[12] as MessagingStyle? - val numberArg = args[13].let { num -> if (num is Int) num.toLong() else num as Long? } + val numberArg = args[13] as Long? val smallIconResourceNameArg = args[14] as String? val wrapped: List = try { api.notify(tagArg, idArg, autoCancelArg, channelIdArg, colorArg, contentIntentArg, contentTextArg, contentTitleArg, extrasArg, groupKeyArg, inboxStyleArg, isGroupSummaryArg, messagingStyleArg, numberArg, smallIconResourceNameArg) @@ -759,7 +751,7 @@ interface AndroidNotificationHostApi { channel.setMessageHandler { message, reply -> val args = message as List val tagArg = args[0] as String? - val idArg = args[1].let { num -> if (num is Int) num.toLong() else num as Long } + val idArg = args[1] as Long val wrapped: List = try { api.cancel(tagArg, idArg) listOf(null) diff --git a/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt b/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt index bae5ad43ab..eb332d786f 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt @@ -183,7 +183,7 @@ private class AndroidNotificationHost(val context: Context) contentIntent: PendingIntent?, contentText: String?, contentTitle: String?, - extras: Map?, + extras: Map?, groupKey: String?, inboxStyle: InboxStyle?, isGroupSummary: Boolean?, @@ -221,13 +221,13 @@ private class AndroidNotificationHost(val context: Context) val style = NotificationCompat.MessagingStyle(toAndroidPerson(messagingStyle.user)) .setConversationTitle(messagingStyle.conversationTitle) .setGroupConversation(messagingStyle.isGroupConversation) - messagingStyle.messages.forEach { it?.let { + messagingStyle.messages.forEach { style.addMessage(NotificationCompat.MessagingStyle.Message( it.text, it.timestampMs, toAndroidPerson(it.person), )) - } } + } setStyle(style) } number?.let { setNumber(it.toInt()) } @@ -268,8 +268,11 @@ private class AndroidNotificationHost(val context: Context) Notification( it.notification.group, desiredExtras - .associateWith { key -> it.notification.extras.getString(key) } - .filter { entry -> entry.value != null } + .mapNotNull { key -> + it.notification.extras.getString(key)?.let { value -> + key to value + } } + .toMap() ), ) } diff --git a/android/gradle.properties b/android/gradle.properties index 0a5710a599..b0202e3120 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 57a301f2e8..0be670d5cb 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/check.svg b/assets/icons/check.svg new file mode 100644 index 0000000000..26332a3599 --- /dev/null +++ b/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/check_remove.svg b/assets/icons/check_remove.svg new file mode 100644 index 0000000000..cc5939b04d --- /dev/null +++ b/assets/icons/check_remove.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/three_person.svg b/assets/icons/three_person.svg new file mode 100644 index 0000000000..294155cf5a --- /dev/null +++ b/assets/icons/three_person.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/l10n/app_ar.arb b/assets/l10n/app_ar.arb index 0967ef424b..5ca1208723 100644 --- a/assets/l10n/app_ar.arb +++ b/assets/l10n/app_ar.arb @@ -1 +1,11 @@ -{} +{ + "wildcardMentionAll": "الجميع", + "wildcardMentionEveryone": "الكل", + "wildcardMentionChannel": "القناة", + "wildcardMentionStream": "الدفق", + "wildcardMentionTopic": "الموضوع", + "wildcardMentionChannelDescription": "إخطار القناة", + "wildcardMentionStreamDescription": "إخطار الدفق", + "wildcardMentionAllDmDescription": "إخطار المستلمين", + "wildcardMentionTopicDescription": "إخطار الموضوع" +} diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 58822303fd..0640af9ee1 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -56,6 +56,10 @@ "@profileButtonSendDirectMessage": { "description": "Label for button in profile screen to navigate to DMs with the shown user." }, + "errorCouldNotShowUserProfile": "Could not show user profile.", + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, "permissionsNeededTitle": "Permissions needed", "@permissionsNeededTitle": { "description": "Title for dialog asking the user to grant additional permissions." @@ -88,6 +92,22 @@ "@actionSheetOptionUnfollowTopic": { "description": "Label for unfollowing a topic on action sheet." }, + "actionSheetOptionResolveTopic": "Mark as resolved", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "actionSheetOptionUnresolveTopic": "Mark as unresolved", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "errorResolveTopicFailedTitle": "Failed to mark topic as resolved", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "errorUnresolveTopicFailedTitle": "Failed to mark topic as unresolved", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, "actionSheetOptionCopyMessageText": "Copy message text", "@actionSheetOptionCopyMessageText": { "description": "Label for copy message text button on action sheet." @@ -151,13 +171,21 @@ "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{File is} other{{num} files are}} larger than the server's limit of {maxFileUploadSizeMib} MiB and will not be uploaded:\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\nbar.txt"} + "listMessage": {"type": "String", "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB"} } }, "errorFilesTooLargeTitle": "{num, plural, =1{File} other{Files}} too large", @@ -230,6 +258,17 @@ "event": {"type": "String", "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')"} } }, + "errorCouldNotOpenLinkTitle": "Unable to open link", + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "errorCouldNotOpenLink": "Link could not be opened: {url}", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": {"type": "String", "example": "https://chat.example.com"} + } + }, "errorMuteTopicFailed": "Failed to mute topic", "@errorMuteTopicFailed": { "description": "Error message when muting a topic failed." @@ -321,8 +360,8 @@ "@composeBoxSendTooltip": { "description": "Tooltip for send button in compose box." }, - "composeBoxUnknownChannelName": "(unknown channel)", - "@composeBoxUnknownChannelName": { + "unknownChannelName": "(unknown channel)", + "@unknownChannelName": { "description": "Replacement name for channel when it cannot be found in the store." }, "composeBoxTopicHintText": "Topic", @@ -336,10 +375,21 @@ "filename": {"type": "String", "example": "file.txt"} } }, + "composeBoxLoadingMessage": "(loading message {messageId})", + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": {"type": "int", "example": "1234"} + } + }, "unknownUserName": "(unknown user)", "@unknownUserName": { "description": "Name placeholder to use for a user when we don't know their name." }, + "dmsWithYourselfPageTitle": "DMs with yourself", + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, "messageListGroupYouAndOthers": "You and {others}", "@messageListGroupYouAndOthers": { "description": "Message list recipient header for a DM group with others.", @@ -347,6 +397,13 @@ "others": {"type": "String", "example": "Alice, Bob"} } }, + "dmsWithOthersPageTitle": "DMs with {others}", + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": {"type": "String", "example": "Alice, Bob"} + } + }, "messageListGroupYouWithYourself": "You with yourself", "@messageListGroupYouWithYourself": { "description": "Message list recipient header for a DM group that only includes yourself." @@ -395,6 +452,14 @@ "@lightboxCopyLinkTooltip": { "description": "Tooltip in lightbox for the copy link action." }, + "lightboxVideoCurrentPosition": "Current position", + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "lightboxVideoDuration": "Video duration", + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, "loginPageTitle": "Log in", "@loginPageTitle": { "description": "Title for login page." @@ -418,9 +483,9 @@ "@loginAddAnAccountPageTitle": { "description": "Title for page to add a Zulip account." }, - "loginServerUrlInputLabel": "Your Zulip server URL", - "@loginServerUrlInputLabel": { - "description": "Input label in login page for Zulip server URL entry." + "loginServerUrlLabel": "Your Zulip server URL", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." }, "loginHidePassword": "Hide password", "@loginHidePassword": { @@ -586,6 +651,10 @@ "@recentDmConversationsPageTitle": { "description": "Title for the page with a list of DM conversations." }, + "recentDmConversationsSectionHeader": "Direct messages", + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, "combinedFeedPageTitle": "Combined feed", "@combinedFeedPageTitle": { "description": "Page title for the 'Combined feed' message view." @@ -618,10 +687,26 @@ "numOthers": {"type": "int", "example": "4"} } }, + "pinnedSubscriptionsLabel": "Pinned", + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "unpinnedSubscriptionsLabel": "Unpinned", + "@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" }, + "reactedEmojiSelfUser": "You", + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, "onePersonTyping": "{typist} is typing…", "@onePersonTyping": { "description": "Text to display when there is one user typing.", @@ -641,6 +726,42 @@ "@manyPeopleTyping": { "description": "Text to display when there are multiple users typing." }, + "wildcardMentionAll": "all", + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionEveryone": "everyone", + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionChannel": "channel", + "@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": "topic", + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionChannelDescription": "Notify channel", + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "wildcardMentionStreamDescription": "Notify stream", + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "wildcardMentionAllDmDescription": "Notify recipients", + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "wildcardMentionTopicDescription": "Notify topic", + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, "messageIsEditedLabel": "EDITED", "@messageIsEditedLabel": { "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" @@ -649,6 +770,13 @@ "@messageIsMovedLabel": { "description": "Label for a moved message. (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"} + } + }, "pollWidgetQuestionMissing": "No question.", "@pollWidgetQuestionMissing": { "description": "Text to display for a poll when the question is missing" @@ -680,5 +808,21 @@ "emojiPickerSearchEmoji": "Search emoji", "@emojiPickerSearchEmoji": { "description": "Hint text for the emoji picker search text field." + }, + "noEarlierMessages": "No earlier messages", + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "scrollToBottomTooltip": "Scroll to bottom", + "@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/assets/l10n/app_nb.arb b/assets/l10n/app_nb.arb index 0967ef424b..fb72c02a9d 100644 --- a/assets/l10n/app_nb.arb +++ b/assets/l10n/app_nb.arb @@ -1 +1,14 @@ -{} +{ + "aboutPageAppVersion": "App versjon", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "aboutPageTitle": "Om Zulip", + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "aboutPageOpenSourceLicenses": "Lisenser for åpen kildekode", + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + } +} diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index ba918083d1..6821033271 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -277,10 +277,6 @@ } } }, - "composeBoxUnknownChannelName": "(nieznany kanał)", - "@composeBoxUnknownChannelName": { - "description": "Replacement name for channel when it cannot be found in the store." - }, "composeBoxTopicHintText": "Wątek", "@composeBoxTopicHintText": { "description": "Hint text for topic input widget in compose box." @@ -359,9 +355,9 @@ "@loginAddAnAccountPageTitle": { "description": "Title for page to add a Zulip account." }, - "loginServerUrlInputLabel": "URL serwera Zulip", - "@loginServerUrlInputLabel": { - "description": "Input label in login page for Zulip server URL entry." + "loginServerUrlLabel": "URL serwera Zulip", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." }, "loginHidePassword": "Ukryj hasło", "@loginHidePassword": { @@ -776,5 +772,171 @@ "errorUnmuteTopicFailed": "Wznowienie bez powodzenia", "@errorUnmuteTopicFailed": { "description": "Error message when unmuting a topic failed." + }, + "wildcardMentionAll": "wszyscy", + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionStream": "strumień", + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "wildcardMentionEveryone": "każdy", + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionChannel": "kanał", + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionTopic": "wątek", + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionTopicDescription": "Powiadom w wątku", + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "wildcardMentionAllDmDescription": "Powiadom zainteresowanych", + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "wildcardMentionStreamDescription": "Powiadom w strumieniu", + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "wildcardMentionChannelDescription": "Powiadom w kanale", + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "errorCouldNotShowUserProfile": "Nie udało się wyświetlić profilu.", + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "errorCouldNotOpenLinkTitle": "Nie udało się otworzyć odnośnika", + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "errorCouldNotOpenLink": "Nie można otworzyć: {url}", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "type": "String", + "example": "https://chat.example.com" + } + } + }, + "dmsWithYourselfPageTitle": "DM do siebie", + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "dmsWithOthersPageTitle": "DM z {others}", + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "lightboxVideoCurrentPosition": "Obecna pozycja", + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "lightboxVideoDuration": "Długość wideo", + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "recentDmConversationsSectionHeader": "Wiadomości bezpośrednie", + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "reactedEmojiSelfUser": "Ty", + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "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" + } + } + }, + "noEarlierMessages": "Brak historii", + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "scrollToBottomTooltip": "Przewiń do dołu", + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "composeBoxLoadingMessage": "(ładowanie wiadomości {messageId})", + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "type": "int", + "example": "1234" + } + } + }, + "pinnedSubscriptionsLabel": "Przypięte", + "@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." + }, + "unpinnedSubscriptionsLabel": "Odpięte", + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "actionSheetOptionResolveTopic": "Oznacz jako rozwiązany", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "actionSheetOptionUnresolveTopic": "Oznacz brak rozwiązania", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "errorResolveTopicFailedTitle": "Nie udało się oznaczyć jako rozwiązany", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "errorUnresolveTopicFailedTitle": "Nie udało się oznaczyć brak rozwiązania", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "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." + }, + "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_ru.arb b/assets/l10n/app_ru.arb index fc7e7c964d..ef38533bb2 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -207,9 +207,9 @@ "@loginAddAnAccountPageTitle": { "description": "Title for page to add a Zulip account." }, - "loginServerUrlInputLabel": "URL вашего сервера Zulip", - "@loginServerUrlInputLabel": { - "description": "Input label in login page for Zulip server URL entry." + "loginServerUrlLabel": "URL вашего сервера Zulip", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." }, "loginHidePassword": "Скрыть пароль", "@loginHidePassword": { @@ -571,10 +571,6 @@ "@errorFollowTopicFailed": { "description": "Error message when following a topic failed." }, - "composeBoxUnknownChannelName": "(неизвестный канал)", - "@composeBoxUnknownChannelName": { - "description": "Replacement name for channel when it cannot be found in the store." - }, "dialogContinue": "Продолжить", "@dialogContinue": { "description": "Button label in dialogs to proceed." diff --git a/assets/l10n/app_sk.arb b/assets/l10n/app_sk.arb index 4ad83e6790..087f459697 100644 --- a/assets/l10n/app_sk.arb +++ b/assets/l10n/app_sk.arb @@ -69,9 +69,9 @@ } } }, - "loginServerUrlInputLabel": "Adresa vášho Zulip servera", - "@loginServerUrlInputLabel": { - "description": "Input label in login page for Zulip server URL entry." + "loginServerUrlLabel": "Adresa vášho Zulip servera", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." }, "errorMessageNotSent": "Správa nebola odoslaná", "@errorMessageNotSent": { diff --git a/docs/changelog.md b/docs/changelog.md index 024ab93cdb..e9e4caadd5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,43 @@ ## Unreleased +## 0.0.26 (2025-02-07) + +### Highlights for users + +* Resolve or unresolve a topic, from the menu after you + press and hold the topic. (#744) +* Autocomplete now offers `@all`, `@topic`, and other + wildcards. (#234) +* Channel names starting with emoji go at the start of the + list. (#1202) +* Too many other improvements and fixes to describe them all here. + + +### Highlights for developers + +* Resolved: #1205, #1289, #942, #1238, #1202, #1219, #1204, #1171, + PR #1296, #234, #1207, #1330, #1309, #725, #744 + + +## 0.0.25 (2025-01-13) + +### Highlights for users + +* The combined feed is now conveniently accessible from the app's main + navigation bar. (#1164) +* Messages with @-topic mentions now show them properly. (#892) +* The lightbox now shows the sender's avatar. (#41) +* The About Zulip screen is now available from the main menu. (#1128) +* Too many other improvements and fixes to describe them all here. + + +### Highlights for developers + +* Resolved in main: #892, #1177, #1164, #1177, #1128, #1189, #1116, + #739, #41 + + ## 0.0.24 (2024-12-11) This is a preview beta, including some experimental changes diff --git a/docs/release.md b/docs/release.md index cbf0020223..7895ba50b0 100644 --- a/docs/release.md +++ b/docs/release.md @@ -6,6 +6,9 @@ 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. + * Write an entry in `docs/changelog.md`, under "Unreleased". Commit that change. diff --git a/docs/setup.md b/docs/setup.md index 6a83cfa55c..03ecc87996 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -179,3 +179,37 @@ For the original reports and debugging of this issue, see chat threads [here](https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.20json_annotation.20unexpected.20behavior/near/1824410) and [here](https://chat.zulip.org/#narrow/stream/516-mobile-dev-help/topic/generated.20plugin.20files.20changed/near/1944826). + + +
+ +### Lack of libdrm on Linux target + +This item applies only when building the app to run as a Linux desktop +app. (This is an unsupported configuration which is sometimes +convenient in development.) It does not affect using Linux for a +development environment when building or running Zulip as an Android +app. + +When building or running as a Linux desktop app, you may see an error +about `/usr/include/libdrm`, like this: +``` +$ flutter run -d linux +Launching lib/main.dart on Linux in debug mode... +CMake Error in CMakeLists.txt: + Imported target "PkgConfig::GTK" includes non-existent path + + "/usr/include/libdrm" + + in its INTERFACE_INCLUDE_DIRECTORIES. Possible reasons include: +… +``` + +This means you need to install the header files for "DRM", part of the +Linux graphics infrastructure. + +To resolve the issue, install the appropriate package from your OS +distribution. For example, on Debian or Ubuntu: +``` +$ sudo apt install libdrm-dev +``` diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a8e8b195a3..27e8691603 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -37,31 +37,31 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter - - Firebase/CoreOnly (11.4.0): - - FirebaseCore (= 11.4.0) - - Firebase/Messaging (11.4.0): + - Firebase/CoreOnly (11.6.0): + - FirebaseCore (~> 11.6.0) + - Firebase/Messaging (11.6.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 11.4.0) - - firebase_core (3.9.0): - - Firebase/CoreOnly (= 11.4.0) + - FirebaseMessaging (~> 11.6.0) + - firebase_core (3.10.0): + - Firebase/CoreOnly (= 11.6.0) - Flutter - - firebase_messaging (15.1.6): - - Firebase/Messaging (= 11.4.0) + - firebase_messaging (15.2.0): + - Firebase/Messaging (= 11.6.0) - firebase_core - Flutter - - FirebaseCore (11.4.0): - - FirebaseCoreInternal (~> 11.0) + - FirebaseCore (11.6.0): + - FirebaseCoreInternal (~> 11.6.0) - GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Logger (~> 8.0) - FirebaseCoreInternal (11.6.0): - "GoogleUtilities/NSData+zlib (~> 8.0)" - - FirebaseInstallations (11.4.0): - - FirebaseCore (~> 11.0) + - FirebaseInstallations (11.6.0): + - FirebaseCore (~> 11.6.0) - GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0) - PromisesObjC (~> 2.4) - - FirebaseMessaging (11.4.0): - - FirebaseCore (~> 11.0) + - FirebaseMessaging (11.6.0): + - FirebaseCore (~> 11.6.0) - FirebaseInstallations (~> 11.0) - GoogleDataTransport (~> 10.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0) @@ -213,17 +213,17 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_settings: 017320c6a680cdc94c799949d95b84cb69389ebc - device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 - Firebase: cf1b19f21410b029b6786a54e9764a0cacad3c99 - firebase_core: b62a5080210edad3f2934314a8b2c6f5124e8e10 - firebase_messaging: 98619a0572d82cfb3668e78859ba9f1110e268c9 - FirebaseCore: e0510f1523bc0eb21653cac00792e1e2bd6f1771 + Firebase: 374a441a91ead896215703a674d58cdb3e9d772b + firebase_core: feb37e79f775c2bd08dd35e02d83678291317e10 + firebase_messaging: e2f0ba891b1509668c07f5099761518a5af8fe3c + FirebaseCore: 48b0dd707581cf9c1a1220da68223fb0a562afaa FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2 - FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414 - FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2 + FirebaseInstallations: efc0946fc756e4d22d8113f7c761948120322e8c + FirebaseMessaging: e1aca1fcc23e8b9eddb0e33f375ff90944623021 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d diff --git a/lib/api/exception.dart b/lib/api/exception.dart index cda75b4b07..d4bebeeb62 100644 --- a/lib/api/exception.dart +++ b/lib/api/exception.dart @@ -28,19 +28,46 @@ sealed class ApiRequestException implements Exception { String toString() => message; } -/// An error returned through the Zulip server API. +/// A network-level error that prevented even getting an HTTP response +/// to some Zulip API network request. /// -/// See API docs: https://zulip.com/api/rest-error-handling -class ZulipApiException extends ApiRequestException { +/// This is the antonym of [HttpException]. +class NetworkException extends ApiRequestException { + /// The exception describing the underlying error. + /// + /// This can be any exception value that [http.Client.send] throws. + /// Ideally that would always be an [http.ClientException], + /// but empirically it can be [TlsException] and possibly others. + final Object cause; - /// The Zulip API error code returned by the server. - final String code; + NetworkException({required super.routeName, required super.message, required this.cause}); + @override + String toString() { + return 'NetworkException: $message ($cause)'; + } +} + +/// Some kind of [ApiRequestException] that came as an HTTP response. +/// +/// This is the antonym of [NetworkException]. +sealed class HttpException extends ApiRequestException { /// The HTTP status code returned by the server. /// - /// This is always in the range 400..499. + /// On [ZulipApiException], this is always in the range 400..499. final int httpStatus; + HttpException({required super.routeName, required this.httpStatus, required super.message}); +} + +/// An error returned through the Zulip server API, +/// and with a 4xx HTTP status code. +/// +/// See API docs: https://zulip.com/api/rest-error-handling +class ZulipApiException extends HttpException { + /// The Zulip API error code returned by the server. + final String code; + /// The error's JSON data, if any, beyond the properties common to all errors. /// /// This consists of the properties other than `result`, `code`, and `msg`. @@ -50,8 +77,8 @@ class ZulipApiException extends ApiRequestException { ZulipApiException({ required super.routeName, + required super.httpStatus, required this.code, - required this.httpStatus, required this.data, required super.message, }) : assert(400 <= httpStatus && httpStatus <= 499); @@ -69,31 +96,12 @@ class ZulipApiException extends ApiRequestException { } } -/// A network-level error that prevented even getting an HTTP response. -class NetworkException extends ApiRequestException { - /// The exception describing the underlying error. - /// - /// This can be any exception value that [http.Client.send] throws. - /// Ideally that would always be an [http.ClientException], - /// but empirically it can be [TlsException] and possibly others. - final Object cause; - - NetworkException({required super.routeName, required super.message, required this.cause}); - - @override - String toString() { - return 'NetworkException: $message ($cause)'; - } -} - /// Some kind of server-side error in handling the request. /// /// This should always represent either some kind of operational issue /// on the server, or a bug in the server where its responses don't /// agree with the documented API. -sealed class ServerException extends ApiRequestException { - final int httpStatus; - +sealed class ServerException extends HttpException { /// The response body, decoded as a JSON object. /// /// This is null if the body could not be read, or was not a valid JSON object. @@ -101,7 +109,7 @@ sealed class ServerException extends ApiRequestException { ServerException({ required super.routeName, - required this.httpStatus, + required super.httpStatus, required this.data, required super.message, }); diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 837aab120f..9faa3d367e 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -645,7 +645,7 @@ class UserTopicEvent extends Event { String get type => 'user_topic'; final int streamId; - final String topicName; + final TopicName topicName; final int lastUpdated; final UserTopicVisibilityPolicy visibilityPolicy; @@ -725,9 +725,9 @@ class UpdateMessageEvent extends Event { final PropagateMode? propagateMode; @JsonKey(name: 'orig_subject') - final String? origTopic; + final TopicName? origTopic; @JsonKey(name: 'subject') - final String? newTopic; + final TopicName? newTopic; // final List topicLinks; // TODO handle @@ -766,14 +766,6 @@ class UpdateMessageEvent extends Event { Map toJson() => _$UpdateMessageEventToJson(this); } -/// As in [UpdateMessageEvent.propagateMode]. -@JsonEnum(fieldRename: FieldRename.snake) -enum PropagateMode { - changeOne, - changeLater, - changeAll; -} - /// A Zulip event of type `delete_message`: https://zulip.com/api/get-events#delete_message @JsonSerializable(fieldRename: FieldRename.snake) class DeleteMessageEvent extends Event { @@ -788,7 +780,7 @@ class DeleteMessageEvent extends Event { @MessageTypeConverter() final MessageType messageType; final int? streamId; - final String? topic; + final TopicName? topic; DeleteMessageEvent({ required super.id, @@ -924,7 +916,7 @@ class UpdateMessageFlagsMessageDetail { final bool? mentioned; final List? userIds; final int? streamId; - final String? topic; + final TopicName? topic; UpdateMessageFlagsMessageDetail({ required this.type, @@ -1002,7 +994,7 @@ class TypingEvent extends Event { @JsonKey(name: 'recipients', fromJson: _recipientIdsFromJson) final List? recipientIds; final int? streamId; - final String? topic; + final TopicName? topic; TypingEvent({ required super.id, diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 8f11b8b655..5d47444ecd 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -389,7 +389,7 @@ UserTopicEvent _$UserTopicEventFromJson(Map json) => UserTopicEvent( id: (json['id'] as num).toInt(), streamId: (json['stream_id'] as num).toInt(), - topicName: json['topic_name'] as String, + topicName: TopicName.fromJson(json['topic_name'] as String), lastUpdated: (json['last_updated'] as num).toInt(), visibilityPolicy: $enumDecode( _$UserTopicVisibilityPolicyEnumMap, json['visibility_policy']), @@ -430,8 +430,12 @@ UpdateMessageEvent _$UpdateMessageEventFromJson(Map json) => newStreamId: (json['new_stream_id'] as num?)?.toInt(), propagateMode: $enumDecodeNullable(_$PropagateModeEnumMap, json['propagate_mode']), - origTopic: json['orig_subject'] as String?, - newTopic: json['subject'] as String?, + origTopic: json['orig_subject'] == null + ? null + : TopicName.fromJson(json['orig_subject'] as String), + newTopic: json['subject'] == null + ? null + : TopicName.fromJson(json['subject'] as String), origContent: json['orig_content'] as String?, origRenderedContent: json['orig_rendered_content'] as String?, content: json['content'] as String?, @@ -451,7 +455,7 @@ Map _$UpdateMessageEventToJson(UpdateMessageEvent instance) => 'edit_timestamp': instance.editTimestamp, 'stream_id': instance.origStreamId, 'new_stream_id': instance.newStreamId, - 'propagate_mode': _$PropagateModeEnumMap[instance.propagateMode], + 'propagate_mode': instance.propagateMode, 'orig_subject': instance.origTopic, 'subject': instance.newTopic, 'orig_content': instance.origContent, @@ -487,7 +491,9 @@ DeleteMessageEvent _$DeleteMessageEventFromJson(Map json) => messageType: const MessageTypeConverter().fromJson(json['message_type'] as String), streamId: (json['stream_id'] as num?)?.toInt(), - topic: json['topic'] as String?, + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), ); Map _$DeleteMessageEventToJson(DeleteMessageEvent instance) => @@ -561,7 +567,9 @@ UpdateMessageFlagsMessageDetail _$UpdateMessageFlagsMessageDetailFromJson( ?.map((e) => (e as num).toInt()) .toList(), streamId: (json['stream_id'] as num?)?.toInt(), - topic: json['topic'] as String?, + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), ); Map _$UpdateMessageFlagsMessageDetailToJson( @@ -609,7 +617,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'] as String?, + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), ); Map _$TypingEventToJson(TypingEvent instance) => diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 6be0cadda9..1adc44196f 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -64,6 +64,13 @@ class InitialSnapshot { final List? userTopics; // TODO(server-6) + /// The policy for who can use wildcard mentions in large channels. + /// + /// Search for "realm_wildcard_mention_policy" in https://zulip.com/api/register-queue. + final RealmWildcardMentionPolicy realmWildcardMentionPolicy; + + final bool realmMandatoryTopics; + /// The number of days until a user's account is treated as a full member. /// /// Search for "realm_waiting_period_threshold" in https://zulip.com/api/register-queue. @@ -125,6 +132,8 @@ class InitialSnapshot { required this.streams, required this.userSettings, required this.userTopics, + required this.realmWildcardMentionPolicy, + required this.realmMandatoryTopics, required this.realmWaitingPeriodThreshold, required this.realmDefaultExternalAccounts, required this.maxFileUploadSizeMib, @@ -148,6 +157,22 @@ enum EmailAddressVisibility { @JsonValue(5) moderators, } +@JsonEnum(valueField: 'apiValue') +enum RealmWildcardMentionPolicy { + everyone(apiValue: 1), + members(apiValue: 2), + fullMembers(apiValue: 3), + admins(apiValue: 5), + nobody(apiValue: 6), + moderators(apiValue: 7); + + const RealmWildcardMentionPolicy({required this.apiValue}); + + final int? apiValue; + + int? toJson() => apiValue; +} + /// An item in `realm_default_external_accounts`. /// /// For docs, search for "realm_default_external_accounts:" @@ -231,7 +256,7 @@ class UserSettings { @JsonSerializable(fieldRename: FieldRename.snake) class UserTopicItem { final int streamId; - final String topicName; + final TopicName topicName; final int lastUpdated; @JsonKey(unknownEnumValue: UserTopicVisibilityPolicy.unknown) final UserTopicVisibilityPolicy visibilityPolicy; @@ -310,7 +335,7 @@ class UnreadDmSnapshot { /// An item in [UnreadMessagesSnapshot.channels]. @JsonSerializable(fieldRename: FieldRename.snake) class UnreadChannelSnapshot { - final String topic; + final TopicName topic; final int streamId; final List unreadMessageIds; diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 75472c1d7f..a69b6ebafe 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -58,6 +58,10 @@ InitialSnapshot _$InitialSnapshotFromJson(Map json) => 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(), realmDefaultExternalAccounts: @@ -108,6 +112,8 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => '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_default_external_accounts': instance.realmDefaultExternalAccounts, 'max_file_upload_size_mib': instance.maxFileUploadSizeMib, @@ -125,6 +131,15 @@ const _$EmailAddressVisibilityEnumMap = { EmailAddressVisibility.moderators: 5, }; +const _$RealmWildcardMentionPolicyEnumMap = { + RealmWildcardMentionPolicy.everyone: 1, + RealmWildcardMentionPolicy.members: 2, + RealmWildcardMentionPolicy.fullMembers: 3, + RealmWildcardMentionPolicy.admins: 5, + RealmWildcardMentionPolicy.nobody: 6, + RealmWildcardMentionPolicy.moderators: 7, +}; + RealmDefaultExternalAccount _$RealmDefaultExternalAccountFromJson( Map json) => RealmDefaultExternalAccount( @@ -188,7 +203,7 @@ const _$EmojisetEnumMap = { UserTopicItem _$UserTopicItemFromJson(Map json) => UserTopicItem( streamId: (json['stream_id'] as num).toInt(), - topicName: json['topic_name'] as String, + topicName: TopicName.fromJson(json['topic_name'] as String), lastUpdated: (json['last_updated'] as num).toInt(), visibilityPolicy: $enumDecode( _$UserTopicVisibilityPolicyEnumMap, json['visibility_policy'], @@ -260,7 +275,7 @@ Map _$UnreadDmSnapshotToJson(UnreadDmSnapshot instance) => UnreadChannelSnapshot _$UnreadChannelSnapshotFromJson( Map json) => UnreadChannelSnapshot( - topic: json['topic'] as String, + 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()) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index b36e1f2490..fad8ddc5bc 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -556,11 +556,11 @@ sealed class Message { final String senderFullName; final int senderId; final String senderRealmStr; - @JsonKey(name: 'subject') - String topic; + /// Poll data if "submessages" describe a poll, `null` otherwise. @JsonKey(name: 'submessages', readValue: _readPoll, fromJson: Poll.fromJson, toJson: Poll.toJson) Poll? poll; + final int timestamp; String get type; @@ -613,7 +613,6 @@ sealed class Message { required this.senderFullName, required this.senderId, required this.senderRealmStr, - required this.topic, required this.timestamp, required this.flags, required this.matchContent, @@ -656,6 +655,65 @@ enum MessageFlag { String toJson() => _$MessageFlagEnumMap[this]!; } +/// The name of a Zulip topic. +// TODO(dart): Can we forbid calling Object members on this extension type? +// (The lack of "implements Object" ought to do that, but doesn't.) +// In particular an interpolation "foo > $topic" is a bug we'd like to catch. +// TODO(dart): Can we forbid using this extension type as a key in a Map? +// (The lack of "implements Object" arguably should do that, but doesn't.) +// Using as a Map key is almost certainly a bug because it won't case-fold; +// see for example #739, #980, #1205. +extension type const TopicName(String _value) { + /// The canonical form of the resolved-topic prefix. + // This is RESOLVED_TOPIC_PREFIX in web: + // https://github.com/zulip/zulip/blob/1fac99733/web/shared/src/resolved_topic.ts + static const resolvedTopicPrefix = '✔ '; + + /// Pattern for an arbitrary resolved-topic prefix. + /// + /// These always begin with [resolvedTopicPrefix] + /// but can be weird and go on longer, like "✔ ✔✔ ". + // This is RESOLVED_TOPIC_PREFIX_RE in web: + // https://github.com/zulip/zulip/blob/1fac99733/web/shared/src/resolved_topic.ts#L4-L12 + static final resolvedTopicPrefixRegexp = RegExp(r'^✔ [ ✔]*'); + + /// The string this topic is identified by in the Zulip API. + /// + /// This should be used in constructing HTTP requests to the server, + /// but rarely for other purposes. See [displayName] and [canonicalize]. + String get apiName => _value; + + /// The string this topic is displayed as to the user in our UI. + /// + /// At the moment this always equals [apiName]. + /// In the future this will become null for the "general chat" topic (#1250), + /// so that UI code can identify when it needs to represent the topic + /// specially in the way prescribed for "general chat". + // TODO(#1250) carry out that plan + String get displayName => _value; + + /// The key to use for "same topic as" comparisons. + String canonicalize() => apiName.toLowerCase(); + + /// Whether the topic starts with [resolvedTopicPrefix]. + bool get isResolved => _value.startsWith(resolvedTopicPrefix); + + /// This [TopicName] plus the [resolvedTopicPrefix] prefix. + TopicName resolve() => TopicName(resolvedTopicPrefix + _value); + + /// A [TopicName] with [resolvedTopicPrefixRegexp] stripped if present. + TopicName unresolve() => + TopicName(_value.replaceFirst(resolvedTopicPrefixRegexp, '')); + + /// Whether [this] and [other] have the same canonical form, + /// using [canonicalize]. + bool isSameAs(TopicName other) => canonicalize() == other.canonicalize(); + + TopicName.fromJson(this._value); + + String toJson() => apiName; +} + @JsonSerializable(fieldRename: FieldRename.snake) class StreamMessage extends Message { @override @@ -667,8 +725,16 @@ class StreamMessage extends Message { // invalidated. @JsonKey(required: true, disallowNullValue: true) String? displayRecipient; + int streamId; + // The topic/subject is documented to be present on DMs too, just empty. + // We ignore it on DMs; if a future server introduces distinct topics in DMs, + // that will need new UI that we'll design then as part of that feature, + // and ignoring the topics seems as good a fallback behavior as any. + @JsonKey(name: 'subject') + TopicName topic; + StreamMessage({ required super.client, required super.content, @@ -683,13 +749,13 @@ class StreamMessage extends Message { required super.senderFullName, required super.senderId, required super.senderRealmStr, - required super.topic, required super.timestamp, required super.flags, required super.matchContent, required super.matchTopic, required this.displayRecipient, required this.streamId, + required this.topic, }); factory StreamMessage.fromJson(Map json) => @@ -786,7 +852,6 @@ class DmMessage extends Message { required super.senderFullName, required super.senderId, required super.senderRealmStr, - required super.topic, required super.timestamp, required super.flags, required super.matchContent, @@ -806,25 +871,28 @@ enum MessageEditState { edited, moved; - // Adapted from the shared code: - // https://github.com/zulip/zulip/blob/1fac99733/web/shared/src/resolved_topic.ts - // The canonical resolved-topic prefix. - static const String _resolvedTopicPrefix = '✔ '; - /// Whether the given topic move reflected either a "resolve topic" /// or "unresolve topic" operation. /// /// The Zulip "resolved topics" feature is implemented by renaming the topic; /// but for purposes of [Message.editState], we want to ignore such renames. /// This method identifies topic moves that should be ignored in that context. - static bool topicMoveWasResolveOrUnresolve(String topic, String prevTopic) { - if (topic.startsWith(_resolvedTopicPrefix) - && topic.substring(_resolvedTopicPrefix.length) == prevTopic) { + static bool topicMoveWasResolveOrUnresolve(TopicName topic, TopicName prevTopic) { + // Implemented to match web; see analyze_edit_history in zulip/zulip's + // web/src/message_list_view.ts. + // + // Also, this is a hot codepath (decoding messages, a high-volume type of + // data we get from the server), so we avoid calling [canonicalize] and + // using [TopicName.resolvedTopicPrefixRegexp], to be performance-sensitive. + // Discussion: + // https://github.com/zulip/zulip-flutter/pull/1242#discussion_r1917592157 + if (topic.apiName.startsWith(TopicName.resolvedTopicPrefix) + && topic.apiName.substring(TopicName.resolvedTopicPrefix.length) == prevTopic.apiName) { return true; } - if (prevTopic.startsWith(_resolvedTopicPrefix) - && prevTopic.substring(_resolvedTopicPrefix.length) == topic) { + if (prevTopic.apiName.startsWith(TopicName.resolvedTopicPrefix) + && prevTopic.apiName.substring(TopicName.resolvedTopicPrefix.length) == topic.apiName) { return true; } @@ -857,8 +925,10 @@ enum MessageEditState { } // TODO(server-5) prev_subject was the old name of prev_topic on pre-5.0 servers - final prevTopic = (entry['prev_topic'] ?? entry['prev_subject']) as String?; - final topic = entry['topic'] as String?; + final prevTopicStr = (entry['prev_topic'] ?? entry['prev_subject']) as String?; + final prevTopic = prevTopicStr == null ? null : TopicName.fromJson(prevTopicStr); + final topicStr = entry['topic'] as String?; + final topic = topicStr == null ? null : TopicName.fromJson(topicStr); if (prevTopic != null) { // TODO(server-5) pre-5.0 servers do not have the 'topic' field if (topic == null) { @@ -875,3 +945,13 @@ enum MessageEditState { return MessageEditState.none; } } + +/// As in [updateMessage] or [UpdateMessageEvent.propagateMode]. +@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) +enum PropagateMode { + changeOne, + changeLater, + changeAll; + + String toJson() => _$PropagateModeEnumMap[this]!; +} diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 2c5adbe15c..cfa38aec5f 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -267,13 +267,13 @@ StreamMessage _$StreamMessageFromJson(Map json) { senderFullName: json['sender_full_name'] as String, senderId: (json['sender_id'] as num).toInt(), senderRealmStr: json['sender_realm_str'] as String, - topic: json['subject'] as String, timestamp: (json['timestamp'] as num).toInt(), flags: Message._flagsFromJson(json['flags']), matchContent: json['match_content'] as String?, matchTopic: json['match_subject'] as String?, displayRecipient: json['display_recipient'] as String?, streamId: (json['stream_id'] as num).toInt(), + topic: TopicName.fromJson(json['subject'] as String), )..poll = Poll.fromJson(Message._readPoll(json, 'submessages')); } @@ -292,7 +292,6 @@ Map _$StreamMessageToJson(StreamMessage instance) => 'sender_full_name': instance.senderFullName, 'sender_id': instance.senderId, 'sender_realm_str': instance.senderRealmStr, - 'subject': instance.topic, 'submessages': Poll.toJson(instance.poll), 'timestamp': instance.timestamp, 'flags': instance.flags, @@ -302,6 +301,7 @@ Map _$StreamMessageToJson(StreamMessage instance) => if (instance.displayRecipient case final value?) 'display_recipient': value, 'stream_id': instance.streamId, + 'subject': instance.topic, }; const _$MessageEditStateEnumMap = { @@ -338,7 +338,6 @@ DmMessage _$DmMessageFromJson(Map json) => DmMessage( senderFullName: json['sender_full_name'] as String, senderId: (json['sender_id'] as num).toInt(), senderRealmStr: json['sender_realm_str'] as String, - topic: json['subject'] as String, timestamp: (json['timestamp'] as num).toInt(), flags: Message._flagsFromJson(json['flags']), matchContent: json['match_content'] as String?, @@ -361,7 +360,6 @@ Map _$DmMessageToJson(DmMessage instance) => { 'sender_full_name': instance.senderFullName, 'sender_id': instance.senderId, 'sender_realm_str': instance.senderRealmStr, - 'subject': instance.topic, 'submessages': Poll.toJson(instance.poll), 'timestamp': instance.timestamp, 'flags': instance.flags, @@ -405,3 +403,9 @@ const _$MessageFlagEnumMap = { MessageFlag.historical: 'historical', MessageFlag.unknown: 'unknown', }; + +const _$PropagateModeEnumMap = { + PropagateMode.changeOne: 'change_one', + PropagateMode.changeLater: 'change_later', + PropagateMode.changeAll: 'change_all', +}; diff --git a/lib/api/model/narrow.dart b/lib/api/model/narrow.dart index 3ec08a4fe4..8082ac64ff 100644 --- a/lib/api/model/narrow.dart +++ b/lib/api/model/narrow.dart @@ -1,5 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; +import 'model.dart'; + part 'narrow.g.dart'; typedef ApiNarrow = List; @@ -26,7 +28,37 @@ ApiNarrow resolveDmElements(ApiNarrow narrow, int zulipFeatureLevel) { /// please add more as needed. sealed class ApiNarrowElement { String get operator; - Object get operand; + + /// The operand of this narrow filter. + /// + /// The base-class getter [ApiNarrowElement.operand] returns `dynamic`, + /// and its value should only be used for encoding as JSON, for use in a + /// request to the Zulip server. + /// + /// For any operations that depend more specifically on the operand's type, + /// do not use run-time type checks on the value of [operand]; instead, make + /// a run-time type check (e.g. with `switch`) on the [ApiNarrowElement] + /// itself, and use the [operand] getter of the specific subtype. + /// + /// That makes a difference because [ApiNarrowTopic.operand] has type + /// [TopicName]; at runtime a [TopicName] is indistinguishable from [String], + /// but an [ApiNarrowTopic] can still be distinguished from other subclasses. + // + // We can't just write [Object] here; if we do, the compiler rejects the + // override in ApiNarrowTopic because TopicName can't be assigned to Object. + // The reason that could be bad is that a caller of [ApiNarrowElement.operand] + // could take the result and call Object members on it, like toString, even + // though TopicName doesn't declare those members. + // + // In this case that's fine because the only plausible thing to do with + // a generic [ApiNarrowElement.operand] is to encode it as JSON anyway, + // which behaves just fine on TopicName. + // + // ... Even if it weren't fine, in the case of Object this protection is + // thoroughly undermined already: code that has a TopicName can call Object + // members on it directly. See comments at [TopicName]. + dynamic get operand; // see justification for `dynamic` above + final bool negated; ApiNarrowElement({this.negated = false}); @@ -54,12 +86,12 @@ class ApiNarrowStream extends ApiNarrowElement { class ApiNarrowTopic extends ApiNarrowElement { @override String get operator => 'topic'; - @override final String operand; + @override final TopicName operand; ApiNarrowTopic(this.operand, {super.negated}); factory ApiNarrowTopic.fromJson(Map json) => ApiNarrowTopic( - json['operand'] as String, + TopicName.fromJson(json['operand'] as String), negated: json['negated'] as bool? ?? false, ); } diff --git a/lib/api/notifications.dart b/lib/api/notifications.dart index 2337c0456e..6d028aa267 100644 --- a/lib/api/notifications.dart +++ b/lib/api/notifications.dart @@ -1,6 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; +import 'model/model.dart'; + part 'notifications.g.dart'; /// Parsed version of an FCM message, of any type. @@ -187,7 +189,7 @@ class FcmMessageChannelRecipient extends FcmMessageRecipient { @JsonKey(name: 'stream') final String? streamName; - final String topic; + final TopicName topic; FcmMessageChannelRecipient({required this.streamId, required this.streamName, required this.topic}); diff --git a/lib/api/notifications.g.dart b/lib/api/notifications.g.dart index 4a9752a9af..b56412f1fd 100644 --- a/lib/api/notifications.g.dart +++ b/lib/api/notifications.g.dart @@ -47,7 +47,7 @@ FcmMessageChannelRecipient _$FcmMessageChannelRecipientFromJson( FcmMessageChannelRecipient( streamId: const _IntConverter().fromJson(json['stream_id'] as String), streamName: json['stream'] as String?, - topic: json['topic'] as String, + topic: TopicName.fromJson(json['topic'] as String), ); RemoveFcmMessage _$RemoveFcmMessageFromJson(Map json) => diff --git a/lib/api/route/channels.dart b/lib/api/route/channels.dart index 00832f7fd0..bfa46f5ab8 100644 --- a/lib/api/route/channels.dart +++ b/lib/api/route/channels.dart @@ -28,7 +28,7 @@ class GetStreamTopicsResult { @JsonSerializable(fieldRename: FieldRename.snake) class GetStreamTopicsEntry { final int maxId; - final String name; + final TopicName name; GetStreamTopicsEntry({ required this.maxId, @@ -46,7 +46,7 @@ class GetStreamTopicsEntry { // TODO(server-7): remove this and just use updateUserTopic Future updateUserTopicCompat(ApiConnection connection, { required int streamId, - required String topic, + required TopicName topic, required UserTopicVisibilityPolicy visibilityPolicy, }) { final useLegacyApi = connection.zulipFeatureLevel! < 170; @@ -59,7 +59,7 @@ Future updateUserTopicCompat(ApiConnection connection, { // https://zulip.com/api/mute-topic return connection.patch('muteTopic', (_) {}, 'users/me/subscriptions/muted_topics', { 'stream_id': streamId, - 'topic': RawParameter(topic), + 'topic': RawParameter(topic.apiName), 'op': RawParameter(op), }); } else { @@ -76,14 +76,14 @@ Future updateUserTopicCompat(ApiConnection connection, { // TODO(server-7) remove FL 170+ mention in doc, and the related `assert` Future updateUserTopic(ApiConnection connection, { required int streamId, - required String topic, + required TopicName topic, required UserTopicVisibilityPolicy visibilityPolicy, }) { assert(visibilityPolicy != UserTopicVisibilityPolicy.unknown); assert(connection.zulipFeatureLevel! >= 170); return connection.post('updateUserTopic', (_) {}, 'user_topics', { 'stream_id': streamId, - 'topic': RawParameter(topic), + 'topic': RawParameter(topic.apiName), 'visibility_policy': visibilityPolicy, }); } diff --git a/lib/api/route/channels.g.dart b/lib/api/route/channels.g.dart index 561b43f005..4a5f7009c3 100644 --- a/lib/api/route/channels.g.dart +++ b/lib/api/route/channels.g.dart @@ -26,7 +26,7 @@ GetStreamTopicsEntry _$GetStreamTopicsEntryFromJson( Map json) => GetStreamTopicsEntry( maxId: (json['max_id'] as num).toInt(), - name: json['name'] as String, + name: TopicName.fromJson(json['name'] as String), ); Map _$GetStreamTopicsEntryToJson( diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 26fd80f3ac..be6728c790 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -156,7 +156,7 @@ class GetMessagesResult { } // https://zulip.com/api/send-message#parameter-topic -const int kMaxTopicLength = 60; +const int kMaxTopicLengthCodePoints = 60; // https://zulip.com/api/send-message#parameter-content const int kMaxMessageLengthCodePoints = 10000; @@ -186,7 +186,7 @@ Future sendMessage( StreamDestination() => { 'type': RawParameter('stream'), 'to': destination.streamId, - 'topic': RawParameter(destination.topic), + 'topic': RawParameter(destination.topic.apiName), }, DmDestination() => { 'type': supportsTypeDirect ? RawParameter('direct') : RawParameter('private'), @@ -231,7 +231,7 @@ class StreamDestination extends MessageDestination { const StreamDestination(this.streamId, this.topic); final int streamId; - final String topic; + final TopicName topic; } /// A DM conversation, for specifying to [sendMessage]. @@ -258,6 +258,39 @@ class SendMessageResult { Map toJson() => _$SendMessageResultToJson(this); } +/// https://zulip.com/api/update-message +Future updateMessage( + ApiConnection connection, { + required int messageId, + TopicName? topic, + PropagateMode? propagateMode, + bool? sendNotificationToOldThread, + bool? sendNotificationToNewThread, + String? content, + int? streamId, +}) { + return connection.patch('updateMessage', UpdateMessageResult.fromJson, 'messages/$messageId', { + if (topic != null) 'topic': RawParameter(topic.apiName), + if (propagateMode != null) 'propagate_mode': RawParameter(propagateMode.toJson()), + if (sendNotificationToOldThread != null) 'send_notification_to_old_thread': sendNotificationToOldThread, + if (sendNotificationToNewThread != null) 'send_notification_to_new_thread': sendNotificationToNewThread, + if (content != null) 'content': RawParameter(content), + if (streamId != null) 'stream_id': streamId, + }); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class UpdateMessageResult { + // final List detachedUploads; // TODO handle + + UpdateMessageResult(); + + factory UpdateMessageResult.fromJson(Map json) => + _$UpdateMessageResultFromJson(json); + + Map toJson() => _$UpdateMessageResultToJson(this); +} + /// https://zulip.com/api/upload-file Future uploadFile( ApiConnection connection, { @@ -449,10 +482,10 @@ Future markStreamAsRead(ApiConnection connection, { // TODO(server-6): Remove as deprecated by updateMessageFlagsForNarrow Future markTopicAsRead(ApiConnection connection, { required int streamId, - required String topicName, + required TopicName topicName, }) { return connection.post('markTopicAsRead', (_) {}, 'mark_topic_as_read', { 'stream_id': streamId, - 'topic_name': RawParameter(topicName), + 'topic_name': RawParameter(topicName.apiName), }); } diff --git a/lib/api/route/messages.g.dart b/lib/api/route/messages.g.dart index 161ea7588e..3a449d73ac 100644 --- a/lib/api/route/messages.g.dart +++ b/lib/api/route/messages.g.dart @@ -50,6 +50,13 @@ Map _$SendMessageResultToJson(SendMessageResult instance) => 'id': instance.id, }; +UpdateMessageResult _$UpdateMessageResultFromJson(Map json) => + UpdateMessageResult(); + +Map _$UpdateMessageResultToJson( + UpdateMessageResult instance) => + {}; + UploadFileResult _$UploadFileResultFromJson(Map json) => UploadFileResult( uri: json['uri'] as String, diff --git a/lib/api/route/typing.dart b/lib/api/route/typing.dart index b9f8503172..8e2e5dae6b 100644 --- a/lib/api/route/typing.dart +++ b/lib/api/route/typing.dart @@ -17,7 +17,7 @@ Future setTypingStatus(ApiConnection connection, { 'type': RawParameter(supportsTypeChannel ? 'channel' : 'stream'), if (supportsStreamId) 'stream_id': destination.streamId else 'to': [destination.streamId], - 'topic': RawParameter(destination.topic), + 'topic': RawParameter(destination.topic.apiName), }); case DmDestination(): final supportsDirect = connection.zulipFeatureLevel! >= 174; // TODO(server-7) diff --git a/lib/example/sticky_header.dart b/lib/example/sticky_header.dart new file mode 100644 index 0000000000..3fa2185c1a --- /dev/null +++ b/lib/example/sticky_header.dart @@ -0,0 +1,428 @@ +/// Example app for exercising the sticky_header library. +/// +/// This is useful when developing changes to [StickyHeaderListView], +/// [SliverStickyHeaderList], and [StickyHeaderItem], +/// for experimenting visually with changes. +/// +/// To use this example app, run the command: +/// flutter run lib/example/sticky_header.dart +/// or run this file from your IDE. +/// +/// One inconvenience: this means the example app will use the same app ID +/// as the actual Zulip app. The app's data remains untouched, though, so +/// a normal `flutter run` will put things back as they were. +/// This inconvenience could be fixed with a bit more work: we'd use +/// `flutter run --flavor`, and define an Android flavor in build.gradle +/// and an Xcode scheme in the iOS build config +/// so as to set the app ID differently. +library; + +import 'package:flutter/material.dart'; + +import '../widgets/sticky_header.dart'; + +/// Example page using [StickyHeaderListView] and [StickyHeaderItem] in a +/// vertically-scrolling list. +class ExampleVertical extends StatelessWidget { + ExampleVertical({ + super.key, + required this.title, + this.reverse = false, + this.headerDirection = AxisDirection.down, + }) : assert(axisDirectionToAxis(headerDirection) == Axis.vertical); + + final String title; + final bool reverse; + final AxisDirection headerDirection; + + @override + Widget build(BuildContext context) { + final headerAtBottom = axisDirectionIsReversed(headerDirection); + + const numSections = 100; + const numPerSection = 10; + return Scaffold( + appBar: AppBar(title: Text(title)), + + // Invoke StickyHeaderListView the same way you'd invoke ListView. + // The constructor takes the same arguments. + body: StickyHeaderListView.separated( + reverse: reverse, + reverseHeader: headerAtBottom, + itemCount: numSections, + separatorBuilder: (context, i) => const SizedBox.shrink(), + + // Use StickyHeaderItem as an item widget in the ListView. + // A header will float over the item as needed in order to + // "stick" at the edge of the viewport. + // + // You can also include non-StickyHeaderItem items in the list. + // They'll behave just like in a plain ListView. + // + // Each StickyHeaderItem needs to be an item directly in the list, not + // wrapped inside other widgets that affect layout, in order to get + // the sticky-header behavior. + itemBuilder: (context, i) => StickyHeaderItem( + header: WideHeader(i: i), + child: Column( + verticalDirection: headerAtBottom + ? VerticalDirection.up : VerticalDirection.down, + children: List.generate( + numPerSection + 1, (j) { + if (j == 0) return WideHeader(i: i); + return WideItem(i: i, j: j-1); + }))))); + } +} + +/// Example page using [StickyHeaderListView] and [StickyHeaderItem] in a +/// horizontally-scrolling list. +class ExampleHorizontal extends StatelessWidget { + ExampleHorizontal({ + super.key, + required this.title, + this.reverse = false, + required this.headerDirection, + }) : assert(axisDirectionToAxis(headerDirection) == Axis.horizontal); + + final String title; + final bool reverse; + final AxisDirection headerDirection; + + @override + Widget build(BuildContext context) { + final headerAtRight = axisDirectionIsReversed(headerDirection); + const numSections = 100; + const numPerSection = 10; + return Scaffold( + appBar: AppBar(title: Text(title)), + body: StickyHeaderListView.separated( + + // StickyHeaderListView and StickyHeaderItem also work for horizontal + // scrolling. Pass `scrollDirection: Axis.horizontal` to the + // StickyHeaderListView constructor, just like for ListView. + scrollDirection: Axis.horizontal, + reverse: reverse, + reverseHeader: headerAtRight, + itemCount: numSections, + separatorBuilder: (context, i) => const SizedBox.shrink(), + itemBuilder: (context, i) => StickyHeaderItem( + header: TallHeader(i: i), + child: Row( + textDirection: headerAtRight ? TextDirection.rtl : TextDirection.ltr, + children: List.generate( + numPerSection + 1, + (j) { + if (j == 0) return TallHeader(i: i); + return TallItem(i: i, j: j-1, numPerSection: numPerSection); + }))))); + } +} + +/// An experimental example approximating the Zulip message list. +class ExampleVerticalDouble extends StatelessWidget { + const ExampleVerticalDouble({ + super.key, + required this.title, + // this.reverse = false, + // this.headerDirection = AxisDirection.down, + }); // : assert(axisDirectionToAxis(headerDirection) == Axis.vertical); + + final String title; + // final bool reverse; + // final AxisDirection headerDirection; + + @override + Widget build(BuildContext context) { + const centerSliverKey = ValueKey('center sliver'); + const numSections = 100; + const numBottomSections = 2; + const numPerSection = 10; + return Scaffold( + appBar: AppBar(title: Text(title)), + body: CustomScrollView( + semanticChildCount: numSections, + anchor: 0.5, + center: centerSliverKey, + slivers: [ + SliverStickyHeaderList( + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildBuilderDelegate( + childCount: numSections - numBottomSections, + (context, i) { + final ii = i + numBottomSections; + return StickyHeaderItem( + header: WideHeader(i: ii), + child: Column( + children: List.generate(numPerSection + 1, (j) { + if (j == 0) return WideHeader(i: ii); + return WideItem(i: ii, j: j-1); + }))); + })), + SliverStickyHeaderList( + key: centerSliverKey, + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildBuilderDelegate( + childCount: numBottomSections, + (context, i) { + final ii = numBottomSections - 1 - i; + return StickyHeaderItem( + header: WideHeader(i: ii), + child: Column( + children: List.generate(numPerSection + 1, (j) { + if (j == 0) return WideHeader(i: ii); + return WideItem(i: ii, j: j-1); + }))); + })), + ])); + } +} + +//////////////////////////////////////////////////////////////////////////// +// +// 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}); + + final int i; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.primaryContainer, + child: ListTile( + title: Text("Section ${i + 1}", + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer)))); + } +} + +class WideItem extends StatelessWidget { + const WideItem({super.key, required this.i, required this.j}); + + final int i; + final int j; + + @override + Widget build(BuildContext context) { + return ListTile(title: Text("Item ${i + 1}.${j + 1}")); + } +} + +class TallHeader extends StatelessWidget { + const TallHeader({super.key, required this.i}); + + final int i; + + @override + Widget build(BuildContext context) { + final contents = Column(children: [ + Text("Section ${i + 1}", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimaryContainer)), + const SizedBox(height: 8), + const Expanded(child: SizedBox.shrink()), + const SizedBox(height: 8), + const Text("end"), + ]); + + return Container( + alignment: Alignment.center, + child: Card( + color: Theme.of(context).colorScheme.primaryContainer, + child: Padding(padding: const EdgeInsets.all(8), child: contents))); + } +} + +class TallItem extends StatelessWidget { + const TallItem({super.key, + required this.i, + required this.j, + required this.numPerSection, + }); + + final int i; + final int j; + final int numPerSection; + + @override + Widget build(BuildContext context) { + final heightFactor = (1 + j) / numPerSection; + + final contents = Column(children: [ + Text("Item ${i + 1}.${j + 1}"), + const SizedBox(height: 8), + Expanded( + child: FractionallySizedBox( + heightFactor: heightFactor, + child: ColoredBox( + color: Theme.of(context).colorScheme.secondary, + child: const SizedBox(width: 4)))), + const SizedBox(height: 8), + const Text("end"), + ]); + + return Container( + alignment: Alignment.center, + child: Card( + child: Padding(padding: const EdgeInsets.all(8), child: contents))); + } +} + +enum _ExampleType { vertical, horizontal } + +class MainPage extends StatelessWidget { + const MainPage({super.key}); + + @override + Widget build(BuildContext context) { + final verticalItems = [ + _buildItem(context, _ExampleType.vertical, + primary: true, + title: 'Scroll down, headers at top (a standard list)', + headerDirection: AxisDirection.down), + _buildItem(context, _ExampleType.vertical, + title: 'Scroll up, headers at top', + reverse: true, + headerDirection: AxisDirection.down), + _buildItem(context, _ExampleType.vertical, + title: 'Scroll down, headers at bottom', + headerDirection: AxisDirection.up), + _buildItem(context, _ExampleType.vertical, + title: 'Scroll up, headers at bottom', + reverse: true, + headerDirection: AxisDirection.up), + ]; + final horizontalItems = [ + _buildItem(context, _ExampleType.horizontal, + title: 'Scroll right, headers at left', + headerDirection: AxisDirection.right), + _buildItem(context, _ExampleType.horizontal, + title: 'Scroll left, headers at left', + reverse: true, + headerDirection: AxisDirection.right), + _buildItem(context, _ExampleType.horizontal, + title: 'Scroll right, headers at right', + headerDirection: AxisDirection.left), + _buildItem(context, _ExampleType.horizontal, + title: 'Scroll left, headers at right', + reverse: true, + headerDirection: AxisDirection.left), + ]; + final otherItems = [ + _buildButton(context, + title: 'Double slivers', + page: ExampleVerticalDouble(title: 'Double slivers')), + ]; + return Scaffold( + appBar: AppBar(title: const Text('Sticky Headers example')), + body: CustomScrollView(slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 24), + child: Center( + child: Text("Vertical lists", + style: Theme.of(context).textTheme.headlineMedium)))), + SliverPadding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + sliver: SliverGrid.count( + childAspectRatio: 2, + crossAxisCount: 2, + children: verticalItems)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 24), + child: Center( + child: Text("Horizontal lists", + style: Theme.of(context).textTheme.headlineMedium)))), + SliverPadding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + sliver: SliverGrid.count( + childAspectRatio: 2, + crossAxisCount: 2, + children: horizontalItems)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 24), + child: Center( + child: Text("Other examples", + style: Theme.of(context).textTheme.headlineMedium)))), + SliverPadding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + sliver: SliverGrid.count( + childAspectRatio: 2, + crossAxisCount: 2, + children: otherItems)), + ])); + } + + Widget _buildItem(BuildContext context, _ExampleType exampleType, { + required String title, + bool reverse = false, + required AxisDirection headerDirection, + bool primary = false, + }) { + Widget page; + switch (exampleType) { + case _ExampleType.vertical: + page = ExampleVertical( + title: title, reverse: reverse, headerDirection: headerDirection); + break; + case _ExampleType.horizontal: + page = ExampleHorizontal( + title: title, reverse: reverse, headerDirection: headerDirection); + break; + } + return _buildButton(context, title: title, page: page); + } + + Widget _buildButton(BuildContext context, { + bool primary = false, + required String title, + required Widget page, + }) { + var label = Text(title, + textAlign: TextAlign.center, + style: TextStyle( + inherit: true, + fontSize: Theme.of(context).textTheme.titleMedium?.fontSize)); + var buttonStyle = primary + ? null + : ElevatedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.onSecondary, + backgroundColor: Theme.of(context).colorScheme.secondary); + return Container( + padding: const EdgeInsets.all(16), + child: ElevatedButton( + style: buttonStyle, + onPressed: () => Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => page)), + child: label)); + } +} + +class ExampleApp extends StatelessWidget { + const ExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Sticky Headers example', + theme: ThemeData( + colorScheme: + ColorScheme.fromSeed(seedColor: const Color(0xff3366cc))), + home: const MainPage(), + ); + } +} + +void main() { + runApp(const ExampleApp()); +} diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 6ff41633fd..3cbd917563 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -189,6 +189,12 @@ abstract class ZulipLocalizations { /// **'Send direct message'** String get profileButtonSendDirectMessage; + /// Message that appears on the user profile page when the profile cannot be shown. + /// + /// In en, this message translates to: + /// **'Could not show user profile.'** + String get errorCouldNotShowUserProfile; + /// Title for dialog asking the user to grant additional permissions. /// /// In en, this message translates to: @@ -237,6 +243,30 @@ abstract class ZulipLocalizations { /// **'Unfollow topic'** String get actionSheetOptionUnfollowTopic; + /// Label for the 'Mark as resolved' button on the topic action sheet. + /// + /// In en, this message translates to: + /// **'Mark as resolved'** + String get actionSheetOptionResolveTopic; + + /// Label for the 'Mark as unresolved' button on the topic action sheet. + /// + /// In en, this message translates to: + /// **'Mark as unresolved'** + String get actionSheetOptionUnresolveTopic; + + /// Error title when marking a topic as resolved failed. + /// + /// In en, this message translates to: + /// **'Failed to mark topic as resolved'** + String get errorResolveTopicFailedTitle; + + /// Error title when marking a topic as unresolved failed. + /// + /// In en, this message translates to: + /// **'Failed to mark topic as unresolved'** + String get errorUnresolveTopicFailedTitle; + /// Label for copy message text button on action sheet. /// /// In en, this message translates to: @@ -321,6 +351,12 @@ abstract class ZulipLocalizations { /// **'Failed to upload file: {filename}'** String errorFailedToUploadFileTitle(String filename); + /// The name of a file, and its size in mebibytes. + /// + /// In en, this message translates to: + /// **'{filename}: {size} MiB'** + String filenameAndSizeInMiB(String filename, String size); + /// Error message when attached files are too large in size. /// /// In en, this message translates to: @@ -405,6 +441,18 @@ abstract class ZulipLocalizations { /// **'Error handling a Zulip event from {serverUrl}; will retry.\n\nError: {error}\n\nEvent: {event}'** String errorHandlingEventDetails(String serverUrl, String error, String event); + /// Error title when opening a link failed. + /// + /// In en, this message translates to: + /// **'Unable to open link'** + String get errorCouldNotOpenLinkTitle; + + /// Error message when opening a link failed. + /// + /// In en, this message translates to: + /// **'Link could not be opened: {url}'** + String errorCouldNotOpenLink(String url); + /// Error message when muting a topic failed. /// /// In en, this message translates to: @@ -535,7 +583,7 @@ abstract class ZulipLocalizations { /// /// In en, this message translates to: /// **'(unknown channel)'** - String get composeBoxUnknownChannelName; + String get unknownChannelName; /// Hint text for topic input widget in compose box. /// @@ -549,18 +597,36 @@ abstract class ZulipLocalizations { /// **'Uploading {filename}…'** String composeBoxUploadingFilename(String filename); + /// Placeholder in compose box showing the quoted message is currently loading. + /// + /// In en, this message translates to: + /// **'(loading message {messageId})'** + String composeBoxLoadingMessage(int messageId); + /// Name placeholder to use for a user when we don't know their name. /// /// In en, this message translates to: /// **'(unknown user)'** String get unknownUserName; + /// Message list page title for a DM group that only includes yourself. + /// + /// In en, this message translates to: + /// **'DMs with yourself'** + String get dmsWithYourselfPageTitle; + /// Message list recipient header for a DM group with others. /// /// In en, this message translates to: /// **'You and {others}'** String messageListGroupYouAndOthers(String others); + /// Message list page title for a DM group with others. + /// + /// In en, this message translates to: + /// **'DMs with {others}'** + String dmsWithOthersPageTitle(String others); + /// Message list recipient header for a DM group that only includes yourself. /// /// In en, this message translates to: @@ -633,6 +699,18 @@ abstract class ZulipLocalizations { /// **'Copy link'** String get lightboxCopyLinkTooltip; + /// The current playback position of the video playing in the lightbox. + /// + /// In en, this message translates to: + /// **'Current position'** + String get lightboxVideoCurrentPosition; + + /// The total duration of the video playing in the lightbox. + /// + /// In en, this message translates to: + /// **'Video duration'** + String get lightboxVideoDuration; + /// Title for login page. /// /// In en, this message translates to: @@ -663,11 +741,11 @@ abstract class ZulipLocalizations { /// **'Add an account'** String get loginAddAnAccountPageTitle; - /// Input label in login page for Zulip server URL entry. + /// Label in login page for Zulip server URL entry. /// /// In en, this message translates to: /// **'Your Zulip server URL'** - String get loginServerUrlInputLabel; + String get loginServerUrlLabel; /// Icon label for button to hide password in input form. /// @@ -891,6 +969,12 @@ abstract class ZulipLocalizations { /// **'Direct messages'** String get recentDmConversationsPageTitle; + /// Heading for direct messages section on the 'Inbox' message view. + /// + /// In en, this message translates to: + /// **'Direct messages'** + String get recentDmConversationsSectionHeader; + /// Page title for the 'Combined feed' message view. /// /// In en, this message translates to: @@ -933,12 +1017,36 @@ abstract class ZulipLocalizations { /// **'{senderFullName} to you and {numOthers, plural, =1{1 other} other{{numOthers} others}}'** String notifGroupDmConversationLabel(String senderFullName, int numOthers); + /// Label for the list of pinned subscribed channels. + /// + /// In en, this message translates to: + /// **'Pinned'** + String get pinnedSubscriptionsLabel; + + /// Label for the list of unpinned subscribed channels. + /// + /// In en, this message translates to: + /// **'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: /// **'You'** String get notifSelfUser; + /// Display name for the user themself, to show on an emoji reaction added by the user. + /// + /// In en, this message translates to: + /// **'You'** + String get reactedEmojiSelfUser; + /// Text to display when there is one user typing. /// /// In en, this message translates to: @@ -957,6 +1065,60 @@ abstract class ZulipLocalizations { /// **'Several people are typing…'** String get manyPeopleTyping; + /// Text for "@all" wildcard-mention autocomplete option when writing a channel or DM message. + /// + /// In en, this message translates to: + /// **'all'** + String get wildcardMentionAll; + + /// Text for "@everyone" wildcard-mention autocomplete option when writing a channel or DM message. + /// + /// In en, this message translates to: + /// **'everyone'** + String get wildcardMentionEveryone; + + /// Text for "@channel" wildcard-mention autocomplete option when writing a channel message. + /// + /// In en, this message translates to: + /// **'channel'** + String get wildcardMentionChannel; + + /// Text for "@stream" wildcard-mention autocomplete option when writing a channel message in older servers. + /// + /// In en, this message translates to: + /// **'stream'** + String get wildcardMentionStream; + + /// Text for "@topic" wildcard-mention autocomplete option when writing a channel message. + /// + /// In en, this message translates to: + /// **'topic'** + String get wildcardMentionTopic; + + /// Description for "@all", "@everyone", "@channel", and "@stream" wildcard-mention autocomplete options when writing a channel message. + /// + /// In en, this message translates to: + /// **'Notify channel'** + String get wildcardMentionChannelDescription; + + /// Description for "@all", "@everyone", and "@stream" wildcard-mention autocomplete options when writing a channel message in older servers. + /// + /// In en, this message translates to: + /// **'Notify stream'** + String get wildcardMentionStreamDescription; + + /// Description for "@all" and "@everyone" wildcard-mention autocomplete options when writing a DM message. + /// + /// In en, this message translates to: + /// **'Notify recipients'** + String get wildcardMentionAllDmDescription; + + /// Description for "@topic" wildcard-mention autocomplete options when writing a channel message. + /// + /// In en, this message translates to: + /// **'Notify topic'** + String get wildcardMentionTopicDescription; + /// Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) /// /// In en, this message translates to: @@ -969,6 +1131,12 @@ abstract class ZulipLocalizations { /// **'MOVED'** String get messageIsMovedLabel; + /// The list of people who voted for a poll option, wrapped in parentheses. + /// + /// In en, this message translates to: + /// **'({voterNames})'** + String pollVoterNames(String voterNames); + /// Text to display for a poll when the question is missing /// /// In en, this message translates to: @@ -1016,6 +1184,30 @@ abstract class ZulipLocalizations { /// In en, this message translates to: /// **'Search emoji'** String get emojiPickerSearchEmoji; + + /// Text to show at the start of a message list if there are no earlier messages. + /// + /// In en, this message translates to: + /// **'No earlier messages'** + String get noEarlierMessages; + + /// Tooltip for button to scroll to bottom. + /// + /// In en, this message translates to: + /// **'Scroll to bottom'** + String get scrollToBottomTooltip; + + /// Placeholder to show in place of the app version when it is unknown. + /// + /// In en, this message translates to: + /// **'(…)'** + String get appVersionUnknownPlaceholder; + + /// The name of Zulip. This should be either 'Zulip' or a transliteration. + /// + /// In en, this message translates to: + /// **'Zulip'** + String get zulipAppTitle; } class _ZulipLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 542b85031b..0304fd3e6f 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -52,6 +52,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get profileButtonSendDirectMessage => 'Send direct message'; + @override + String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + @override String get permissionsNeededTitle => 'Permissions needed'; @@ -76,6 +79,18 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @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'; @@ -122,6 +137,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations { 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( @@ -188,6 +208,14 @@ class ZulipLocalizationsAr extends ZulipLocalizations { 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'; @@ -256,7 +284,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get composeBoxSendTooltip => 'Send'; @override - String get composeBoxUnknownChannelName => '(unknown channel)'; + String get unknownChannelName => '(unknown channel)'; @override String get composeBoxTopicHintText => 'Topic'; @@ -266,14 +294,27 @@ class ZulipLocalizationsAr extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String composeBoxLoadingMessage(int messageId) { + return '(loading message $messageId)'; + } + @override String get unknownUserName => '(unknown user)'; + @override + String get dmsWithYourselfPageTitle => 'DMs with yourself'; + @override String messageListGroupYouAndOthers(String others) { return 'You and $others'; } + @override + String dmsWithOthersPageTitle(String others) { + return 'DMs with $others'; + } + @override String get messageListGroupYouWithYourself => 'You with yourself'; @@ -310,6 +351,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get lightboxCopyLinkTooltip => 'Copy link'; + @override + String get lightboxVideoCurrentPosition => 'Current position'; + + @override + String get lightboxVideoDuration => 'Video duration'; + @override String get loginPageTitle => 'Log in'; @@ -328,7 +375,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Add an account'; @override - String get loginServerUrlInputLabel => 'Your Zulip server URL'; + String get loginServerUrlLabel => 'Your Zulip server URL'; @override String get loginHidePassword => 'Hide password'; @@ -463,6 +510,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get recentDmConversationsPageTitle => 'Direct messages'; + @override + String get recentDmConversationsSectionHeader => 'Direct messages'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -492,9 +542,21 @@ class ZulipLocalizationsAr extends ZulipLocalizations { return '$senderFullName to you and $_temp0'; } + @override + String get pinnedSubscriptionsLabel => 'Pinned'; + + @override + String get unpinnedSubscriptionsLabel => 'Unpinned'; + + @override + String get subscriptionListNoChannels => 'No channels found'; + @override String get notifSelfUser => 'You'; + @override + String get reactedEmojiSelfUser => 'You'; + @override String onePersonTyping(String typist) { return '$typist is typing…'; @@ -508,12 +570,44 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get manyPeopleTyping => 'Several people are typing…'; + @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 => 'EDITED'; @override String get messageIsMovedLabel => 'MOVED'; + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + @override String get pollWidgetQuestionMissing => 'No question.'; @@ -537,4 +631,16 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get noEarlierMessages => 'No earlier messages'; + + @override + String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index b6bc9f72e7..7af8cd7bab 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -52,6 +52,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get profileButtonSendDirectMessage => 'Send direct message'; + @override + String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + @override String get permissionsNeededTitle => 'Permissions needed'; @@ -76,6 +79,18 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @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'; @@ -122,6 +137,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations { 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( @@ -188,6 +208,14 @@ class ZulipLocalizationsEn extends ZulipLocalizations { 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'; @@ -256,7 +284,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get composeBoxSendTooltip => 'Send'; @override - String get composeBoxUnknownChannelName => '(unknown channel)'; + String get unknownChannelName => '(unknown channel)'; @override String get composeBoxTopicHintText => 'Topic'; @@ -266,14 +294,27 @@ class ZulipLocalizationsEn extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String composeBoxLoadingMessage(int messageId) { + return '(loading message $messageId)'; + } + @override String get unknownUserName => '(unknown user)'; + @override + String get dmsWithYourselfPageTitle => 'DMs with yourself'; + @override String messageListGroupYouAndOthers(String others) { return 'You and $others'; } + @override + String dmsWithOthersPageTitle(String others) { + return 'DMs with $others'; + } + @override String get messageListGroupYouWithYourself => 'You with yourself'; @@ -310,6 +351,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get lightboxCopyLinkTooltip => 'Copy link'; + @override + String get lightboxVideoCurrentPosition => 'Current position'; + + @override + String get lightboxVideoDuration => 'Video duration'; + @override String get loginPageTitle => 'Log in'; @@ -328,7 +375,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Add an account'; @override - String get loginServerUrlInputLabel => 'Your Zulip server URL'; + String get loginServerUrlLabel => 'Your Zulip server URL'; @override String get loginHidePassword => 'Hide password'; @@ -463,6 +510,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get recentDmConversationsPageTitle => 'Direct messages'; + @override + String get recentDmConversationsSectionHeader => 'Direct messages'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -492,9 +542,21 @@ class ZulipLocalizationsEn extends ZulipLocalizations { return '$senderFullName to you and $_temp0'; } + @override + String get pinnedSubscriptionsLabel => 'Pinned'; + + @override + String get unpinnedSubscriptionsLabel => 'Unpinned'; + + @override + String get subscriptionListNoChannels => 'No channels found'; + @override String get notifSelfUser => 'You'; + @override + String get reactedEmojiSelfUser => 'You'; + @override String onePersonTyping(String typist) { return '$typist is typing…'; @@ -508,12 +570,44 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @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 pollVoterNames(String voterNames) { + return '($voterNames)'; + } + @override String get pollWidgetQuestionMissing => 'No question.'; @@ -537,4 +631,16 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get noEarlierMessages => 'No earlier messages'; + + @override + String get scrollToBottomTooltip => 'Scroll to bottom'; + + @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 7adbc9ae8a..6ac34645e2 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -52,6 +52,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get profileButtonSendDirectMessage => 'ダイレクトメッセージを送信'; + @override + String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + @override String get permissionsNeededTitle => 'Permissions needed'; @@ -76,6 +79,18 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @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'; @@ -122,6 +137,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations { 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( @@ -188,6 +208,14 @@ class ZulipLocalizationsJa extends ZulipLocalizations { 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'; @@ -256,7 +284,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get composeBoxSendTooltip => 'Send'; @override - String get composeBoxUnknownChannelName => '(unknown channel)'; + String get unknownChannelName => '(unknown channel)'; @override String get composeBoxTopicHintText => 'Topic'; @@ -266,14 +294,27 @@ class ZulipLocalizationsJa extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String composeBoxLoadingMessage(int messageId) { + return '(loading message $messageId)'; + } + @override String get unknownUserName => '(unknown user)'; + @override + String get dmsWithYourselfPageTitle => 'DMs with yourself'; + @override String messageListGroupYouAndOthers(String others) { return 'You and $others'; } + @override + String dmsWithOthersPageTitle(String others) { + return 'DMs with $others'; + } + @override String get messageListGroupYouWithYourself => 'You with yourself'; @@ -310,6 +351,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get lightboxCopyLinkTooltip => 'Copy link'; + @override + String get lightboxVideoCurrentPosition => 'Current position'; + + @override + String get lightboxVideoDuration => 'Video duration'; + @override String get loginPageTitle => 'Log in'; @@ -328,7 +375,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Add an account'; @override - String get loginServerUrlInputLabel => 'Your Zulip server URL'; + String get loginServerUrlLabel => 'Your Zulip server URL'; @override String get loginHidePassword => 'Hide password'; @@ -463,6 +510,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get recentDmConversationsPageTitle => 'Direct messages'; + @override + String get recentDmConversationsSectionHeader => 'Direct messages'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -492,9 +542,21 @@ class ZulipLocalizationsJa extends ZulipLocalizations { return '$senderFullName to you and $_temp0'; } + @override + String get pinnedSubscriptionsLabel => 'Pinned'; + + @override + String get unpinnedSubscriptionsLabel => 'Unpinned'; + + @override + String get subscriptionListNoChannels => 'No channels found'; + @override String get notifSelfUser => 'You'; + @override + String get reactedEmojiSelfUser => 'You'; + @override String onePersonTyping(String typist) { return '$typist is typing…'; @@ -508,12 +570,44 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @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 pollVoterNames(String voterNames) { + return '($voterNames)'; + } + @override String get pollWidgetQuestionMissing => 'No question.'; @@ -537,4 +631,16 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get noEarlierMessages => 'No earlier messages'; + + @override + String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 99c545f98e..b3360e9f62 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -9,13 +9,13 @@ class ZulipLocalizationsNb extends ZulipLocalizations { ZulipLocalizationsNb([String locale = 'nb']) : super(locale); @override - String get aboutPageTitle => 'About Zulip'; + String get aboutPageTitle => 'Om Zulip'; @override - String get aboutPageAppVersion => 'App version'; + String get aboutPageAppVersion => 'App versjon'; @override - String get aboutPageOpenSourceLicenses => 'Open-source licenses'; + String get aboutPageOpenSourceLicenses => 'Lisenser for åpen kildekode'; @override String get aboutPageTapToView => 'Tap to view'; @@ -52,6 +52,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get profileButtonSendDirectMessage => 'Send direct message'; + @override + String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + @override String get permissionsNeededTitle => 'Permissions needed'; @@ -76,6 +79,18 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @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'; @@ -122,6 +137,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations { 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( @@ -188,6 +208,14 @@ class ZulipLocalizationsNb extends ZulipLocalizations { 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'; @@ -256,7 +284,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get composeBoxSendTooltip => 'Send'; @override - String get composeBoxUnknownChannelName => '(unknown channel)'; + String get unknownChannelName => '(unknown channel)'; @override String get composeBoxTopicHintText => 'Topic'; @@ -266,14 +294,27 @@ class ZulipLocalizationsNb extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String composeBoxLoadingMessage(int messageId) { + return '(loading message $messageId)'; + } + @override String get unknownUserName => '(unknown user)'; + @override + String get dmsWithYourselfPageTitle => 'DMs with yourself'; + @override String messageListGroupYouAndOthers(String others) { return 'You and $others'; } + @override + String dmsWithOthersPageTitle(String others) { + return 'DMs with $others'; + } + @override String get messageListGroupYouWithYourself => 'You with yourself'; @@ -310,6 +351,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get lightboxCopyLinkTooltip => 'Copy link'; + @override + String get lightboxVideoCurrentPosition => 'Current position'; + + @override + String get lightboxVideoDuration => 'Video duration'; + @override String get loginPageTitle => 'Log in'; @@ -328,7 +375,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Add an account'; @override - String get loginServerUrlInputLabel => 'Your Zulip server URL'; + String get loginServerUrlLabel => 'Your Zulip server URL'; @override String get loginHidePassword => 'Hide password'; @@ -463,6 +510,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get recentDmConversationsPageTitle => 'Direct messages'; + @override + String get recentDmConversationsSectionHeader => 'Direct messages'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -492,9 +542,21 @@ class ZulipLocalizationsNb extends ZulipLocalizations { return '$senderFullName to you and $_temp0'; } + @override + String get pinnedSubscriptionsLabel => 'Pinned'; + + @override + String get unpinnedSubscriptionsLabel => 'Unpinned'; + + @override + String get subscriptionListNoChannels => 'No channels found'; + @override String get notifSelfUser => 'You'; + @override + String get reactedEmojiSelfUser => 'You'; + @override String onePersonTyping(String typist) { return '$typist is typing…'; @@ -508,12 +570,44 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @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 pollVoterNames(String voterNames) { + return '($voterNames)'; + } + @override String get pollWidgetQuestionMissing => 'No question.'; @@ -537,4 +631,16 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get noEarlierMessages => 'No earlier messages'; + + @override + String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index e7a05a58aa..22a56e5a0c 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -52,6 +52,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get profileButtonSendDirectMessage => 'Wyślij wiadomość bezpośrednią'; + @override + String get errorCouldNotShowUserProfile => 'Nie udało się wyświetlić profilu.'; + @override String get permissionsNeededTitle => 'Wymagane uprawnienia'; @@ -76,6 +79,18 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get actionSheetOptionUnfollowTopic => 'Nie śledź wątku'; + @override + String get actionSheetOptionResolveTopic => 'Oznacz jako rozwiązany'; + + @override + String get actionSheetOptionUnresolveTopic => 'Oznacz brak rozwiązania'; + + @override + String get errorResolveTopicFailedTitle => 'Nie udało się oznaczyć jako rozwiązany'; + + @override + String get errorUnresolveTopicFailedTitle => 'Nie udało się oznaczyć brak rozwiązania'; + @override String get actionSheetOptionCopyMessageText => 'Skopiuj tekst wiadomości'; @@ -122,6 +137,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations { return 'Nie udało się załadować pliku: $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( @@ -188,6 +208,14 @@ class ZulipLocalizationsPl extends ZulipLocalizations { return 'Błąd zdarzenia Zulip z $serverUrl; ponawiam.\n\nBłąd: $error\n\nZdarzenie: $event'; } + @override + String get errorCouldNotOpenLinkTitle => 'Nie udało się otworzyć odnośnika'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Nie można otworzyć: $url'; + } + @override String get errorMuteTopicFailed => 'Wyciszenie bez powodzenia'; @@ -256,7 +284,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get composeBoxSendTooltip => 'Wyślij'; @override - String get composeBoxUnknownChannelName => '(nieznany kanał)'; + String get unknownChannelName => '(nieznany kanał)'; @override String get composeBoxTopicHintText => 'Wątek'; @@ -266,14 +294,27 @@ class ZulipLocalizationsPl extends ZulipLocalizations { return 'Przekazywanie $filename…'; } + @override + String composeBoxLoadingMessage(int messageId) { + return '(ładowanie wiadomości $messageId)'; + } + @override String get unknownUserName => '(nieznany użytkownik)'; + @override + String get dmsWithYourselfPageTitle => 'DM do siebie'; + @override String messageListGroupYouAndOthers(String others) { return 'Ty i $others'; } + @override + String dmsWithOthersPageTitle(String others) { + return 'DM z $others'; + } + @override String get messageListGroupYouWithYourself => 'Ty ze sobą'; @@ -310,6 +351,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get lightboxCopyLinkTooltip => 'Skopiuj odnośnik'; + @override + String get lightboxVideoCurrentPosition => 'Obecna pozycja'; + + @override + String get lightboxVideoDuration => 'Długość wideo'; + @override String get loginPageTitle => 'Zaloguj'; @@ -328,7 +375,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Dodaj konto'; @override - String get loginServerUrlInputLabel => 'URL serwera Zulip'; + String get loginServerUrlLabel => 'URL serwera Zulip'; @override String get loginHidePassword => 'Ukryj hasło'; @@ -463,6 +510,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get recentDmConversationsPageTitle => 'Wiadomości bezpośrednie'; + @override + String get recentDmConversationsSectionHeader => 'Wiadomości bezpośrednie'; + @override String get combinedFeedPageTitle => 'Mieszany widok'; @@ -492,9 +542,21 @@ class ZulipLocalizationsPl extends ZulipLocalizations { return '$senderFullName do ciebie i $_temp0'; } + @override + String get pinnedSubscriptionsLabel => 'Przypięte'; + + @override + String get unpinnedSubscriptionsLabel => 'Odpięte'; + + @override + String get subscriptionListNoChannels => 'Nie odnaleziono kanałów'; + @override String get notifSelfUser => 'Ty'; + @override + String get reactedEmojiSelfUser => 'Ty'; + @override String onePersonTyping(String typist) { return '$typist coś pisze…'; @@ -508,12 +570,44 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get manyPeopleTyping => 'Wielu ludzi coś pisze…'; + @override + String get wildcardMentionAll => 'wszyscy'; + + @override + String get wildcardMentionEveryone => 'każdy'; + + @override + String get wildcardMentionChannel => 'kanał'; + + @override + String get wildcardMentionStream => 'strumień'; + + @override + String get wildcardMentionTopic => 'wątek'; + + @override + String get wildcardMentionChannelDescription => 'Powiadom w kanale'; + + @override + String get wildcardMentionStreamDescription => 'Powiadom w strumieniu'; + + @override + String get wildcardMentionAllDmDescription => 'Powiadom zainteresowanych'; + + @override + String get wildcardMentionTopicDescription => 'Powiadom w wątku'; + @override String get messageIsEditedLabel => 'ZMIENIONO'; @override String get messageIsMovedLabel => 'PRZENIESIONO'; + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + @override String get pollWidgetQuestionMissing => 'Brak pytania.'; @@ -537,4 +631,16 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Szukaj emoji'; + + @override + String get noEarlierMessages => 'Brak historii'; + + @override + String get scrollToBottomTooltip => 'Przewiń do dołu'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 2082984588..babbc976fd 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -52,6 +52,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get profileButtonSendDirectMessage => 'Отправить личное сообщение'; + @override + String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + @override String get permissionsNeededTitle => 'Требуются разрешения'; @@ -76,6 +79,18 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get actionSheetOptionUnfollowTopic => 'Не отслеживать тему'; + @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 => 'Скопировать текст сообщения'; @@ -122,6 +137,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations { 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( @@ -188,6 +208,14 @@ class ZulipLocalizationsRu extends ZulipLocalizations { return 'Ошибка обработки события Zulip от $serverUrl; повторим попытку.\n\nОшибка: $error\n\nСобытие: $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 => 'Не удалось отключить тему'; @@ -256,7 +284,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get composeBoxSendTooltip => 'Отправить'; @override - String get composeBoxUnknownChannelName => '(неизвестный канал)'; + String get unknownChannelName => '(unknown channel)'; @override String get composeBoxTopicHintText => 'Тема'; @@ -266,14 +294,27 @@ class ZulipLocalizationsRu extends ZulipLocalizations { return 'Загрузка $filename…'; } + @override + String composeBoxLoadingMessage(int messageId) { + return '(loading message $messageId)'; + } + @override String get unknownUserName => '(неизвестный пользователь)'; + @override + String get dmsWithYourselfPageTitle => 'DMs with yourself'; + @override String messageListGroupYouAndOthers(String others) { return 'Вы и $others'; } + @override + String dmsWithOthersPageTitle(String others) { + return 'DMs with $others'; + } + @override String get messageListGroupYouWithYourself => 'Вы с собой'; @@ -310,6 +351,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get lightboxCopyLinkTooltip => 'Скопировать ссылку'; + @override + String get lightboxVideoCurrentPosition => 'Current position'; + + @override + String get lightboxVideoDuration => 'Video duration'; + @override String get loginPageTitle => 'Вход в систему'; @@ -328,7 +375,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Добавление учетной записи'; @override - String get loginServerUrlInputLabel => 'URL вашего сервера Zulip'; + String get loginServerUrlLabel => 'URL вашего сервера Zulip'; @override String get loginHidePassword => 'Скрыть пароль'; @@ -463,6 +510,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get recentDmConversationsPageTitle => 'Личные сообщения'; + @override + String get recentDmConversationsSectionHeader => 'Direct messages'; + @override String get combinedFeedPageTitle => 'Объединенная лента'; @@ -492,9 +542,21 @@ class ZulipLocalizationsRu extends ZulipLocalizations { return '$senderFullName вам и еще $_temp0'; } + @override + String get pinnedSubscriptionsLabel => 'Pinned'; + + @override + String get unpinnedSubscriptionsLabel => 'Unpinned'; + + @override + String get subscriptionListNoChannels => 'No channels found'; + @override String get notifSelfUser => 'Вы'; + @override + String get reactedEmojiSelfUser => 'You'; + @override String onePersonTyping(String typist) { return '$typist набирает сообщение…'; @@ -508,12 +570,44 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get manyPeopleTyping => 'Несколько человек набирают сообщения…'; + @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 => 'ИЗМЕНЕНО'; @override String get messageIsMovedLabel => 'ПЕРЕМЕЩЕНО'; + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + @override String get pollWidgetQuestionMissing => 'Нет вопроса.'; @@ -537,4 +631,16 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Поиск эмодзи'; + + @override + String get noEarlierMessages => 'No earlier messages'; + + @override + String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index fabfa06eb4..964dbc29ad 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -52,6 +52,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get profileButtonSendDirectMessage => 'Poslať priamu správu'; + @override + String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + @override String get permissionsNeededTitle => 'Permissions needed'; @@ -76,6 +79,18 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionUnfollowTopic => 'Prestať sledovať tému'; + @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 => 'Skopírovať text správy'; @@ -122,6 +137,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations { return 'Nepodarilo sa nahrať súbor: $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( @@ -188,6 +208,14 @@ class ZulipLocalizationsSk extends ZulipLocalizations { return 'Chyba obsluhy Zulip udalosti na serveri $serverUrl; skúsim znovu.\n\nChyba: $error\n\nUdalosť: $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 => 'Nepodarilo sa ztíšiť tému'; @@ -256,7 +284,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get composeBoxSendTooltip => 'Send'; @override - String get composeBoxUnknownChannelName => '(unknown channel)'; + String get unknownChannelName => '(unknown channel)'; @override String get composeBoxTopicHintText => 'Topic'; @@ -266,14 +294,27 @@ class ZulipLocalizationsSk extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String composeBoxLoadingMessage(int messageId) { + return '(loading message $messageId)'; + } + @override String get unknownUserName => '(unknown user)'; + @override + String get dmsWithYourselfPageTitle => 'DMs with yourself'; + @override String messageListGroupYouAndOthers(String others) { return 'You and $others'; } + @override + String dmsWithOthersPageTitle(String others) { + return 'DMs with $others'; + } + @override String get messageListGroupYouWithYourself => 'You with yourself'; @@ -310,6 +351,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get lightboxCopyLinkTooltip => 'Skopírovať odkaz'; + @override + String get lightboxVideoCurrentPosition => 'Current position'; + + @override + String get lightboxVideoDuration => 'Video duration'; + @override String get loginPageTitle => 'Prihlásiť sa'; @@ -328,7 +375,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Pridať účet'; @override - String get loginServerUrlInputLabel => 'Adresa vášho Zulip servera'; + String get loginServerUrlLabel => 'Adresa vášho Zulip servera'; @override String get loginHidePassword => 'Skryť heslo'; @@ -463,6 +510,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get recentDmConversationsPageTitle => 'Priama správa'; + @override + String get recentDmConversationsSectionHeader => 'Direct messages'; + @override String get combinedFeedPageTitle => 'Zlúčený kanál'; @@ -492,9 +542,21 @@ class ZulipLocalizationsSk extends ZulipLocalizations { return '$senderFullName to you and $_temp0'; } + @override + String get pinnedSubscriptionsLabel => 'Pinned'; + + @override + String get unpinnedSubscriptionsLabel => 'Unpinned'; + + @override + String get subscriptionListNoChannels => 'No channels found'; + @override String get notifSelfUser => 'Ty'; + @override + String get reactedEmojiSelfUser => 'You'; + @override String onePersonTyping(String typist) { return '$typist píše…'; @@ -508,12 +570,44 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get manyPeopleTyping => 'Niekoľko ludí píše…'; + @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 => 'UPRAVENÉ'; @override String get messageIsMovedLabel => 'PRESUNUTÉ'; + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + @override String get pollWidgetQuestionMissing => 'Bez otázky.'; @@ -537,4 +631,16 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Hľadať emotikon'; + + @override + String get noEarlierMessages => 'No earlier messages'; + + @override + String get scrollToBottomTooltip => 'Scroll to bottom'; + + @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 54825750ca..7b53559e64 100644 --- a/lib/host/android_notifications.g.dart +++ b/lib/host/android_notifications.g.dart @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v20.0.2), do not edit directly. +// Autogenerated from Pigeon (v22.7.2), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers @@ -256,7 +256,7 @@ class MessagingStyle { String? conversationTitle; - List messages; + List messages; bool isGroupConversation; @@ -274,7 +274,7 @@ class MessagingStyle { return MessagingStyle( user: result[0]! as Person, conversationTitle: result[1] as String?, - messages: (result[2] as List?)!.cast(), + messages: (result[2] as List?)!.cast(), isGroupConversation: result[3]! as bool, ); } @@ -291,7 +291,7 @@ class Notification { String group; - Map extras; + Map extras; Object encode() { return [ @@ -304,7 +304,7 @@ class Notification { result as List; return Notification( group: result[0]! as String, - extras: (result[1] as Map?)!.cast(), + extras: (result[1] as Map?)!.cast(), ); } } @@ -390,34 +390,37 @@ class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override void writeValue(WriteBuffer buffer, Object? value) { - if (value is NotificationChannel) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is NotificationChannel) { buffer.putUint8(129); writeValue(buffer, value.encode()); - } else if (value is AndroidIntent) { + } else if (value is AndroidIntent) { buffer.putUint8(130); writeValue(buffer, value.encode()); - } else if (value is PendingIntent) { + } else if (value is PendingIntent) { buffer.putUint8(131); writeValue(buffer, value.encode()); - } else if (value is InboxStyle) { + } else if (value is InboxStyle) { buffer.putUint8(132); writeValue(buffer, value.encode()); - } else if (value is Person) { + } else if (value is Person) { buffer.putUint8(133); writeValue(buffer, value.encode()); - } else if (value is MessagingStyleMessage) { + } else if (value is MessagingStyleMessage) { buffer.putUint8(134); writeValue(buffer, value.encode()); - } else if (value is MessagingStyle) { + } else if (value is MessagingStyle) { buffer.putUint8(135); writeValue(buffer, value.encode()); - } else if (value is Notification) { + } else if (value is Notification) { buffer.putUint8(136); writeValue(buffer, value.encode()); - } else if (value is StatusBarNotification) { + } else if (value is StatusBarNotification) { buffer.putUint8(137); writeValue(buffer, value.encode()); - } else if (value is StoredNotificationSound) { + } else if (value is StoredNotificationSound) { buffer.putUint8(138); writeValue(buffer, value.encode()); } else { @@ -459,33 +462,33 @@ class AndroidNotificationHostApi { /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. AndroidNotificationHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) - : __pigeon_binaryMessenger = binaryMessenger, - __pigeon_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; - final BinaryMessenger? __pigeon_binaryMessenger; + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); - final String __pigeon_messageChannelSuffix; + final String pigeonVar_messageChannelSuffix; /// Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`. /// /// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat) Future createNotificationChannel(NotificationChannel channel) async { - final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.createNotificationChannel$__pigeon_messageChannelSuffix'; - final BasicMessageChannel __pigeon_channel = BasicMessageChannel( - __pigeon_channelName, + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.createNotificationChannel$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, pigeonChannelCodec, - binaryMessenger: __pigeon_binaryMessenger, + binaryMessenger: pigeonVar_binaryMessenger, ); - final List? __pigeon_replyList = - await __pigeon_channel.send([channel]) as List?; - if (__pigeon_replyList == null) { - throw _createConnectionError(__pigeon_channelName); - } else if (__pigeon_replyList.length > 1) { + final List? pigeonVar_replyList = + await pigeonVar_channel.send([channel]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: __pigeon_replyList[0]! as String, - message: __pigeon_replyList[1] as String?, - details: __pigeon_replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); } else { return; @@ -495,30 +498,30 @@ class AndroidNotificationHostApi { /// Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`. /// /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat() - Future> getNotificationChannels() async { - final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getNotificationChannels$__pigeon_messageChannelSuffix'; - final BasicMessageChannel __pigeon_channel = BasicMessageChannel( - __pigeon_channelName, + Future> getNotificationChannels() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getNotificationChannels$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, pigeonChannelCodec, - binaryMessenger: __pigeon_binaryMessenger, + binaryMessenger: pigeonVar_binaryMessenger, ); - final List? __pigeon_replyList = - await __pigeon_channel.send(null) as List?; - if (__pigeon_replyList == null) { - throw _createConnectionError(__pigeon_channelName); - } else if (__pigeon_replyList.length > 1) { + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: __pigeon_replyList[0]! as String, - message: __pigeon_replyList[1] as String?, - details: __pigeon_replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); - } else if (__pigeon_replyList[0] == null) { + } else if (pigeonVar_replyList[0] == null) { throw PlatformException( code: 'null-error', message: 'Host platform returned null value for non-null return value.', ); } else { - return (__pigeon_replyList[0] as List?)!.cast(); + return (pigeonVar_replyList[0] as List?)!.cast(); } } @@ -526,21 +529,21 @@ class AndroidNotificationHostApi { /// /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String) Future deleteNotificationChannel(String channelId) async { - final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.deleteNotificationChannel$__pigeon_messageChannelSuffix'; - final BasicMessageChannel __pigeon_channel = BasicMessageChannel( - __pigeon_channelName, + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.deleteNotificationChannel$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, pigeonChannelCodec, - binaryMessenger: __pigeon_binaryMessenger, + binaryMessenger: pigeonVar_binaryMessenger, ); - final List? __pigeon_replyList = - await __pigeon_channel.send([channelId]) as List?; - if (__pigeon_replyList == null) { - throw _createConnectionError(__pigeon_channelName); - } else if (__pigeon_replyList.length > 1) { + final List? pigeonVar_replyList = + await pigeonVar_channel.send([channelId]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: __pigeon_replyList[0]! as String, - message: __pigeon_replyList[1] as String?, - details: __pigeon_replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); } else { return; @@ -557,30 +560,30 @@ class AndroidNotificationHostApi { /// 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) - Future> listStoredSoundsInNotificationsDirectory() async { - final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory$__pigeon_messageChannelSuffix'; - final BasicMessageChannel __pigeon_channel = BasicMessageChannel( - __pigeon_channelName, + Future> listStoredSoundsInNotificationsDirectory() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, pigeonChannelCodec, - binaryMessenger: __pigeon_binaryMessenger, + binaryMessenger: pigeonVar_binaryMessenger, ); - final List? __pigeon_replyList = - await __pigeon_channel.send(null) as List?; - if (__pigeon_replyList == null) { - throw _createConnectionError(__pigeon_channelName); - } else if (__pigeon_replyList.length > 1) { + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: __pigeon_replyList[0]! as String, - message: __pigeon_replyList[1] as String?, - details: __pigeon_replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); - } else if (__pigeon_replyList[0] == null) { + } else if (pigeonVar_replyList[0] == null) { throw PlatformException( code: 'null-error', message: 'Host platform returned null value for non-null return value.', ); } else { - return (__pigeon_replyList[0] as List?)!.cast(); + return (pigeonVar_replyList[0] as List?)!.cast(); } } @@ -599,29 +602,29 @@ class AndroidNotificationHostApi { /// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) /// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) Future copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName}) async { - final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.copySoundResourceToMediaStore$__pigeon_messageChannelSuffix'; - final BasicMessageChannel __pigeon_channel = BasicMessageChannel( - __pigeon_channelName, + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.copySoundResourceToMediaStore$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, pigeonChannelCodec, - binaryMessenger: __pigeon_binaryMessenger, + binaryMessenger: pigeonVar_binaryMessenger, ); - final List? __pigeon_replyList = - await __pigeon_channel.send([targetFileDisplayName, sourceResourceName]) as List?; - if (__pigeon_replyList == null) { - throw _createConnectionError(__pigeon_channelName); - } else if (__pigeon_replyList.length > 1) { + final List? pigeonVar_replyList = + await pigeonVar_channel.send([targetFileDisplayName, sourceResourceName]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: __pigeon_replyList[0]! as String, - message: __pigeon_replyList[1] as String?, - details: __pigeon_replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); - } else if (__pigeon_replyList[0] == null) { + } else if (pigeonVar_replyList[0] == null) { throw PlatformException( code: 'null-error', message: 'Host platform returned null value for non-null return value.', ); } else { - return (__pigeon_replyList[0] as String?)!; + return (pigeonVar_replyList[0] as String?)!; } } @@ -642,22 +645,22 @@ class AndroidNotificationHostApi { /// See: /// https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify /// https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder - Future notify({String? tag, required int id, 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,}) async { - final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.notify$__pigeon_messageChannelSuffix'; - final BasicMessageChannel __pigeon_channel = BasicMessageChannel( - __pigeon_channelName, + Future notify({String? tag, required int id, 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, }) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.notify$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, pigeonChannelCodec, - binaryMessenger: __pigeon_binaryMessenger, + binaryMessenger: pigeonVar_binaryMessenger, ); - final List? __pigeon_replyList = - await __pigeon_channel.send([tag, id, autoCancel, channelId, color, contentIntent, contentText, contentTitle, extras, groupKey, inboxStyle, isGroupSummary, messagingStyle, number, smallIconResourceName]) as List?; - if (__pigeon_replyList == null) { - throw _createConnectionError(__pigeon_channelName); - } else if (__pigeon_replyList.length > 1) { + final List? pigeonVar_replyList = + await pigeonVar_channel.send([tag, id, autoCancel, channelId, color, contentIntent, contentText, contentTitle, extras, groupKey, inboxStyle, isGroupSummary, messagingStyle, number, smallIconResourceName]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: __pigeon_replyList[0]! as String, - message: __pigeon_replyList[1] as String?, - details: __pigeon_replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); } else { return; @@ -676,24 +679,24 @@ class AndroidNotificationHostApi { /// 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) Future getActiveNotificationMessagingStyleByTag(String tag) async { - final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotificationMessagingStyleByTag$__pigeon_messageChannelSuffix'; - final BasicMessageChannel __pigeon_channel = BasicMessageChannel( - __pigeon_channelName, + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotificationMessagingStyleByTag$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, pigeonChannelCodec, - binaryMessenger: __pigeon_binaryMessenger, + binaryMessenger: pigeonVar_binaryMessenger, ); - final List? __pigeon_replyList = - await __pigeon_channel.send([tag]) as List?; - if (__pigeon_replyList == null) { - throw _createConnectionError(__pigeon_channelName); - } else if (__pigeon_replyList.length > 1) { + final List? pigeonVar_replyList = + await pigeonVar_channel.send([tag]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: __pigeon_replyList[0]! as String, - message: __pigeon_replyList[1] as String?, - details: __pigeon_replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); } else { - return (__pigeon_replyList[0] as MessagingStyle?); + return (pigeonVar_replyList[0] as MessagingStyle?); } } @@ -705,30 +708,30 @@ class AndroidNotificationHostApi { /// 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() - Future> getActiveNotifications({required List desiredExtras}) async { - final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotifications$__pigeon_messageChannelSuffix'; - final BasicMessageChannel __pigeon_channel = BasicMessageChannel( - __pigeon_channelName, + Future> getActiveNotifications({required List desiredExtras}) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotifications$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, pigeonChannelCodec, - binaryMessenger: __pigeon_binaryMessenger, + binaryMessenger: pigeonVar_binaryMessenger, ); - final List? __pigeon_replyList = - await __pigeon_channel.send([desiredExtras]) as List?; - if (__pigeon_replyList == null) { - throw _createConnectionError(__pigeon_channelName); - } else if (__pigeon_replyList.length > 1) { + final List? pigeonVar_replyList = + await pigeonVar_channel.send([desiredExtras]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: __pigeon_replyList[0]! as String, - message: __pigeon_replyList[1] as String?, - details: __pigeon_replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); - } else if (__pigeon_replyList[0] == null) { + } else if (pigeonVar_replyList[0] == null) { throw PlatformException( code: 'null-error', message: 'Host platform returned null value for non-null return value.', ); } else { - return (__pigeon_replyList[0] as List?)!.cast(); + return (pigeonVar_replyList[0] as List?)!.cast(); } } @@ -736,21 +739,21 @@ class AndroidNotificationHostApi { /// /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) Future cancel({String? tag, required int id}) async { - final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.cancel$__pigeon_messageChannelSuffix'; - final BasicMessageChannel __pigeon_channel = BasicMessageChannel( - __pigeon_channelName, + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.cancel$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, pigeonChannelCodec, - binaryMessenger: __pigeon_binaryMessenger, + binaryMessenger: pigeonVar_binaryMessenger, ); - final List? __pigeon_replyList = - await __pigeon_channel.send([tag, id]) as List?; - if (__pigeon_replyList == null) { - throw _createConnectionError(__pigeon_channelName); - } else if (__pigeon_replyList.length > 1) { + final List? pigeonVar_replyList = + await pigeonVar_channel.send([tag, id]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: __pigeon_replyList[0]! as String, - message: __pigeon_replyList[1] as String?, - details: __pigeon_replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); } else { return; diff --git a/lib/licenses.dart b/lib/licenses.dart index a52c3f3240..c23882bb83 100644 --- a/lib/licenses.dart +++ b/lib/licenses.dart @@ -23,6 +23,12 @@ Stream additionalLicenses() async* { rootBundle.loadString('assets/Pygments/AUTHORS.txt'), ]); + // This does not need to be translated, as it is just a small fragment + // of text surrounded by a large quantity of English text that isn't + // translated anyway. + // (And it would be logistically tricky to translate, as this code is + // called from the `main` function before the [ZulipApp] widget is built, + // let alone has updated [GlobalLocalizations].) return '$licenseFileText\n\nAUTHORS file follows:\n\n$authorsFileText'; }()); yield LicenseEntryWithLineBreaks( diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 5b66a6d52a..dffe44d6fe 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -6,7 +6,9 @@ import 'package:flutter/services.dart'; import '../api/model/events.dart'; import '../api/model/model.dart'; import '../api/route/channels.dart'; +import '../generated/l10n/zulip_localizations.dart'; import '../widgets/compose_box.dart'; +import 'compose.dart'; import 'emoji.dart'; import 'narrow.dart'; import 'store.dart'; @@ -417,18 +419,21 @@ class MentionAutocompleteView extends AutocompleteView sortedUsers; - - @override - Future?> computeResults() async { - final results = []; - if (await filterCandidates(filter: _testUser, - candidates: sortedUsers, results: results)) { - return null; - } - return results; - } - - MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) { - if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) { - return UserMentionAutocompleteResult(userId: user.userId); - } - return null; - } + final ZulipLocalizations localizations; static List _usersByRelevance({ required PerAccountStore store, @@ -485,7 +474,7 @@ class MentionAutocompleteView extends AutocompleteView results, + required bool isComposingChannelMessage, + }) { + if (query.silent) return; + + bool tryOption(WildcardMentionOption option) { + if (query.testWildcardOption(option, localizations: localizations)) { + results.add(WildcardMentionAutocompleteResult(wildcardOption: option)); + return true; + } + return false; + } + + // Only one of the (all, everyone, channel, stream) channel wildcards are + // shown. + all: { + if (tryOption(WildcardMentionOption.all)) break all; + if (tryOption(WildcardMentionOption.everyone)) break all; + if (isComposingChannelMessage) { + final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9) + if (isChannelWildcardAvailable && tryOption(WildcardMentionOption.channel)) break all; + if (tryOption(WildcardMentionOption.stream)) break all; + } + } + + final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(server-8) + if (isComposingChannelMessage && isTopicWildcardAvailable) { + tryOption(WildcardMentionOption.topic); + } + } + + @override + Future?> computeResults() async { + final results = []; + // Give priority to wildcard mentions. + computeWildcardMentionResults(results: results, + isComposingChannelMessage: narrow is ChannelNarrow || narrow is TopicNarrow); + + if (await filterCandidates(filter: _testUser, + candidates: sortedUsers, results: results)) { + return null; + } + return results; + } + + MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) { + if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) { + return UserMentionAutocompleteResult(userId: user.userId); + } + return null; + } + @override void dispose() { store.autocompleteViewManager.unregisterMentionAutocomplete(this); @@ -642,13 +682,17 @@ class MentionAutocompleteView extends AutocompleteView _lowercaseWords; + late final String _lowercase; + + late final List _lowercaseWords; /// Whether all of this query's words have matches in [words] that appear in order. /// @@ -679,7 +723,11 @@ abstract class ComposeAutocompleteQuery extends AutocompleteQuery { /// Construct an [AutocompleteView] initialized with this query /// and ready to handle queries of the same type. - ComposeAutocompleteView initViewModel(PerAccountStore store, Narrow narrow); + ComposeAutocompleteView initViewModel({ + required PerAccountStore store, + required ZulipLocalizations localizations, + required Narrow narrow, + }); } /// A @-mention autocomplete query, used by [MentionAutocompleteView]. @@ -690,13 +738,24 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery { final bool silent; @override - MentionAutocompleteView initViewModel(PerAccountStore store, Narrow narrow) { - return MentionAutocompleteView.init(store: store, narrow: narrow, query: this); + MentionAutocompleteView initViewModel({ + required PerAccountStore store, + required ZulipLocalizations localizations, + required Narrow narrow, + }) { + return MentionAutocompleteView.init( + store: store, localizations: localizations, narrow: narrow, query: this); + } + + bool testWildcardOption(WildcardMentionOption wildcardOption, { + required ZulipLocalizations localizations}) { + // TODO(#237): match insensitively to diacritics + return wildcardOption.canonicalString.contains(_lowercase) + || wildcardOption.localizedCanonicalString(localizations).contains(_lowercase); } bool testUser(User user, AutocompleteDataCache cache) { // TODO(#236) test email too, not just name - if (!user.isActive) return false; return _testName(user, cache); @@ -720,6 +779,19 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery { int get hashCode => Object.hash('MentionAutocompleteQuery', raw, silent); } +extension WildcardMentionOptionExtension on WildcardMentionOption { + /// A translation of [canonicalString], from [localizations]. + String localizedCanonicalString(ZulipLocalizations localizations) { + return switch (this) { + WildcardMentionOption.all => localizations.wildcardMentionAll, + WildcardMentionOption.everyone => localizations.wildcardMentionEveryone, + WildcardMentionOption.channel => localizations.wildcardMentionChannel, + WildcardMentionOption.stream => localizations.wildcardMentionStream, + WildcardMentionOption.topic => localizations.wildcardMentionTopic, + }; + } +} + /// Cached data that is used for autocomplete /// but kept around in between autocomplete interactions. /// @@ -788,9 +860,14 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult { final int userId; } -// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult { +/// An autocomplete result for an @-mention of all the users in a conversation. +class WildcardMentionAutocompleteResult extends MentionAutocompleteResult { + WildcardMentionAutocompleteResult({required this.wildcardOption}); + + final WildcardMentionOption wildcardOption; +} -// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult { +// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult { /// An autocomplete interaction for choosing a topic for a message. class TopicAutocompleteView extends AutocompleteView { @@ -815,7 +892,7 @@ class TopicAutocompleteView extends AutocompleteView _topics = []; + Iterable _topics = []; bool _isFetching = false; /// Fetches topics of the current stream narrow, expected to fetch @@ -843,7 +920,7 @@ class TopicAutocompleteView extends AutocompleteView> get debugTopicVisibility; + Map> get debugTopicVisibility; /// Whether this topic should appear when already focusing on its stream. /// @@ -63,7 +63,7 @@ mixin ChannelStore { /// /// For UI contexts that are not specific to a particular stream, see /// [isTopicVisible]. - bool isTopicVisibleInStream(int streamId, String topic) { + bool isTopicVisibleInStream(int streamId, TopicName topic) { return _isTopicVisibleInStream(topicVisibilityPolicy(streamId, topic)); } @@ -100,7 +100,7 @@ mixin ChannelStore { /// /// For UI contexts that are specific to a particular stream, see /// [isTopicVisibleInStream]. - bool isTopicVisible(int streamId, String topic) { + bool isTopicVisible(int streamId, TopicName topic) { return _isTopicVisible(streamId, topicVisibilityPolicy(streamId, topic)); } @@ -171,7 +171,7 @@ 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) @@ -204,12 +204,12 @@ 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, String topic) { + UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) { return topicVisibility[streamId]?[topic] ?? UserTopicVisibilityPolicy.none; } diff --git a/lib/model/compose.dart b/lib/model/compose.dart index b59a3efcc7..54c7f6ce00 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -1,10 +1,33 @@ import 'dart:math'; import '../api/model/model.dart'; +import '../generated/l10n/zulip_localizations.dart'; import 'internal_link.dart'; import 'narrow.dart'; import 'store.dart'; +/// The available user wildcard mention options, +/// known to the server as [canonicalString]. +/// +/// See API docs: +/// https://zulip.com/api/message-formatting#mentions-and-silent-mentions +enum WildcardMentionOption { + all(canonicalString: 'all'), + everyone(canonicalString: 'everyone'), + channel(canonicalString: 'channel'), + // TODO(server-9): Deprecated in FL 247. Empirically, current servers (FL 339) + // still parse "@**stream**" in messages though. + stream(canonicalString: 'stream'), + topic(canonicalString: 'topic'); // TODO(server-8): New in FL 224. + + const WildcardMentionOption({required this.canonicalString}); + + /// The string identifying this option (e.g. "all" as in "@**all**"). + final String canonicalString; + + String get name => throw UnsupportedError('Use [canonicalString] instead.'); +} + // // Put functions for nontrivial message-content generation in this file. // @@ -101,18 +124,42 @@ String wrapWithBacktickFence({required String content, String? infoString}) { return resultBuffer.toString(); } -/// An @-mention, like @**Chris Bobbe|13313**. +/// An @-mention of an individual user, like @**Chris Bobbe|13313**. /// /// To omit the user ID part ("|13313") whenever the name part is unambiguous, /// pass a Map of all users we know about. This means accepting a linear scan /// through all users; avoid it in performance-sensitive codepaths. -String mention(User user, {bool silent = false, Map? users}) { +String userMention(User user, {bool silent = false, Map? users}) { bool includeUserId = users == null || users.values.where((u) => u.fullName == user.fullName).take(2).length == 2; return '@${silent ? '_' : ''}**${user.fullName}${includeUserId ? '|${user.userId}' : ''}**'; } +/// An @-mention of all the users in a conversation, like @**channel**. +String wildcardMention(WildcardMentionOption wildcardOption, { + required PerAccountStore store, +}) { + final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9) + final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(server-8) + + String name = wildcardOption.canonicalString; + switch (wildcardOption) { + case WildcardMentionOption.all: + case WildcardMentionOption.everyone: + break; + case WildcardMentionOption.channel: + assert(isChannelWildcardAvailable); + case WildcardMentionOption.stream: + if (isChannelWildcardAvailable) { + name = WildcardMentionOption.channel.canonicalString; + } + case WildcardMentionOption.topic: + assert(isTopicWildcardAvailable); + } + return '@**$name**'; +} + /// https://spec.commonmark.org/0.30/#inline-link /// /// The "link text" is made by enclosing [visibleText] in square brackets. @@ -136,7 +183,9 @@ String inlineLink(String visibleText, Uri? destination) { } /// What we show while fetching the target message's raw Markdown. -String quoteAndReplyPlaceholder(PerAccountStore store, { +String quoteAndReplyPlaceholder( + ZulipLocalizations zulipLocalizations, + PerAccountStore store, { required Message message, }) { final sender = store.users[message.senderId]; @@ -145,8 +194,8 @@ String quoteAndReplyPlaceholder(PerAccountStore store, { SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), nearMessageId: message.id); // See note in [quoteAndReply] about asking `mention` to omit the | part. - return '${mention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(i18n) ? - '*(loading message ${message.id})*\n'; // TODO(i18n) ? + return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(#1285) + '*${zulipLocalizations.composeBoxLoadingMessage(message.id)}*\n'; } /// Quote-and-reply syntax. @@ -169,6 +218,6 @@ String quoteAndReply(PerAccountStore store, { // 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 '${mention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(i18n) ? + return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(#1285) '${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}'; } diff --git a/lib/model/content.dart b/lib/model/content.dart index c98a5ceaaf..e228163a2e 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -786,7 +786,7 @@ class MathInlineNode extends InlineContentNode { class GlobalTimeNode extends InlineContentNode { const GlobalTimeNode({super.debugHtmlNode, required this.datetime}); - /// Always in UTC, enforced in [_ZulipContentParser.parseInlineContent]. + /// Always in UTC, enforced in [_ZulipInlineContentParser.parseInlineContent]. final DateTime datetime; @override @@ -806,72 +806,68 @@ class GlobalTimeNode extends InlineContentNode { //////////////////////////////////////////////////////////////// -/// What sort of nodes a [_ZulipContentParser] is currently expecting to find. -enum _ParserContext { - /// The parser is currently looking for block nodes. - block, +String? _parseMath(dom.Element element, {required bool block}) { + final dom.Element katexElement; + if (!block) { + assert(element.localName == 'span' && element.className == 'katex'); - /// The parser is currently looking for inline nodes. - inline, -} - -class _ZulipContentParser { - /// The current state of what sort of nodes the parser is looking for. - /// - /// This exists for the sake of debug-mode checks, - /// and should be read or updated only inside an assertion. - _ParserContext _debugParserContext = _ParserContext.block; - - String? parseMath(dom.Element element, {required bool block}) { - assert(block == (_debugParserContext == _ParserContext.block)); - - final dom.Element katexElement; - if (!block) { - assert(element.localName == 'span' && element.className == 'katex'); + katexElement = element; + } else { + assert(element.localName == 'span' && element.className == 'katex-display'); - katexElement = element; - } else { - assert(element.localName == 'span' && element.className == 'katex-display'); - - if (element.nodes.length != 1) return null; - final child = element.nodes.single; - if (child is! dom.Element) return null; - if (child.localName != 'span') return null; - if (child.className != 'katex') return null; - katexElement = child; - } - - // Expect two children span.katex-mathml, span.katex-html . - // For now we only care about the .katex-mathml . - if (katexElement.nodes.isEmpty) return null; - final child = katexElement.nodes.first; + if (element.nodes.length != 1) return null; + final child = element.nodes.single; if (child is! dom.Element) return null; if (child.localName != 'span') return null; - if (child.className != 'katex-mathml') return null; - - if (child.nodes.length != 1) return null; - final grandchild = child.nodes.single; - if (grandchild is! dom.Element) return null; - if (grandchild.localName != 'math') return null; - if (grandchild.attributes['display'] != (block ? 'block' : null)) return null; - if (grandchild.namespaceUri != 'http://www.w3.org/1998/Math/MathML') return null; - - if (grandchild.nodes.length != 1) return null; - final greatgrand = grandchild.nodes.single; - if (greatgrand is! dom.Element) return null; - if (greatgrand.localName != 'semantics') return null; - - if (greatgrand.nodes.isEmpty) return null; - final descendant4 = greatgrand.nodes.last; - if (descendant4 is! dom.Element) return null; - if (descendant4.localName != 'annotation') return null; - if (descendant4.attributes['encoding'] != 'application/x-tex') return null; - - return descendant4.text.trim(); + if (child.className != 'katex') return null; + katexElement = child; + } + + // Expect two children span.katex-mathml, span.katex-html . + // For now we only care about the .katex-mathml . + if (katexElement.nodes.isEmpty) return null; + final child = katexElement.nodes.first; + if (child is! dom.Element) return null; + if (child.localName != 'span') return null; + if (child.className != 'katex-mathml') return null; + + if (child.nodes.length != 1) return null; + final grandchild = child.nodes.single; + if (grandchild is! dom.Element) return null; + if (grandchild.localName != 'math') return null; + if (grandchild.attributes['display'] != (block ? 'block' : null)) return null; + if (grandchild.namespaceUri != 'http://www.w3.org/1998/Math/MathML') return null; + + if (grandchild.nodes.length != 1) return null; + final greatgrand = grandchild.nodes.single; + if (greatgrand is! dom.Element) return null; + if (greatgrand.localName != 'semantics') return null; + + if (greatgrand.nodes.isEmpty) return null; + final descendant4 = greatgrand.nodes.last; + if (descendant4 is! dom.Element) return null; + if (descendant4.localName != 'annotation') return null; + if (descendant4.attributes['encoding'] != 'application/x-tex') return null; + + return descendant4.text.trim(); +} + +/// Parser for the inline-content subtrees within Zulip content HTML. +/// +/// The only entry point to this class is [parseBlockInline]. +/// +/// After a call to [parseBlockInline] returns, the [_ZulipInlineContentParser] +/// instance has been reset to its starting state, and can be re-used for +/// parsing other subtrees. +class _ZulipInlineContentParser { + InlineContentNode? parseInlineMath(dom.Element element) { + final debugHtmlNode = kDebugMode ? element : null; + final texSource = _parseMath(element, block: false); + if (texSource == null) return null; + return MathInlineNode(texSource: texSource, debugHtmlNode: debugHtmlNode); } UserMentionNode? parseUserMention(dom.Element element) { - assert(_debugParserContext == _ParserContext.inline); assert(element.localName == 'span'); final debugHtmlNode = kDebugMode ? element : null; @@ -945,7 +941,6 @@ class _ZulipContentParser { static final _emojiCodeFromClassNameRegexp = RegExp(r"emoji-([^ ]+)"); InlineContentNode parseInlineContent(dom.Node node) { - assert(_debugParserContext == _ParserContext.inline); final debugHtmlNode = kDebugMode ? node : null; InlineContentNode unimplemented() => UnimplementedInlineContentNode(htmlNode: node); @@ -1025,9 +1020,7 @@ class _ZulipContentParser { } if (localName == 'span' && className == 'katex') { - final texSource = parseMath(element, block: false); - if (texSource == null) return unimplemented(); - return MathInlineNode(texSource: texSource, debugHtmlNode: debugHtmlNode); + return parseInlineMath(element) ?? unimplemented(); } // TODO more types of node @@ -1035,26 +1028,41 @@ class _ZulipContentParser { } List parseInlineContentList(List nodes) { - assert(_debugParserContext == _ParserContext.inline); return nodes.map(parseInlineContent).toList(growable: false); } + /// Parse the children of a [BlockInlineContainerNode], making up a + /// complete subtree of inline content with no further inline ancestors. ({List nodes, List? links}) parseBlockInline(List nodes) { - assert(_debugParserContext == _ParserContext.block); - assert(() { - _debugParserContext = _ParserContext.inline; - return true; - }()); final resultNodes = parseInlineContentList(nodes); - assert(() { - _debugParserContext = _ParserContext.block; - return true; - }()); return (nodes: resultNodes, links: _takeLinkNodes()); } +} + +/// Parser for a complete piece of Zulip HTML content, a [ZulipContent]. +/// +/// The only entry point to this class is [parse]. +class _ZulipContentParser { + /// The single inline-content parser used and re-used throughout parsing of + /// a complete piece of Zulip HTML content. + /// + /// Because block content can never appear nested inside inline content, + /// there's never a need for more than one of these at a time, + /// so we can allocate just one up front. + final inlineParser = _ZulipInlineContentParser(); + + ({List nodes, List? links}) parseBlockInline(List nodes) { + return inlineParser.parseBlockInline(nodes); + } + + BlockContentNode parseMathBlock(dom.Element element) { + final debugHtmlNode = kDebugMode ? element : null; + final texSource = _parseMath(element, block: true); + if (texSource == null) return UnimplementedBlockContentNode(htmlNode: element); + return MathBlockNode(texSource: texSource, debugHtmlNode: debugHtmlNode); + } BlockContentNode parseListNode(dom.Element element) { - assert(_debugParserContext == _ParserContext.block); ListStyle? listStyle; switch (element.localName) { case 'ol': listStyle = ListStyle.ordered; break; @@ -1077,7 +1085,6 @@ class _ZulipContentParser { } BlockContentNode parseSpoilerNode(dom.Element divElement) { - assert(_debugParserContext == _ParserContext.block); assert(divElement.localName == 'div' && divElement.className == 'spoiler-block'); @@ -1097,7 +1104,6 @@ class _ZulipContentParser { } BlockContentNode parseCodeBlock(dom.Element divElement) { - assert(_debugParserContext == _ParserContext.block); final mainElement = () { assert(divElement.localName == 'div' && divElement.className == "codehilite"); @@ -1180,7 +1186,6 @@ class _ZulipContentParser { static final _imageDimensionsRegExp = RegExp(r'^(\d+)x(\d+)$'); BlockContentNode parseImageNode(dom.Element divElement) { - assert(_debugParserContext == _ParserContext.block); final elements = () { assert(divElement.localName == 'div' && divElement.className == 'message_inline_image'); @@ -1272,7 +1277,6 @@ class _ZulipContentParser { }(); BlockContentNode parseInlineVideoNode(dom.Element divElement) { - assert(_debugParserContext == _ParserContext.block); assert(divElement.localName == 'div' && _videoClassNameRegexp.hasMatch(divElement.className)); @@ -1305,7 +1309,6 @@ class _ZulipContentParser { } BlockContentNode parseEmbedVideoNode(dom.Element divElement) { - assert(_debugParserContext == _ParserContext.block); assert(divElement.localName == 'div' && _videoClassNameRegexp.hasMatch(divElement.className)); @@ -1344,7 +1347,6 @@ class _ZulipContentParser { } BlockContentNode parseTableContent(dom.Element tableElement) { - assert(_debugParserContext == _ParserContext.block); assert(tableElement.localName == 'table' && tableElement.className.isEmpty); @@ -1452,7 +1454,6 @@ class _ZulipContentParser { } BlockContentNode parseBlockContent(dom.Node node) { - assert(_debugParserContext == _ParserContext.block); final debugHtmlNode = kDebugMode ? node : null; if (node is! dom.Element) { return UnimplementedBlockContentNode(htmlNode: node); @@ -1480,9 +1481,7 @@ class _ZulipContentParser { // The case with the `
\n` can happen when at the end of a quote; // it seems like a glitch in the server's Markdown processing, // so hopefully there just aren't any further such glitches. - final texSource = parseMath(child, block: true); - if (texSource == null) return UnimplementedBlockContentNode(htmlNode: node); - return MathBlockNode(texSource: texSource, debugHtmlNode: debugHtmlNode); + return parseMathBlock(child); } } } @@ -1579,10 +1578,15 @@ class _ZulipContentParser { /// /// See [ParagraphNode]. List parseImplicitParagraphBlockContentList(dom.NodeList nodes) { - assert(_debugParserContext == _ParserContext.block); final List result = []; - final List currentParagraph = []; + List imageNodes = []; + void consumeImageNodes() { + result.add(ImageNodeList(imageNodes)); + imageNodes = []; + } + + final List currentParagraph = []; void consumeParagraph() { final parsed = parseBlockInline(currentParagraph); result.add(ParagraphNode( @@ -1597,8 +1601,7 @@ class _ZulipContentParser { if (_isPossibleInlineNode(node)) { if (imageNodes.isNotEmpty) { - result.add(ImageNodeList(imageNodes)); - imageNodes = []; + consumeImageNodes(); // In a context where paragraphs are implicit it should be impossible // to have more paragraph content after image previews. result.add(UnimplementedBlockContentNode(htmlNode: node)); @@ -1613,24 +1616,25 @@ class _ZulipContentParser { imageNodes.add(block); continue; } - if (imageNodes.isNotEmpty) { - result.add(ImageNodeList(imageNodes)); - imageNodes = []; - } + if (imageNodes.isNotEmpty) consumeImageNodes(); result.add(block); } if (currentParagraph.isNotEmpty) consumeParagraph(); - if (imageNodes.isNotEmpty) result.add(ImageNodeList(imageNodes)); - + if (imageNodes.isNotEmpty) consumeImageNodes(); return result; } static final _redundantLineBreaksRegexp = RegExp(r'^\n+$'); List parseBlockContentList(dom.NodeList nodes) { - assert(_debugParserContext == _ParserContext.block); final List result = []; + List imageNodes = []; + void consumeImageNodes() { + result.add(ImageNodeList(imageNodes)); + imageNodes = []; + } + for (final node in nodes) { // We get a bunch of newline Text nodes between paragraphs. // A browser seems to ignore these; let's do the same. @@ -1643,13 +1647,10 @@ class _ZulipContentParser { imageNodes.add(block); continue; } - if (imageNodes.isNotEmpty) { - result.add(ImageNodeList(imageNodes)); - imageNodes = []; - } + if (imageNodes.isNotEmpty) consumeImageNodes(); result.add(block); } - if (imageNodes.isNotEmpty) result.add(ImageNodeList(imageNodes)); + if (imageNodes.isNotEmpty) consumeImageNodes(); return result; } @@ -1660,6 +1661,8 @@ class _ZulipContentParser { } } +/// Parse a complete piece of Zulip HTML content, +/// such as an entire value of [Message.content]. ZulipContent parseContent(String html) { return _ZulipContentParser().parse(html); } diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 2728600e44..b0ec5f7324 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -5,6 +5,7 @@ import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import '../api/route/realm.dart'; +import '../generated/l10n/zulip_localizations.dart'; import 'algorithms.dart'; import 'autocomplete.dart'; import 'narrow.dart'; @@ -465,7 +466,11 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery { } @override - EmojiAutocompleteView initViewModel(PerAccountStore store, Narrow narrow) { + EmojiAutocompleteView initViewModel({ + required PerAccountStore store, + required ZulipLocalizations localizations, + required Narrow narrow, + }) { return EmojiAutocompleteView.init(store: store, query: this); } diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart index a5dd23c2a2..db11115cf3 100644 --- a/lib/model/internal_link.dart +++ b/lib/model/internal_link.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; +import '../api/model/model.dart'; import '../api/model/narrow.dart'; import 'narrow.dart'; import 'store.dart'; @@ -76,7 +77,7 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { final slugifiedName = _encodeHashComponent(name.replaceAll(' ', '-')); fragment.write('$streamId-$slugifiedName'); case ApiNarrowTopic(): - fragment.write(_encodeHashComponent(element.operand)); + fragment.write(_encodeHashComponent(element.operand.apiName)); case ApiNarrowDmModern(): final suffix = element.operand.length >= 3 ? 'group' : 'dm'; fragment.write('${element.operand.join(',')}-$suffix'); @@ -178,7 +179,7 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { if (topicElement != null) return null; final String? topic = decodeHashComponent(operand); if (topic == null) return null; - topicElement = ApiNarrowTopic(topic, negated: negated); + topicElement = ApiNarrowTopic(TopicName(topic), negated: negated); case _NarrowOperator.dm: case _NarrowOperator.pmWith: diff --git a/lib/model/localizations.dart b/lib/model/localizations.dart index 0f719fed06..07598cd8fe 100644 --- a/lib/model/localizations.dart +++ b/lib/model/localizations.dart @@ -1,6 +1,17 @@ import '../generated/l10n/zulip_localizations.dart'; abstract final class GlobalLocalizations { + /// The [ZulipLocalizations] for the user's chosen language and locale. + /// + /// Where possible, the [ZulipLocalizations] should be acquired + /// through [ZulipLocalizations.of] instead, using a [BuildContext]. + /// This static field is to be used where access to a [BuildContext] + /// is impractical, such as in the API bindings + /// (which use localizations when throwing exceptions). + /// + /// This gets set during app startup once we have the user's choice of locale. + /// If accessed before that point, it uses the app's first supported locale, + /// namely 'en'. static ZulipLocalizations zulipLocalizations = lookupZulipLocalizations(ZulipLocalizations.supportedLocales.first); } diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index ecfb16c2b9..670785ac4e 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -352,7 +352,7 @@ mixin _MessageSequence { bool haveSameRecipient(Message prevMessage, Message message) { if (prevMessage is StreamMessage && message is StreamMessage) { if (prevMessage.streamId != message.streamId) return false; - if (prevMessage.topic.toLowerCase() != message.topic.toLowerCase()) return false; + if (prevMessage.topic.canonicalize() != message.topic.canonicalize()) return false; } else if (prevMessage is DmMessage && message is DmMessage) { if (!_equalIdSequences(prevMessage.allRecipientIds, message.allRecipientIds)) { return false; @@ -686,8 +686,8 @@ class MessageListView with ChangeNotifier, _MessageSequence { void messagesMoved({ required int origStreamId, required int newStreamId, - required String origTopic, - required String newTopic, + required TopicName origTopic, + required TopicName newTopic, required List messageIds, required PropagateMode propagateMode, }) { diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart index 1553750849..9e29808ceb 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -99,7 +99,7 @@ class TopicNarrow extends Narrow implements SendableNarrow { } final int streamId; - final String topic; + final TopicName topic; @override bool containsMessage(Message message) { @@ -114,7 +114,7 @@ class TopicNarrow extends Narrow implements SendableNarrow { StreamDestination get destination => StreamDestination(streamId, topic); @override - String toString() => 'TopicNarrow($streamId, $topic)'; + String toString() => 'TopicNarrow($streamId, ${topic.displayName})'; @override bool operator ==(Object other) { diff --git a/lib/model/recent_senders.dart b/lib/model/recent_senders.dart index d075d05eee..a5c4bca778 100644 --- a/lib/model/recent_senders.dart +++ b/lib/model/recent_senders.dart @@ -16,7 +16,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. @@ -29,7 +29,7 @@ class RecentSenders { /// or null if no such message is known. int? latestMessageIdOfSenderInTopic({ required int streamId, - required String topic, + required TopicName topic, required int senderId, }) => topicSenders[streamId]?[topic]?[senderId]?.maxId; @@ -38,7 +38,7 @@ class RecentSenders { /// The messages must be sorted by [Message.id] ascending. void handleMessages(List messages) { final messagesByUserInStream = <(int, int), QueueList>{}; - final messagesByUserInTopic = <(int, String, int), QueueList>{}; + final messagesByUserInTopic = <(int, TopicName, int), QueueList>{}; for (final message in messages) { if (message is! StreamMessage) continue; final StreamMessage(:streamId, :topic, :senderId, id: int messageId) = message; diff --git a/lib/model/store.dart b/lib/model/store.dart index 58a8a70615..7603c7f452 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -268,6 +268,8 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess globalStore: globalStore, connection: connection, realmUrl: realmUrl, + realmWildcardMentionPolicy: initialSnapshot.realmWildcardMentionPolicy, + realmMandatoryTopics: initialSnapshot.realmMandatoryTopics, realmWaitingPeriodThreshold: initialSnapshot.realmWaitingPeriodThreshold, maxFileUploadSizeMib: initialSnapshot.maxFileUploadSizeMib, realmDefaultExternalAccounts: initialSnapshot.realmDefaultExternalAccounts, @@ -311,6 +313,8 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess required GlobalStore globalStore, required this.connection, required this.realmUrl, + required this.realmWildcardMentionPolicy, + required this.realmMandatoryTopics, required this.realmWaitingPeriodThreshold, required this.maxFileUploadSizeMib, required this.realmDefaultExternalAccounts, @@ -375,6 +379,8 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess Uri? tryResolveUrl(String reference) => _tryResolveUrl(realmUrl, reference); String get zulipVersion => account.zulipVersion; + final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting + final bool realmMandatoryTopics; // TODO(#668): update this realm setting /// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold]. final int realmWaitingPeriodThreshold; // TODO(#668): update this realm setting final int maxFileUploadSizeMib; // No event for this. @@ -466,10 +472,10 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess @override Map get subscriptions => _channels.subscriptions; @override - UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, String topic) => + UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) => _channels.topicVisibilityPolicy(streamId, topic); @override - Map> get debugTopicVisibility => + Map> get debugTopicVisibility => _channels.debugTopicVisibility; final ChannelStoreImpl _channels; @@ -1128,8 +1134,7 @@ class UpdateMachine { case Server5xxException(): shouldReportToUser = true; - case ServerException(httpStatus: 429): - case ZulipApiException(httpStatus: 429): + case HttpException(httpStatus: 429): case ZulipApiException(code: 'RATE_LIMIT_HIT'): // TODO(#946) handle rate-limit errors more generally, in ApiConnection shouldReportToUser = true; diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart index 4f1ddc603c..1fcea3f83c 100644 --- a/lib/model/unreads.dart +++ b/lib/model/unreads.dart @@ -40,7 +40,7 @@ class Unreads extends ChangeNotifier { required int selfUserId, required ChannelStore channelStore, }) { - final streams = >>{}; + final streams = >>{}; final dms = >{}; final mentions = Set.of(initial.mentions); @@ -86,7 +86,7 @@ class Unreads extends ChangeNotifier { // int count; /// Unread stream messages, as: stream ID → topic → message IDs (sorted). - final Map>> streams; + final Map>> streams; /// Unread DM messages, as: DM narrow → message IDs (sorted). final Map> dms; @@ -185,7 +185,7 @@ class Unreads extends ChangeNotifier { return c; } - int countInTopicNarrow(int streamId, String topic) { + int countInTopicNarrow(int streamId, TopicName topic) { final topics = streams[streamId]; return topics?[topic]?.length ?? 0; } @@ -365,7 +365,7 @@ class Unreads extends ChangeNotifier { _slowRemoveAllInDms(messageIdsSet); } case UpdateMessageFlagsRemoveEvent(): - final newlyUnreadInStreams = >>{}; + final newlyUnreadInStreams = >>{}; final newlyUnreadInDms = >{}; for (final messageId in event.messages) { final detail = event.messageDetails![messageId]; @@ -449,12 +449,12 @@ class Unreads extends ChangeNotifier { ); } - void _addLastInStreamTopic(int messageId, int streamId, String topic) { + void _addLastInStreamTopic(int messageId, int streamId, TopicName topic) { ((streams[streamId] ??= {})[topic] ??= QueueList()).addLast(messageId); } // [messageIds] must be sorted ascending and without duplicates. - void _addAllInStreamTopic(QueueList messageIds, int streamId, String topic) { + void _addAllInStreamTopic(QueueList messageIds, int streamId, TopicName topic) { final topics = streams[streamId] ??= {}; topics.update(topic, ifAbsent: () => messageIds, @@ -469,7 +469,7 @@ class Unreads extends ChangeNotifier { void _slowRemoveAllInStreams(Set idsToRemove) { final newlyEmptyStreams = []; for (final MapEntry(key: streamId, value: topics) in streams.entries) { - final newlyEmptyTopics = []; + final newlyEmptyTopics = []; for (final MapEntry(key: topic, value: messageIds) in topics.entries) { messageIds.removeWhere((id) => idsToRemove.contains(id)); if (messageIds.isEmpty) { @@ -488,7 +488,7 @@ class Unreads extends ChangeNotifier { } } - void _removeAllInStreamTopic(Set incomingMessageIds, int streamId, String topic) { + void _removeAllInStreamTopic(Set incomingMessageIds, int streamId, TopicName topic) { final topics = streams[streamId]; if (topics == null) return; final messageIds = topics[topic]; diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 46ea95486a..1d74db6854 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -6,6 +6,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart' hide Notification; +import '../api/model/model.dart'; import '../api/notifications.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../host/android_notifications.dart'; @@ -24,10 +25,11 @@ import '../widgets/theme.dart'; AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNotificationHost; enum NotificationSound { - // Any new entry here must appear in `keep.xml` too, see #528. + // TODO(i18n): translate these file display names chime2(resourceName: 'chime2', fileDisplayName: 'Zulip - Low Chime.m4a'), chime3(resourceName: 'chime3', fileDisplayName: 'Zulip - Chime.m4a'), chime4(resourceName: 'chime4', fileDisplayName: 'Zulip - High Chime.m4a'); + // Any new entry here must appear in `keep.xml` too, see #528. const NotificationSound({ required this.resourceName, @@ -107,7 +109,7 @@ class NotificationChannelManager { // and check against our list of sounds we have. final soundsToAdd = NotificationSound.values.toList(); - final List storedSounds; + final List storedSounds; try { storedSounds = await _androidHost.listStoredSoundsInNotificationsDirectory(); } catch (e, st) { @@ -115,11 +117,9 @@ class NotificationChannelManager { return defaultSoundUrl; } for (final storedSound in storedSounds) { - assert(storedSound != null); // TODO(#942) - // If the file is one we put there, and has the name we give to our // default sound, then use it as the default sound. - if (storedSound!.fileName == kDefaultNotificationSound.fileDisplayName + if (storedSound.fileName == kDefaultNotificationSound.fileDisplayName && storedSound.isOwned) { defaultSoundUrl = storedSound.contentUrl; } @@ -187,8 +187,7 @@ class NotificationChannelManager { var found = false; final channels = await _androidHost.getNotificationChannels(); for (final channel in channels) { - assert(channel != null); // TODO(#942) - if (channel!.id == kChannelId) { + if (channel.id == kChannelId) { found = true; } else { await _androidHost.deleteNotificationChannel(channel.id); @@ -206,7 +205,7 @@ class NotificationChannelManager { await _androidHost.createNotificationChannel(NotificationChannel( id: kChannelId, - name: 'Messages', // TODO(i18n) + name: 'Messages', // TODO(#1284) importance: NotificationImportance.high, lightsEnabled: true, soundUrl: defaultSoundUrl, @@ -263,9 +262,9 @@ class NotificationDisplayManager { // the first. messagingStyle.conversationTitle = switch (data.recipient) { FcmMessageChannelRecipient(:var streamName?, :var topic) => - '#$streamName > $topic', + '#$streamName > ${topic.displayName}', FcmMessageChannelRecipient(:var topic) => - '#(unknown channel) > $topic', // TODO get stream name from data + '#${zulipLocalizations.unknownChannelName} > ${topic.displayName}', // TODO get stream name from data FcmMessageDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 => zulipLocalizations.notifGroupDmConversationLabel( data.senderFullName, allRecipientIds.length - 2), // TODO use others' names, from data @@ -377,8 +376,6 @@ class NotificationDisplayManager { final activeNotifications = await _androidHost.getActiveNotifications( desiredExtras: [kExtraLastZulipMessageId]); for (final statusBarNotification in activeNotifications) { - if (statusBarNotification == null) continue; // TODO(pigeon) eliminate this case - // The StatusBarNotification object describes an active notification in the UI. // Its `.tag`, `.id`, and `.notification` are the same values as we passed to // [AndroidNotificationHostApi.notify] (and so to `NotificationManager#notify` @@ -442,7 +439,7 @@ class NotificationDisplayManager { static String _conversationKey(MessageFcmMessage data, String groupKey) { final conversation = switch (data.recipient) { - FcmMessageChannelRecipient(:var streamId, :var topic) => 'stream:$streamId:$topic', + FcmMessageChannelRecipient(:var streamId, :var topic) => 'stream:$streamId:${topic.canonicalize()}', FcmMessageDmRecipient(:var allRecipientIds) => 'dm:${allRecipientIds.join(',')}', }; return '$groupKey|$conversation'; @@ -456,6 +453,40 @@ 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, + }) { + 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 @@ -463,29 +494,16 @@ class NotificationDisplayManager { static Future navigateForNotification(Uri url) async { assert(debugLog('opened notif: url: $url')); - assert(url.scheme == 'zulip' && url.host == 'notification'); - final payload = NotificationOpenPayload.parseUrl(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 zulipLocalizations = ZulipLocalizations.of(context); - final globalStore = GlobalStoreWidget.of(context); - final account = globalStore.accounts.firstWhereOrNull((account) => - account.realmUrl == payload.realmUrl && account.userId == payload.userId); - if (account == null) { // TODO(log) - showErrorDialog(context: context, - title: zulipLocalizations.errorNotificationOpenTitle, - message: zulipLocalizations.errorNotificationOpenAccountMissing); - return; - } + 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(MaterialAccountWidgetRoute(accountId: account.id, - // TODO(#82): Open at specific message, not just conversation - page: MessageListPage(initNarrow: payload.narrow)))); + unawaited(navigator.push(route)); } static Future _fetchBitmap(Uri url) async { @@ -538,8 +556,8 @@ class NotificationOpenPayload { case 'topic': final channelIdStr = url.queryParameters['channel_id']!; final channelId = int.parse(channelIdStr, radix: 10); - final topic = url.queryParameters['topic']!; - narrow = TopicNarrow(channelId, topic); + final topicStr = url.queryParameters['topic']!; + narrow = TopicNarrow(channelId, TopicName(topicStr)); case 'dm': final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!; final allRecipientIds = allRecipientIdsStr.split(',') @@ -572,7 +590,7 @@ class NotificationOpenPayload { TopicNarrow(streamId: var channelId, :var topic) => { 'narrow_type': 'topic', 'channel_id': channelId.toString(), - 'topic': topic, + 'topic': topic.apiName, }, DmNarrow(:var allRecipientIds) => { 'narrow_type': 'dm', diff --git a/lib/widgets/about_zulip.dart b/lib/widgets/about_zulip.dart index d0c1c8d29e..8abfa97c49 100644 --- a/lib/widgets/about_zulip.dart +++ b/lib/widgets/about_zulip.dart @@ -43,7 +43,8 @@ class _AboutZulipPageState extends State { child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ ListTile( title: Text(zulipLocalizations.aboutPageAppVersion), - subtitle: Text(_packageInfo?.version ?? '(…)')), + subtitle: Text(_packageInfo?.version + ?? zulipLocalizations.appVersionUnknownPlaceholder)), ListTile( title: Text(zulipLocalizations.aboutPageOpenSourceLicenses), subtitle: Text(zulipLocalizations.aboutPageTapToView), diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index fca61d4810..7c3ea6e622 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -24,6 +24,7 @@ import 'emoji_reaction.dart'; import 'icons.dart'; import 'inset_shadow.dart'; import 'message_list.dart'; +import 'page.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -64,6 +65,20 @@ void _showActionSheet( }); } +/// A button in an action sheet. +/// +/// When built from server data, the action sheet ignores changes in that data; +/// we intentionally don't live-update the buttons on events. +/// If a button's label, action, or position changes suddenly, +/// it can be confusing and make the on-tap behavior unexpected. +/// Better to let the user decide to tap +/// based on information that's comfortably in their working memory, +/// even if we sometimes have to explain (where we handle the tap) +/// that that information has changed and they need to decide again. +/// +/// (Even if we did live-update the buttons, it's possible anyway that a user's +/// action can race with a change that's already been applied on the server, +/// because it takes some time for the server to report changes to us.) abstract class ActionSheetMenuItemButton extends StatelessWidget { const ActionSheetMenuItemButton({super.key, required this.pageContext}); @@ -149,11 +164,19 @@ class ActionSheetCancelButton extends StatelessWidget { } /// Show a sheet of actions you can take on a topic. +/// +/// Needs a [PageRoot] ancestor. +/// +/// The API request for resolving/unresolving a topic needs a message ID. +/// If [someMessageIdInTopic] is null, the button for that will be absent. void showTopicActionSheet(BuildContext context, { required int channelId, - required String topic, + required TopicName topic, + required int? someMessageIdInTopic, }) { - final store = PerAccountStoreWidget.of(context); + final pageContext = PageRoot.contextOf(context); + + final store = PerAccountStoreWidget.of(pageContext); final subscription = store.subscriptions[channelId]; final optionButtons = []; @@ -223,9 +246,15 @@ void showTopicActionSheet(BuildContext context, { currentVisibilityPolicy: visibilityPolicy, newVisibilityPolicy: to, narrow: TopicNarrow(channelId, topic), - pageContext: context); + pageContext: pageContext); })); + if (someMessageIdInTopic != null) { + optionButtons.add(ResolveUnresolveButton(pageContext: pageContext, + topic: topic, + someMessageIdInTopic: someMessageIdInTopic)); + } + 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 @@ -236,7 +265,7 @@ void showTopicActionSheet(BuildContext context, { return; } - _showActionSheet(context, optionButtons: optionButtons); + _showActionSheet(pageContext, optionButtons: optionButtons); } class UserTopicUpdateButton extends ActionSheetMenuItemButton { @@ -358,18 +387,93 @@ class UserTopicUpdateButton extends ActionSheetMenuItemButton { } } +class ResolveUnresolveButton extends ActionSheetMenuItemButton { + ResolveUnresolveButton({ + super.key, + required this.topic, + required this.someMessageIdInTopic, + required super.pageContext, + }) : _actionIsResolve = !topic.isResolved; + + /// The topic that the action sheet was opened for. + /// + /// There might not currently be any messages with this topic; + /// see dartdoc of [ActionSheetMenuItemButton]. + final TopicName topic; + + /// The message ID that was passed when opening the action sheet. + /// + /// The message with this ID might currently not exist, + /// or might exist with a different topic; + /// see dartdoc of [ActionSheetMenuItemButton]. + final int someMessageIdInTopic; + + final bool _actionIsResolve; + + @override + IconData get icon => _actionIsResolve ? ZulipIcons.check : ZulipIcons.check_remove; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return _actionIsResolve + ? zulipLocalizations.actionSheetOptionResolveTopic + : zulipLocalizations.actionSheetOptionUnresolveTopic; + } + + @override void onPressed() async { + final zulipLocalizations = ZulipLocalizations.of(pageContext); + final store = PerAccountStoreWidget.of(pageContext); + + // We *could* check here if the topic has changed since the action sheet was + // opened (see dartdoc of [ActionSheetMenuItemButton]) and abort if so. + // We simplify by not doing so. + // There's already an inherent race that that check wouldn't help with: + // when you tap the button, an intervening topic change may already have + // happened, just not reached us in an event yet. + // Discussion, including about what web does: + // https://github.com/zulip/zulip-flutter/pull/1301#discussion_r1936181560 + + try { + await updateMessage(store.connection, + messageId: someMessageIdInTopic, + topic: _actionIsResolve ? topic.resolve() : topic.unresolve(), + propagateMode: PropagateMode.changeAll, + sendNotificationToOldThread: false, + sendNotificationToNewThread: true, + ); + } catch (e) { + if (!pageContext.mounted) return; + + String? errorMessage; + switch (e) { + case ZulipApiException(): + errorMessage = e.message; + // TODO(#741) specific messages for common errors, like network errors + // (support with reusable code) + default: + } + + final title = _actionIsResolve + ? zulipLocalizations.errorResolveTopicFailedTitle + : zulipLocalizations.errorUnresolveTopicFailedTitle; + showErrorDialog(context: pageContext, title: title, message: errorMessage); + } + } +} + /// Show a sheet of actions you can take on a message in the message list. /// /// Must have a [MessageListPage] ancestor. void showMessageActionSheet({required BuildContext context, required Message message}) { - final store = PerAccountStoreWidget.of(context); + final pageContext = PageRoot.contextOf(context); + final store = PerAccountStoreWidget.of(pageContext); // The UI that's conditioned on this won't live-update during this appearance // of the action sheet (we avoid calling composeBoxControllerOf in a build // method; see its doc). // So we rely on the fact that isComposeBoxOffered for any given message list // will be constant through the page's life. - final messageListPage = MessageListPage.ancestorOf(context); + final messageListPage = MessageListPage.ancestorOf(pageContext); final isComposeBoxOffered = messageListPage.composeBoxController != null; final isMessageRead = message.flags.contains(MessageFlag.read); @@ -377,18 +481,18 @@ void showMessageActionSheet({required BuildContext context, required Message mes final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead; final optionButtons = [ - ReactionButtons(message: message, pageContext: context), - StarButton(message: message, pageContext: context), + ReactionButtons(message: message, pageContext: pageContext), + StarButton(message: message, pageContext: pageContext), if (isComposeBoxOffered) - QuoteAndReplyButton(message: message, pageContext: context), + QuoteAndReplyButton(message: message, pageContext: pageContext), if (showMarkAsUnreadButton) - MarkAsUnreadButton(message: message, pageContext: context), - CopyMessageTextButton(message: message, pageContext: context), - CopyMessageLinkButton(message: message, pageContext: context), - ShareButton(message: message, pageContext: context), + MarkAsUnreadButton(message: message, pageContext: pageContext), + CopyMessageTextButton(message: message, pageContext: pageContext), + CopyMessageLinkButton(message: message, pageContext: pageContext), + ShareButton(message: message, pageContext: pageContext), ]; - _showActionSheet(context, optionButtons: optionButtons); + _showActionSheet(pageContext, optionButtons: optionButtons); } abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButton { @@ -633,6 +737,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { @override void onPressed() async { final zulipLocalizations = ZulipLocalizations.of(pageContext); + final message = this.message; var composeBoxController = findMessageListPage().composeBoxController; // The compose box doesn't null out its controller; it's either always null @@ -644,13 +749,15 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { && composeBoxController.topic.textNormalized == kNoTopicTopic && message is StreamMessage ) { - composeBoxController.topic.value = TextEditingValue(text: message.topic); + composeBoxController.topic.setTopic(message.topic); } // This inserts a "[Quoting…]" placeholder into the content input, // giving the user a form of progress feedback. final tag = composeBoxController.content - .registerQuoteAndReplyStart(PerAccountStoreWidget.of(pageContext), + .registerQuoteAndReplyStart( + zulipLocalizations, + PerAccountStoreWidget.of(pageContext), message: message, ); diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 75fec7bc8b..9fa82144a1 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -139,6 +139,52 @@ class ZulipApp extends StatefulWidget { } class _ZulipAppState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + 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!; + + final initialRouteUrl = Uri.tryParse(initialRoute); + if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { + final route = NotificationDisplayManager.routeForNotification( + context: context, + url: initialRouteUrl); + + 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 globalStore = GlobalStoreWidget.of(context); + // TODO(#524) choose initial account as last one used + final initialAccountId = globalStore.accounts.firstOrNull?.id; + return [ + if (initialAccountId == null) + MaterialWidgetRoute(page: const ChooseAccountPage()) + else + HomePage.buildRoute(accountId: initialAccountId), + ]; + } + @override Future didPushRouteInformation(routeInformation) async { switch (routeInformation.uri) { @@ -152,69 +198,39 @@ class _ZulipAppState extends State with WidgetsBindingObserver { return super.didPushRouteInformation(routeInformation); } - Future _handleInitialRoute() async { - final initialRouteUrl = Uri.parse(WidgetsBinding.instance.platformDispatcher.defaultRouteName); - if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { - await NotificationDisplayManager.navigateForNotification(initialRouteUrl); - } - } - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - _handleInitialRoute(); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - @override Widget build(BuildContext context) { final themeData = zulipThemeData(context); return GlobalStoreWidget( - child: Builder(builder: (context) { - final globalStore = GlobalStoreWidget.of(context); - // TODO(#524) choose initial account as last one used - final initialAccountId = globalStore.accounts.firstOrNull?.id; - return MaterialApp( - title: 'Zulip', - localizationsDelegates: ZulipLocalizations.localizationsDelegates, - supportedLocales: ZulipLocalizations.supportedLocales, - theme: themeData, - - navigatorKey: ZulipApp.navigatorKey, - navigatorObservers: widget.navigatorObservers ?? const [], - builder: (BuildContext context, Widget? child) { - if (!ZulipApp.ready.value) { - SchedulerBinding.instance.addPostFrameCallback( - (_) => widget._declareReady()); - } - GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context); - return child!; - }, - - // We use onGenerateInitialRoutes for the real work of specifying the - // initial nav state. To do that we need [MaterialApp] to decide to - // build a [Navigator]... which means specifying either `home`, `routes`, - // `onGenerateRoute`, or `onUnknownRoute`. Make it `onGenerateRoute`. - // It never actually gets called, though: `onGenerateInitialRoutes` - // handles startup, and then we always push whole routes with methods - // like [Navigator.push], never mere names as with [Navigator.pushNamed]. - onGenerateRoute: (_) => null, - - onGenerateInitialRoutes: (_) { - return [ - if (initialAccountId == null) - MaterialWidgetRoute(page: const ChooseAccountPage()) - else - HomePage.buildRoute(accountId: initialAccountId), - ]; - }); - })); + child: MaterialApp( + onGenerateTitle: (BuildContext context) { + return ZulipLocalizations.of(context).zulipAppTitle; + }, + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, + theme: themeData, + + navigatorKey: ZulipApp.navigatorKey, + navigatorObservers: widget.navigatorObservers ?? const [], + builder: (BuildContext context, Widget? child) { + if (!ZulipApp.ready.value) { + SchedulerBinding.instance.addPostFrameCallback( + (_) => widget._declareReady()); + } + GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context); + return child!; + }, + + // We use onGenerateInitialRoutes for the real work of specifying the + // initial nav state. To do that we need [MaterialApp] to decide to + // build a [Navigator]... which means specifying either `home`, `routes`, + // `onGenerateRoute`, or `onUnknownRoute`. Make it `onGenerateRoute`. + // It never actually gets called, though: `onGenerateInitialRoutes` + // handles startup, and then we always push whole routes with methods + // like [Navigator.push], never mere names as with [Navigator.pushNamed]. + onGenerateRoute: (_) => null, + + onGenerateInitialRoutes: _handleGenerateInitialRoutes)); } } @@ -323,6 +339,7 @@ class ChooseAccountPageOverflowButton extends StatelessWidget { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); final materialLocalizations = MaterialLocalizations.of(context); return MenuAnchor( menuChildren: [ @@ -330,7 +347,7 @@ class ChooseAccountPageOverflowButton extends StatelessWidget { onPressed: () { Navigator.push(context, AboutZulipPage.buildRoute(context)); }, - child: const Text('About Zulip')), // TODO(i18n) + child: Text(zulipLocalizations.aboutPageTitle)), ], builder: (BuildContext context, MenuController controller, Widget? child) { return IconButton( diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart index a69e71f7fa..f548557681 100644 --- a/lib/widgets/app_bar.dart +++ b/lib/widgets/app_bar.dart @@ -6,14 +6,66 @@ import 'store.dart'; /// /// This should be used for most of the pages with access to [PerAccountStore]. class ZulipAppBar extends AppBar { + /// Creates our Zulip custom app bar based on [AppBar]. + /// + /// [buildTitle] is passed a boolean `willCenterTitle` that answers + /// whether the underlying [AppBar] will decide to center [title] + /// based on [centerTitle], the theme, the platform, and [actions]. + /// Useful if [title] is a container whose children should align the same way, + /// such as a [Column] with multiple lines of text. + // TODO(upstream) send a PR to replace our `willCenterTitle` code ZulipAppBar({ super.key, super.titleSpacing, - required super.title, + Widget? title, + Widget Function(bool willCenterTitle)? buildTitle, + super.centerTitle, super.backgroundColor, super.shape, super.actions, - }) : super(bottom: _ZulipAppBarBottom(backgroundColor: backgroundColor)); + }) : + assert((title == null) != (buildTitle == null)), + super( + bottom: _ZulipAppBarBottom(backgroundColor: backgroundColor), + title: title ?? _Title(centerTitle: centerTitle, actions: actions, buildTitle: buildTitle!) + ); +} + +class _Title extends StatelessWidget { + const _Title({ + required this.centerTitle, + required this.actions, + required this.buildTitle, + }); + + final bool? centerTitle; + final List? actions; + final Widget Function(bool centerTitle) buildTitle; + + // A copy of [AppBar._getEffectiveCenterTitle]. + bool _getEffectiveCenterTitle(ThemeData theme) { + bool platformCenter() { + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return false; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return actions == null || actions!.length < 2; + } + } + + return centerTitle ?? theme.appBarTheme.centerTitle ?? platformCenter(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final willCenterTitle = _getEffectiveCenterTitle(theme); + return buildTitle(willCenterTitle); + } } class _ZulipAppBarBottom extends StatelessWidget implements PreferredSizeWidget { diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index a1e5289b01..40d1f2bf16 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -1,8 +1,11 @@ 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'; import '../model/autocomplete.dart'; import '../model/compose.dart'; @@ -173,7 +176,9 @@ class ComposeAutocomplete extends AutocompleteField _MentionAutocompleteItem(option: option), + MentionAutocompleteResult() => _MentionAutocompleteItem( + option: option, narrow: narrow), EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), }; return InkWell( @@ -223,18 +231,47 @@ class ComposeAutocomplete extends AutocompleteField= 247; // TODO(server-9) + final localizations = ZulipLocalizations.of(context); + final description = switch (wildcardOption) { + WildcardMentionOption.all || WildcardMentionOption.everyone => isDmNarrow + ? localizations.wildcardMentionAllDmDescription + : isChannelWildcardAvailable + ? localizations.wildcardMentionChannelDescription + : localizations.wildcardMentionStreamDescription, + WildcardMentionOption.channel => localizations.wildcardMentionChannelDescription, + WildcardMentionOption.stream => isChannelWildcardAvailable + ? localizations.wildcardMentionChannelDescription + : localizations.wildcardMentionStreamDescription, + WildcardMentionOption.topic => localizations.wildcardMentionTopicDescription, + }; + return Text.rich(TextSpan(text: '${wildcardOption.canonicalString} ', children: [ + TextSpan(text: description, style: TextStyle(fontSize: 12, + color: DefaultTextStyle.of(context).style.color?.withValues(alpha: 0.8)))])); + } @override Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); Widget avatar; - String label; + Widget label; switch (option) { case UserMentionAutocompleteResult(:var userId): - avatar = Avatar(userId: userId, size: 32, borderRadius: 3); - label = PerAccountStoreWidget.of(context).users[userId]!.fullName; + avatar = Avatar(userId: userId, size: 32, borderRadius: 3); // web uses 21px + label = Text(store.users[userId]!.fullName); + case WildcardMentionAutocompleteResult(:var wildcardOption): + avatar = const Icon(ZulipIcons.three_person, size: 29); // web uses 19px + label = wildcardLabel(wildcardOption, context: context, store: store); } return Padding( @@ -242,7 +279,7 @@ class _MentionAutocompleteItem extends StatelessWidget { child: Row(children: [ avatar, const SizedBox(width: 8), - Text(label), + label, ])); } } @@ -321,13 +358,8 @@ class TopicAutocomplete extends AutocompleteField { + static final light = ComposeBoxTheme._( + boxShadow: null, + ); + + static final dark = ComposeBoxTheme._( + boxShadow: [BoxShadow( + color: DesignVariables.dark.bgTopBar, + offset: const Offset(0, -4), + blurRadius: 16, + spreadRadius: 0, + )], + ); + + ComposeBoxTheme._({ + required this.boxShadow, + }); + + /// The [ComposeBoxTheme] from the context's active theme. + /// + /// The [ThemeData] must include [ComposeBoxTheme] in [ThemeData.extensions]. + static ComposeBoxTheme of(BuildContext context) { + final theme = Theme.of(context); + final extension = theme.extension(); + assert(extension != null); + return extension!; + } + + final List? boxShadow; + + @override + ComposeBoxTheme copyWith({ + List? boxShadow, + }) { + return ComposeBoxTheme._( + boxShadow: boxShadow ?? this.boxShadow, + ); + } + + @override + ComposeBoxTheme lerp(ComposeBoxTheme other, double t) { + if (identical(this, other)) { + return this; + } + return ComposeBoxTheme._( + boxShadow: BoxShadow.lerpList(boxShadow, other.boxShadow, t)!, + ); + } +} + const double _composeButtonSize = 44; /// A [TextEditingController] for use in the compose box. /// /// Subclasses must ensure that [_update] is called in all exposed constructors. abstract class ComposeController extends TextEditingController { + int get maxLengthUnicodeCodePoints; + String get textNormalized => _textNormalized; late String _textNormalized; String _computeTextNormalized(); + /// Length of [textNormalized] in Unicode code points + /// if it might exceed [maxLengthUnicodeCodePoints], else null. + /// + /// Use this instead of [String.length] + /// to enforce a max length expressed in code points. + /// [String.length] is conservative and may cut the user off too short. + /// + /// Counting code points ([String.runes]) + /// is more expensive than getting the number of UTF-16 code units + /// ([String.length]), so we avoid it when the result definitely won't exceed + /// [maxLengthUnicodeCodePoints]. + late int? _lengthUnicodeCodePointsIfLong; + @visibleForTesting + int? get debugLengthUnicodeCodePointsIfLong => _lengthUnicodeCodePointsIfLong; + int? _computeLengthUnicodeCodePointsIfLong() => + _textNormalized.length > maxLengthUnicodeCodePoints + ? _textNormalized.runes.length + : null; + List get validationErrors => _validationErrors; late List _validationErrors; List _computeValidationErrors(); @@ -40,6 +114,8 @@ abstract class ComposeController extends TextEditingController { void _update() { _textNormalized = _computeTextNormalized(); + // uses _textNormalized, so comes after _computeTextNormalized() + _lengthUnicodeCodePointsIfLong = _computeLengthUnicodeCodePointsIfLong(); _validationErrors = _computeValidationErrors(); hasValidationErrors.value = _validationErrors.isNotEmpty; } @@ -66,13 +142,17 @@ enum TopicValidationError { } class ComposeTopicController extends ComposeController { - ComposeTopicController() { + ComposeTopicController({required this.store}) { _update(); } - // TODO: subscribe to this value: - // https://zulip.com/help/require-topics - final mandatory = true; + PerAccountStore store; + + // TODO(#668): listen to [PerAccountStore] once we subscribe to this value + bool get mandatory => store.realmMandatoryTopics; + + // TODO(#307) use `max_topic_length` instead of hardcoded limit + @override final maxLengthUnicodeCodePoints = kMaxTopicLengthCodePoints; @override String _computeTextNormalized() { @@ -85,10 +165,18 @@ class ComposeTopicController extends ComposeController { return [ if (mandatory && textNormalized == kNoTopicTopic) TopicValidationError.mandatoryButEmpty, - if (textNormalized.length > kMaxTopicLength) + + if ( + _lengthUnicodeCodePointsIfLong != null + && _lengthUnicodeCodePointsIfLong! > maxLengthUnicodeCodePoints + ) TopicValidationError.tooLong, ]; } + + void setTopic(TopicName newTopic) { + value = TextEditingValue(text: newTopic.displayName); + } } enum ContentValidationError { @@ -116,6 +204,9 @@ class ComposeContentController extends ComposeController _update(); } + // TODO(#1237) use `max_message_length` instead of hardcoded limit + @override final maxLengthUnicodeCodePoints = kMaxMessageLengthCodePoints; + int _nextQuoteAndReplyTag = 0; int _nextUploadTag = 0; @@ -175,10 +266,15 @@ class ComposeContentController extends ComposeController /// /// Returns an int "tag" that should be passed to registerQuoteAndReplyEnd on /// success or failure - int registerQuoteAndReplyStart(PerAccountStore store, {required Message message}) { + int registerQuoteAndReplyStart( + ZulipLocalizations zulipLocalizations, + PerAccountStore store, { + required Message message, + }) { final tag = _nextQuoteAndReplyTag; _nextQuoteAndReplyTag += 1; - final placeholder = quoteAndReplyPlaceholder(store, message: message); + final placeholder = quoteAndReplyPlaceholder( + zulipLocalizations, store, message: message); _quoteAndReplies[tag] = (messageId: message.id, placeholder: placeholder); notifyListeners(); // _quoteAndReplies change could affect validationErrors insertPadded(placeholder); @@ -257,10 +353,10 @@ class ComposeContentController extends ComposeController if (textNormalized.isEmpty) ContentValidationError.empty, - // normalized.length is the number of UTF-16 code units, while the server - // API expresses the max in Unicode code points. So this comparison will - // be conservative and may cut the user off shorter than necessary. - if (textNormalized.length > kMaxMessageLengthCodePoints) + if ( + _lengthUnicodeCodePointsIfLong != null + && _lengthUnicodeCodePointsIfLong! > maxLengthUnicodeCodePoints + ) ContentValidationError.tooLong, if (_quoteAndReplies.isNotEmpty) @@ -483,10 +579,10 @@ class _StreamContentInputState extends State<_StreamContentInput> { final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); final streamName = store.streams[widget.narrow.streamId]?.name - ?? zulipLocalizations.composeBoxUnknownChannelName; + ?? zulipLocalizations.unknownChannelName; return _ContentInput( narrow: widget.narrow, - destination: TopicNarrow(widget.narrow.streamId, _topicTextNormalized), + destination: TopicNarrow(widget.narrow.streamId, TopicName(_topicTextNormalized)), controller: widget.controller, hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, _topicTextNormalized)); } @@ -545,8 +641,9 @@ class _FixedDestinationContentInput extends StatelessWidget { case TopicNarrow(:final streamId, :final topic): final store = PerAccountStoreWidget.of(context); final streamName = store.streams[streamId]?.name - ?? zulipLocalizations.composeBoxUnknownChannelName; - return zulipLocalizations.composeBoxChannelContentHint(streamName, topic); + ?? zulipLocalizations.unknownChannelName; + return zulipLocalizations.composeBoxChannelContentHint( + streamName, topic.displayName); case DmNarrow(otherRecipientIds: []): // The self-1:1 thread. return zulipLocalizations.composeBoxSelfDmContentHint; @@ -612,7 +709,8 @@ Future _uploadFiles({ if (tooLargeFiles.isNotEmpty) { final listMessage = tooLargeFiles - .map((file) => '${file.filename}: ${(file.length / (1 << 20)).toStringAsFixed(1)} MiB') + .map((file) => zulipLocalizations.filenameAndSizeInMiB( + file.filename, (file.length / (1 << 20)).toStringAsFixed(1))) .join('\n'); showErrorDialog( context: context, @@ -1048,10 +1146,12 @@ class _ComposeBoxContainer extends StatelessWidget { }; // TODO(design): Maybe put a max width on the compose box, like we do on - // the message list itself + // the message list itself; if so, remember to update ComposeBox's dartdoc. return Container(width: double.infinity, decoration: BoxDecoration( - border: Border(top: BorderSide(color: designVariables.borderBar))), + 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, @@ -1151,7 +1251,7 @@ class _StreamComposeBoxBody extends _ComposeBoxBody { @override Widget buildSendButton() => _SendButton( controller: controller, getDestination: () => StreamDestination( - narrow.streamId, controller.topic.textNormalized), + narrow.streamId, TopicName(controller.topic.textNormalized)), ); } @@ -1189,7 +1289,10 @@ sealed class ComposeBoxController { } class StreamComposeBoxController extends ComposeBoxController { - final topic = ComposeTopicController(); + StreamComposeBoxController({required PerAccountStore store}) + : topic = ComposeTopicController(store: store); + + final ComposeTopicController topic; final topicFocusNode = FocusNode(); @override @@ -1237,6 +1340,10 @@ class _ErrorBanner extends StatelessWidget { } } +/// The compose box. +/// +/// Takes the full screen width, covering the horizontal insets with its surface. +/// Also covers the bottom inset with its surface. class ComposeBox extends StatefulWidget { ComposeBox({super.key, required this.narrow}) : assert(ComposeBox.hasComposeBox(narrow)); @@ -1266,16 +1373,20 @@ abstract class ComposeBoxState extends State { ComposeBoxController get controller; } -class _ComposeBoxState extends State implements ComposeBoxState { - @override ComposeBoxController get controller => _controller; - late final ComposeBoxController _controller; +class _ComposeBoxState extends State with PerAccountStoreAwareStateMixin implements ComposeBoxState { + @override ComposeBoxController get controller => _controller!; + ComposeBoxController? _controller; @override - void initState() { - super.initState(); + void onNewStore() { switch (widget.narrow) { case ChannelNarrow(): - _controller = StreamComposeBoxController(); + final store = PerAccountStoreWidget.of(context); + if (_controller == null) { + _controller = StreamComposeBoxController(store: store); + } else { + (controller as StreamComposeBoxController).topic.store = store; + } case TopicNarrow(): case DmNarrow(): _controller = FixedDestinationComposeBoxController(); @@ -1288,7 +1399,7 @@ class _ComposeBoxState extends State implements ComposeBoxState { @override void dispose() { - _controller.dispose(); + controller.dispose(); super.dispose(); } @@ -1328,15 +1439,16 @@ class _ComposeBoxState extends State implements ComposeBoxState { return _ComposeBoxContainer(body: null, errorBanner: errorBanner); } + final controller = this.controller; final narrow = widget.narrow; - switch (_controller) { + switch (controller) { case StreamComposeBoxController(): { narrow as ChannelNarrow; - body = _StreamComposeBoxBody(controller: _controller, narrow: narrow); + body = _StreamComposeBoxBody(controller: controller, narrow: narrow); } case FixedDestinationComposeBoxController(): { narrow as SendableNarrow; - body = _FixedDestinationComposeBoxBody(controller: _controller, narrow: narrow); + body = _FixedDestinationComposeBoxBody(controller: controller, narrow: narrow); } } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index f3b369e876..8799e8d192 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1214,7 +1214,7 @@ class GlobalTime extends StatelessWidget { final GlobalTimeNode node; final TextStyle ambientTextStyle; - static final _dateFormat = DateFormat('EEE, MMM d, y, h:mm a'); // TODO(intl): localize date + static final _dateFormat = DateFormat('EEE, MMM d, y, h:mm a'); // TODO(i18n): localize date @override Widget build(BuildContext context) { @@ -1319,10 +1319,11 @@ class MessageTableCell extends StatelessWidget { void _launchUrl(BuildContext context, String urlString) async { DialogStatus showError(BuildContext context, String? message) { + final zulipLocalizations = ZulipLocalizations.of(context); return showErrorDialog(context: context, - title: 'Unable to open link', + title: zulipLocalizations.errorCouldNotOpenLinkTitle, message: [ - 'Link could not be opened: $urlString', + zulipLocalizations.errorCouldNotOpenLink(urlString), if (message != null) message, ].join("\n\n")); } @@ -1570,6 +1571,7 @@ InlineSpan _errorUnimplemented(UnimplementedNode node, {required BuildContext co // because release mode isn't yet about general users but developer demos, // and we want to keep the demos honest. // TODO(#194) think through UX for general release + // TODO(#1285) translate this final htmlNode = node.htmlNode; if (htmlNode is dom.Element) { return TextSpan(children: [ diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 98147a54d7..de3e2baa26 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -16,38 +16,36 @@ import 'theme.dart'; /// Emoji-reaction styles that differ between light and dark themes. class EmojiReactionTheme extends ThemeExtension { - EmojiReactionTheme.light() : - this._( - bgSelected: Colors.white, - - // TODO shadow effect, following web, which uses `box-shadow: inset`: - // https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow#inset - // Needs Flutter support for something like that: - // https://github.com/flutter/flutter/issues/18636 - // https://github.com/flutter/flutter/issues/52999 - // Until then use a solid color; a much-lightened version of the shadow color. - // Also adapt by making [borderUnselected] more transparent, so we'll - // want to check that against web when implementing the shadow. - bgUnselected: const HSLColor.fromAHSL(0.08, 210, 0.50, 0.875).toColor(), - - borderSelected: Colors.black.withValues(alpha: 0.45), - - // TODO see TODO on [bgUnselected] about shadow effect - borderUnselected: Colors.black.withValues(alpha: 0.05), - - textSelected: const HSLColor.fromAHSL(1, 210, 0.20, 0.20).toColor(), - textUnselected: const HSLColor.fromAHSL(1, 210, 0.20, 0.25).toColor(), - ); - - EmojiReactionTheme.dark() : - this._( - bgSelected: Colors.black.withValues(alpha: 0.8), - bgUnselected: Colors.black.withValues(alpha: 0.3), - borderSelected: Colors.white.withValues(alpha: 0.75), - borderUnselected: Colors.white.withValues(alpha: 0.15), - textSelected: Colors.white.withValues(alpha: 0.85), - textUnselected: Colors.white.withValues(alpha: 0.75), - ); + static final light = EmojiReactionTheme._( + bgSelected: Colors.white, + + // TODO shadow effect, following web, which uses `box-shadow: inset`: + // https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow#inset + // Needs Flutter support for something like that: + // https://github.com/flutter/flutter/issues/18636 + // https://github.com/flutter/flutter/issues/52999 + // Until then use a solid color; a much-lightened version of the shadow color. + // Also adapt by making [borderUnselected] more transparent, so we'll + // want to check that against web when implementing the shadow. + bgUnselected: const HSLColor.fromAHSL(0.08, 210, 0.50, 0.875).toColor(), + + borderSelected: Colors.black.withValues(alpha: 0.45), + + // TODO see TODO on [bgUnselected] about shadow effect + borderUnselected: Colors.black.withValues(alpha: 0.05), + + textSelected: const HSLColor.fromAHSL(1, 210, 0.20, 0.20).toColor(), + textUnselected: const HSLColor.fromAHSL(1, 210, 0.20, 0.25).toColor(), + ); + + static final dark = EmojiReactionTheme._( + bgSelected: Colors.black.withValues(alpha: 0.8), + bgUnselected: Colors.black.withValues(alpha: 0.3), + borderSelected: Colors.white.withValues(alpha: 0.75), + borderUnselected: Colors.white.withValues(alpha: 0.15), + textSelected: Colors.white.withValues(alpha: 0.85), + textUnselected: Colors.white.withValues(alpha: 0.75), + ); EmojiReactionTheme._({ required this.bgSelected, @@ -149,6 +147,7 @@ class ReactionChip extends StatelessWidget { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); final reactionType = reactionWithVotes.reactionType; final emojiCode = reactionWithVotes.emojiCode; @@ -162,8 +161,8 @@ class ReactionChip extends StatelessWidget { // // 'Chris、Greg、Alya、Shu' ? userIds.map((id) { return id == store.selfUserId - ? 'You' - : store.users[id]?.fullName ?? '(unknown user)'; // TODO(i18n) + ? zulipLocalizations.reactedEmojiSelfUser + : store.users[id]?.fullName ?? zulipLocalizations.unknownUserName; }).join(', ') : userIds.length.toString(); diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index 6b5a497928..d7b1585022 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -31,7 +31,7 @@ enum _HomePageTab { class HomePage extends StatefulWidget { const HomePage({super.key}); - static Route buildRoute({required int accountId}) { + static AccountRoute buildRoute({required int accountId}) { return MaterialAccountWidgetRoute(accountId: accountId, loadingPlaceholderPage: _LoadingPlaceholderPage(accountId: accountId), page: const HomePage()); @@ -151,6 +151,11 @@ const kTryAnotherAccountWaitPeriod = Duration(seconds: 5); class _LoadingPlaceholderPage extends StatefulWidget { const _LoadingPlaceholderPage({required this.accountId}); + /// The relevant account for this page. + /// + /// The account is not guaranteed to exist in the global store. This can + /// happen briefly when the account is removed from the database for logout, + /// but before [PerAccountStoreWidget.routeToRemoveOnLogout] is processed. final int accountId; @override @@ -180,9 +185,15 @@ class _LoadingPlaceholderPageState extends State<_LoadingPlaceholderPage> { @override Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); - final realmUrl = GlobalStoreWidget.of(context) - // TODO(#1219) `!` is incorrect - .getAccount(widget.accountId)!.realmUrl; + final account = GlobalStoreWidget.of(context).getAccount(widget.accountId); + + if (account == null) { + // We should only reach this state very briefly. + // See [_LoadingPlaceholderPage.accountId]. + return Scaffold( + appBar: AppBar(), + body: const SizedBox.shrink()); + } return Scaffold( appBar: AppBar(), @@ -201,7 +212,7 @@ class _LoadingPlaceholderPageState extends State<_LoadingPlaceholderPage> { child: Column( children: [ const SizedBox(height: 16), - Text(zulipLocalizations.tryAnotherAccountMessage(realmUrl.toString())), + Text(zulipLocalizations.tryAnotherAccountMessage(account.realmUrl.toString())), const SizedBox(height: 8), ElevatedButton( onPressed: () => Navigator.push(context, diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index ba21bec42f..82cb83704b 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -42,92 +42,101 @@ abstract final class ZulipIcons { /// The Zulip custom icon "camera". static const IconData camera = IconData(0xf106, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "check". + static const IconData check = IconData(0xf107, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "check_remove". + static const IconData check_remove = IconData(0xf108, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "chevron_right". - static const IconData chevron_right = IconData(0xf107, fontFamily: "Zulip Icons"); + static const IconData chevron_right = IconData(0xf109, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf108, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf10a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "contacts". - static const IconData contacts = IconData(0xf109, fontFamily: "Zulip Icons"); + static const IconData contacts = IconData(0xf10b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf10a, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf10c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf10b, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf10d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf122, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "three_person". + static const IconData three_person = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf126, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 12ec8751a1..799f763f1c 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../api/model/model.dart'; +import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; @@ -132,7 +133,7 @@ class _InboxPageState extends State with PerAccountStoreAwareStat }); for (final MapEntry(key: streamId, value: topics) in sortedUnreadStreams) { - final topicItems = <(String, int, bool, int)>[]; + final topicItems = <_StreamSectionTopicData>[]; int countInStream = 0; bool streamHasMention = false; for (final MapEntry(key: topic, value: messageIds) in topics.entries) { @@ -140,15 +141,20 @@ class _InboxPageState extends State with PerAccountStoreAwareStat final countInTopic = messageIds.length; final hasMention = messageIds.any((messageId) => unreadsModel!.mentions.contains(messageId)); if (hasMention) streamHasMention = true; - topicItems.add((topic, countInTopic, hasMention, messageIds.last)); + topicItems.add(_StreamSectionTopicData( + topic: topic, + count: countInTopic, + hasMention: hasMention, + lastUnreadId: messageIds.last, + )); countInStream += countInTopic; } if (countInStream == 0) { continue; } topicItems.sort((a, b) { - final (_, _, _, aLastUnreadId) = a; - final (_, _, _, bLastUnreadId) = b; + final aLastUnreadId = a.lastUnreadId; + final bLastUnreadId = b.lastUnreadId; return bLastUnreadId.compareTo(aLastUnreadId); }); sections.add(_StreamSectionData(streamId, countInStream, streamHasMention, topicItems)); @@ -192,11 +198,25 @@ class _StreamSectionData extends _InboxSectionData { final int streamId; final int count; final bool hasMention; - final List<(String, int, bool, int)> items; + final List<_StreamSectionTopicData> items; const _StreamSectionData(this.streamId, this.count, this.hasMention, this.items); } +class _StreamSectionTopicData { + final TopicName topic; + final int count; + final bool hasMention; + final int lastUnreadId; + + const _StreamSectionTopicData({ + required this.topic, + required this.count, + required this.hasMention, + required this.lastUnreadId, + }); +} + abstract class _HeaderItem extends StatelessWidget { final bool collapsed; final _InboxPageState pageState; @@ -218,7 +238,7 @@ abstract class _HeaderItem extends StatelessWidget { required this.sectionContext, }); - String get title; + String title(ZulipLocalizations zulipLocalizations); IconData get icon; Color collapsedIconColor(BuildContext context); Color uncollapsedIconColor(BuildContext context); @@ -238,6 +258,7 @@ abstract class _HeaderItem extends StatelessWidget { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); return Material( color: collapsed @@ -272,7 +293,7 @@ abstract class _HeaderItem extends StatelessWidget { ).merge(weightVariableTextStyle(context, wght: 600)), maxLines: 1, overflow: TextOverflow.ellipsis, - title))), + title(zulipLocalizations)))), const SizedBox(width: 12), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), Padding(padding: const EdgeInsetsDirectional.only(end: 16), @@ -293,7 +314,8 @@ class _AllDmsHeaderItem extends _HeaderItem { required super.sectionContext, }); - @override String get title => 'Direct messages'; // TODO(i18n) + @override String title(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.recentDmConversationsSectionHeader; @override IconData get icon => ZulipIcons.user; // TODO(design) check if this is the right variable for these @@ -362,16 +384,20 @@ class _DmItem extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final selfUser = store.users[store.selfUserId]!; + final zulipLocalizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); final title = switch (narrow.otherRecipientIds) { // TODO dedupe with [RecentDmConversationsItem] [] => selfUser.fullName, - [var otherUserId] => store.users[otherUserId]?.fullName ?? '(unknown user)', + [var otherUserId] => + store.users[otherUserId]?.fullName ?? zulipLocalizations.unknownUserName, // TODO(i18n): List formatting, like you can do in JavaScript: // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Shu']) // // 'Chris、Greg、Alya、Shu' - _ => narrow.otherRecipientIds.map((id) => store.users[id]?.fullName ?? '(unknown user)').join(', '), + _ => narrow.otherRecipientIds.map( + (id) => store.users[id]?.fullName ?? zulipLocalizations.unknownUserName + ).join(', '), }; return Material( @@ -417,7 +443,8 @@ class _StreamHeaderItem extends _HeaderItem { required super.sectionContext, }); - @override String get title => subscription.name; + @override String title(ZulipLocalizations zulipLocalizations) => + subscription.name; @override IconData get icon => iconDataForStream(subscription); @override Color collapsedIconColor(context) => colorSwatchFor(context, subscription).iconOnPlainBackground; @@ -466,33 +493,23 @@ class _StreamSection extends StatelessWidget { child: Column(children: [ header, if (!collapsed) ...data.items.map((item) { - final (topic, count, hasMention, _) = item; - return _TopicItem( - streamId: data.streamId, - topic: topic, - count: count, - hasMention: hasMention, - ); + return _TopicItem(streamId: data.streamId, data: item); }), ])); } } class _TopicItem extends StatelessWidget { - const _TopicItem({ - required this.streamId, - required this.topic, - required this.count, - required this.hasMention, - }); + const _TopicItem({required this.streamId, required this.data}); final int streamId; - final String topic; - final int count; - final bool hasMention; + final _StreamSectionTopicData data; @override Widget build(BuildContext context) { + final _StreamSectionTopicData( + :topic, :count, :hasMention, :lastUnreadId) = data; + final store = PerAccountStoreWidget.of(context); final subscription = store.subscriptions[streamId]!; @@ -509,7 +526,9 @@ class _TopicItem extends StatelessWidget { MessageListPage.buildRoute(context: context, narrow: narrow)); }, onLongPress: () => showTopicActionSheet(context, - channelId: streamId, topic: topic), + channelId: streamId, + topic: topic, + someMessageIdInTopic: lastUnreadId), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ const SizedBox(width: 63), @@ -524,7 +543,7 @@ class _TopicItem extends StatelessWidget { ), maxLines: 2, overflow: TextOverflow.ellipsis, - topic))), + topic.displayName))), const SizedBox(width: 12), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), // TODO(design) copies the "@" marker color; is there a better color? diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 4635e496f4..65d013a4b6 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -340,7 +340,7 @@ class VideoDurationLabel extends StatelessWidget { final hours = value.inHours.toString().padLeft(2, '0'); final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '${hours == '00' ? '' : '$hours:'}$minutes:$seconds'; + return '${hours == '00' ? '' : '$hours:'}$minutes:$seconds'; // TODO(i18n) } @override @@ -385,13 +385,14 @@ class _VideoPositionSliderControlState extends State<_VideoPositionSliderControl @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); final currentPosition = _isSliderDragging ? _sliderValue : widget.controller.value.position; return Row(children: [ VideoDurationLabel(currentPosition, - semanticsLabel: "Current position"), + semanticsLabel: zulipLocalizations.lightboxVideoCurrentPosition), Expanded( child: Slider( value: currentPosition.inMilliseconds.toDouble(), @@ -421,7 +422,7 @@ class _VideoPositionSliderControlState extends State<_VideoPositionSliderControl ), ), VideoDurationLabel(widget.controller.value.duration, - semanticsLabel: "Video duration"), + semanticsLabel: zulipLocalizations.lightboxVideoDuration), ]); } } diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index ce1cf09438..195bf75e62 100644 --- a/lib/widgets/login.dart +++ b/lib/widgets/login.dart @@ -115,6 +115,20 @@ class AddAccountPage extends StatefulWidget { return _LoginSequenceRoute(page: const AddAccountPage()); } + /// The hint text to show in the "Zulip server URL" input. + /// + /// If this contains an example value, it must be one that has been reserved + /// so that it cannot point to a real Zulip realm (nor any unknown other site). + /// The realm name `your-org` under zulipchat.com is reserved for this reason. + /// See discussion: + /// https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/flutter.3A.20login.20URL/near/1570347 + // TODO(i18n): In principle this should be translated, because it's trying to + // convey to the user the English phrase "your org". But doing that is + // tricky because of the need to have the example name reserved. + // Realistically that probably means we'll only ever translate this for + // at most a handful of languages, most likely none. + static const _serverUrlHint = 'your-org.zulipchat.com'; + @override State createState() => _AddAccountPageState(); } @@ -230,10 +244,10 @@ class _AddAccountPageState extends State { // …but leave out unfocusing the input in case more editing is needed. }, decoration: InputDecoration( - labelText: zulipLocalizations.loginServerUrlInputLabel, + labelText: zulipLocalizations.loginServerUrlLabel, errorText: errorText, helperText: kLayoutPinningHelperText, - hintText: 'your-org.zulipchat.com')), + hintText: AddAccountPage._serverUrlHint)), const SizedBox(height: 8), ElevatedButton( onPressed: !_inProgress && errorText == null diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 8c32a12115..25993efa30 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -27,56 +27,54 @@ import 'theme.dart'; /// Message-list styles that differ between light and dark themes. class MessageListTheme extends ThemeExtension { - MessageListTheme.light() : - this._( - dateSeparator: Colors.black, - dateSeparatorText: const HSLColor.fromAHSL(0.75, 0, 0, 0.15).toColor(), - dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), - messageTimestamp: const HSLColor.fromAHSL(0.8, 0, 0, 0.2).toColor(), - recipientHeaderText: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(), - senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.08, 0.65).toColor(), - senderName: const HSLColor.fromAHSL(1, 0, 0, 0.2).toColor(), - streamMessageBgDefault: Colors.white, - streamRecipientHeaderChevronRight: Colors.black.withValues(alpha: 0.3), - - // From the Figma mockup at: - // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=132-9684 - // See discussion about design at: - // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20unread.20marker/near/1658008 - // (Web uses a left-to-right gradient from hsl(217deg 64% 59%) to transparent, - // in both light and dark theme.) - unreadMarker: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor(), - - unreadMarkerGap: Colors.white.withValues(alpha: 0.6), - - // TODO(design) this seems ad-hoc; is there a better color? - unsubscribedStreamRecipientHeaderBg: const Color(0xfff5f5f5), - ); - - MessageListTheme.dark() : - this._( - dateSeparator: Colors.white, - dateSeparatorText: const HSLColor.fromAHSL(0.75, 0, 0, 1).toColor(), - dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), - messageTimestamp: const HSLColor.fromAHSL(0.8, 0, 0, 0.85).toColor(), - recipientHeaderText: const HSLColor.fromAHSL(0.8, 0, 0, 1).toColor(), - senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.05, 0.5).toColor(), - senderName: const HSLColor.fromAHSL(0.85, 0, 0, 1).toColor(), - streamMessageBgDefault: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(), - streamRecipientHeaderChevronRight: Colors.white.withValues(alpha: 0.3), - - // 0.75 opacity from here: - // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=807-33998&m=dev - // Discussion, some weeks after the discussion linked on the light variant: - // https://github.com/zulip/zulip-flutter/pull/317#issuecomment-1784311663 - // where Vlad includes screenshots that look like they're from there. - unreadMarker: const HSLColor.fromAHSL(0.75, 227, 0.78, 0.59).toColor(), - - unreadMarkerGap: Colors.transparent, - - // TODO(design) this is ad-hoc and untested; is there a better color? - unsubscribedStreamRecipientHeaderBg: const Color(0xff0a0a0a), - ); + static final light = MessageListTheme._( + dateSeparator: Colors.black, + dateSeparatorText: const HSLColor.fromAHSL(0.75, 0, 0, 0.15).toColor(), + dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), + messageTimestamp: const HSLColor.fromAHSL(0.8, 0, 0, 0.2).toColor(), + recipientHeaderText: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(), + senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.08, 0.65).toColor(), + senderName: const HSLColor.fromAHSL(1, 0, 0, 0.2).toColor(), + streamMessageBgDefault: Colors.white, + streamRecipientHeaderChevronRight: Colors.black.withValues(alpha: 0.3), + + // From the Figma mockup at: + // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=132-9684 + // See discussion about design at: + // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20unread.20marker/near/1658008 + // (Web uses a left-to-right gradient from hsl(217deg 64% 59%) to transparent, + // in both light and dark theme.) + unreadMarker: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor(), + + unreadMarkerGap: Colors.white.withValues(alpha: 0.6), + + // TODO(design) this seems ad-hoc; is there a better color? + unsubscribedStreamRecipientHeaderBg: const Color(0xfff5f5f5), + ); + + static final dark = MessageListTheme._( + dateSeparator: Colors.white, + dateSeparatorText: const HSLColor.fromAHSL(0.75, 0, 0, 1).toColor(), + dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), + messageTimestamp: const HSLColor.fromAHSL(0.8, 0, 0, 0.85).toColor(), + recipientHeaderText: const HSLColor.fromAHSL(0.8, 0, 0, 1).toColor(), + senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.05, 0.5).toColor(), + senderName: const HSLColor.fromAHSL(0.85, 0, 0, 1).toColor(), + streamMessageBgDefault: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(), + streamRecipientHeaderChevronRight: Colors.white.withValues(alpha: 0.3), + + // 0.75 opacity from here: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=807-33998&m=dev + // Discussion, some weeks after the discussion linked on the light variant: + // https://github.com/zulip/zulip-flutter/pull/317#issuecomment-1784311663 + // where Vlad includes screenshots that look like they're from there. + unreadMarker: const HSLColor.fromAHSL(0.75, 227, 0.78, 0.59).toColor(), + + unreadMarkerGap: Colors.transparent, + + // TODO(design) this is ad-hoc and untested; is there a better color? + unsubscribedStreamRecipientHeaderBg: const Color(0xff0a0a0a), + ); MessageListTheme._({ required this.dateSeparator, @@ -155,7 +153,7 @@ class MessageListTheme extends ThemeExtension { return MessageListTheme._( dateSeparator: Color.lerp(dateSeparator, other.dateSeparator, t)!, dateSeparatorText: Color.lerp(dateSeparatorText, other.dateSeparatorText, t)!, - dmRecipientHeaderBg: Color.lerp(streamMessageBgDefault, other.dmRecipientHeaderBg, t)!, + dmRecipientHeaderBg: Color.lerp(dmRecipientHeaderBg, other.dmRecipientHeaderBg, t)!, messageTimestamp: Color.lerp(messageTimestamp, other.messageTimestamp, t)!, recipientHeaderText: Color.lerp(recipientHeaderText, other.recipientHeaderText, t)!, senderBotIcon: Color.lerp(senderBotIcon, other.senderBotIcon, t)!, @@ -177,14 +175,20 @@ abstract class MessageListPageState { Narrow get narrow; /// The controller for this [MessageListPage]'s compose box, - /// if this [MessageListPage] offers a compose box. + /// if this [MessageListPage] offers a compose box and it has mounted, + /// else null. ComposeBoxController? get composeBoxController; + + /// The active [MessageListView]. + /// + /// This is null if [MessageList] has not mounted yet. + MessageListView? get model; } class MessageListPage extends StatefulWidget { const MessageListPage({super.key, required this.initNarrow}); - static Route buildRoute({int? accountId, BuildContext? context, + static AccountRoute buildRoute({int? accountId, BuildContext? context, required Narrow narrow}) { return MaterialAccountWidgetRoute(accountId: accountId, context: context, page: MessageListPage(initNarrow: narrow)); @@ -214,9 +218,12 @@ class _MessageListPageState extends State implements MessageLis @override ComposeBoxController? get composeBoxController => _composeBoxKey.currentState?.controller; - final GlobalKey _composeBoxKey = GlobalKey(); + @override + MessageListView? get model => _messageListKey.currentState?.model; + final GlobalKey<_MessageListState> _messageListKey = GlobalKey(); + @override void initState() { super.initState(); @@ -262,8 +269,6 @@ class _MessageListPageState extends State implements MessageLis List? actions; if (narrow case TopicNarrow(:final streamId)) { - // The helper [_getEffectiveCenterTitle] relies on the fact that we - // have at most one action here. (actions ??= []).add(IconButton( icon: const Icon(ZulipIcons.message_feed), tooltip: zulipLocalizations.channelFeedButtonTooltip, @@ -272,9 +277,12 @@ class _MessageListPageState extends State implements MessageLis narrow: ChannelNarrow(streamId))))); } - return Scaffold( + // Insert a PageRoot here, to provide a context that can be used for + // MessageListPage.ancestorOf. + return PageRoot(child: Scaffold( appBar: ZulipAppBar( - title: MessageListAppBarTitle(narrow: narrow), + buildTitle: (willCenterTitle) => + MessageListAppBarTitle(narrow: narrow, willCenterTitle: willCenterTitle), actions: actions, backgroundColor: appBarBackgroundColor, shape: removeAppBarBottomBorder @@ -288,8 +296,11 @@ 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) => Center( - child: Column(children: [ + builder: (BuildContext context) => 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. + children: [ MediaQuery.removePadding( // Scaffold knows about the app bar, and so has run this // BuildContext, which is under `body`, through @@ -302,7 +313,11 @@ class _MessageListPageState extends State implements MessageLis removeBottom: ComposeBox.hasComposeBox(narrow), child: Expanded( - child: MessageList(narrow: narrow, onNarrowChanged: _narrowChanged))), + child: MessageList( + key: _messageListKey, + narrow: narrow, + onNarrowChanged: _narrowChanged, + ))), if (ComposeBox.hasComposeBox(narrow)) ComposeBox(key: _composeBoxKey, narrow: narrow) ])))); @@ -310,13 +325,19 @@ class _MessageListPageState extends State implements MessageLis } class MessageListAppBarTitle extends StatelessWidget { - const MessageListAppBarTitle({super.key, required this.narrow}); + const MessageListAppBarTitle({ + super.key, + required this.narrow, + required this.willCenterTitle, + }); final Narrow narrow; + final bool willCenterTitle; Widget _buildStreamRow(BuildContext context, { ZulipStream? stream, }) { + final zulipLocalizations = ZulipLocalizations.of(context); // A null [Icon.icon] makes a blank space. final icon = stream != null ? iconDataForStream(stream) : null; return Row( @@ -328,13 +349,14 @@ class MessageListAppBarTitle extends StatelessWidget { children: [ Icon(size: 16, icon), const SizedBox(width: 4), - Flexible(child: Text(stream?.name ?? '(unknown channel)')), + Flexible(child: Text( + stream?.name ?? zulipLocalizations.unknownChannelName)), ]); } Widget _buildTopicRow(BuildContext context, { required ZulipStream? stream, - required String topic, + required TopicName topic, }) { final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); @@ -344,7 +366,7 @@ class MessageListAppBarTitle extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - Flexible(child: Text(topic, style: const TextStyle( + Flexible(child: Text(topic.displayName, style: const TextStyle( fontSize: 13, ).merge(weightVariableTextStyle(context)))), if (icon != null) @@ -356,29 +378,6 @@ class MessageListAppBarTitle extends StatelessWidget { ]); } - // TODO(upstream): provide an API for this - // Adapted from [AppBar._getEffectiveCenterTitle]. - bool _getEffectiveCenterTitle(ThemeData theme) { - bool platformCenter() { - switch (theme.platform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - return false; - case TargetPlatform.iOS: - case TargetPlatform.macOS: - // We rely on the fact that there is at most one action - // on the message list app bar, so that the expression returned - // in the original helper, `actions == null || actions!.length < 2`, - // always evaluates to `true`: - return true; - } - } - - return theme.appBarTheme.centerTitle ?? platformCenter(); - } - @override Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); @@ -399,19 +398,29 @@ class MessageListAppBarTitle extends StatelessWidget { return _buildStreamRow(context, stream: stream); case TopicNarrow(:var streamId, :var topic): - final theme = Theme.of(context); final store = PerAccountStoreWidget.of(context); final stream = store.streams[streamId]; - final centerTitle = _getEffectiveCenterTitle(theme); return SizedBox( width: double.infinity, child: GestureDetector( behavior: HitTestBehavior.translucent, - onLongPress: () => showTopicActionSheet(context, - channelId: streamId, topic: topic), + onLongPress: () { + final someMessage = MessageListPage.ancestorOf(context) + .model?.messages.firstOrNull; + // If someMessage is null, the topic action sheet won't have a + // resolve/unresolve button. That seems OK; in that case we're + // 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)); + showTopicActionSheet(context, + channelId: streamId, + topic: topic, + someMessageIdInTopic: someMessage?.id); + }, child: Column( - crossAxisAlignment: centerTitle ? CrossAxisAlignment.center - : CrossAxisAlignment.start, + crossAxisAlignment: willCenterTitle ? CrossAxisAlignment.center + : CrossAxisAlignment.start, children: [ _buildStreamRow(context, stream: stream), _buildTopicRow(context, stream: stream, topic: topic), @@ -420,10 +429,13 @@ class MessageListAppBarTitle extends StatelessWidget { case DmNarrow(:var otherRecipientIds): final store = PerAccountStoreWidget.of(context); if (otherRecipientIds.isEmpty) { - return const Text("DMs with yourself"); + return Text(zulipLocalizations.dmsWithYourselfPageTitle); } else { - final names = otherRecipientIds.map((id) => store.users[id]?.fullName ?? '(unknown user)'); - return Text("DMs with ${names.join(", ")}"); // TODO show avatars + final names = otherRecipientIds.map( + (id) => store.users[id]?.fullName ?? zulipLocalizations.unknownUserName); + // TODO show avatars + return Text( + zulipLocalizations.dmsWithOthersPageTitle(names.join(', '))); } } } @@ -442,6 +454,12 @@ const _kShortMessageHeight = 80; // previous batch. const kFetchMessagesBufferPixels = (kMessageListFetchBatchSize / 2) * _kShortMessageHeight; +/// The message list. +/// +/// Takes the full screen width, keeping its contents +/// out of the horizontal insets with transparent [SafeArea] padding. +/// 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}); @@ -535,12 +553,15 @@ class _MessageListState extends State with PerAccountStoreAwareStat // 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 - // no compose box, we want to let the message-list content pad it. + // no compose box, we want to let the message-list content + // and the scroll-to-bottom button avoid it. // TODO(#311) Remove as unnecessary if we do a bottom nav. // The nav will pad the bottom inset, and an ancestor of this widget // will have a `MediaQuery.removePadding` with `removeBottom: true`. bottom: false, + // Horizontally, on wide screens, this Center grows the SafeArea + // to position its padding over the device insets and centers content. child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 760), @@ -553,7 +574,9 @@ class _MessageListState extends State with PerAccountStoreAwareStat bottom: 0, right: 0, // TODO(#311) SafeArea shouldn't be needed if we have a - // bottom nav. That will pad the bottom inset. + // bottom nav; that will pad the bottom inset. Remove it, + // and the mention of bottom-inset handling in + // MessageList's dartdoc. child: SafeArea( child: ScrollToBottomButton( scrollController: scrollController, @@ -564,6 +587,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat Widget _buildListView(BuildContext context) { final length = model!.items.length; const centerSliverKey = ValueKey('center sliver'); + final zulipLocalizations = ZulipLocalizations.of(context); Widget sliver = SliverStickyHeaderList( headerPlacement: HeaderPlacement.scrollingStart, @@ -600,12 +624,12 @@ class _MessageListState extends State with PerAccountStoreAwareStat if (i == 2) return TypingStatusWidget(narrow: widget.narrow); final data = model!.items[length - 1 - (i - 3)]; - return _buildItem(data, i); + return _buildItem(zulipLocalizations, data, i); })); if (!ComposeBox.hasComposeBox(widget.narrow)) { - // TODO(#311) If we have a bottom nav, it will pad the bottom - // inset, and this shouldn't be necessary + // TODO(#311) If we have a bottom nav, it will pad the bottom inset, + // and this can be removed; also remove mention in MessageList dartdoc sliver = SliverSafeArea(sliver: sliver); } @@ -636,13 +660,13 @@ class _MessageListState extends State with PerAccountStoreAwareStat ]); } - Widget _buildItem(MessageListItem data, int i) { + Widget _buildItem(ZulipLocalizations zulipLocalizations, MessageListItem data, int i) { switch (data) { case MessageListHistoryStartItem(): - return const Center( + return Center( child: Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - child: Text("No earlier messages."))); // TODO use an icon + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Text(zulipLocalizations.noEarlierMessages))); // TODO use an icon case MessageListLoadingItem(): return const Center( child: Padding( @@ -686,6 +710,7 @@ class ScrollToBottomButton extends StatelessWidget { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); return ValueListenableBuilder( valueListenable: visibleValue, builder: (BuildContext context, bool value, Widget? child) { @@ -693,7 +718,7 @@ class ScrollToBottomButton extends StatelessWidget { }, // TODO: fix hardcoded values for size and style here child: IconButton( - tooltip: "Scroll to bottom", + tooltip: zulipLocalizations.scrollToBottomTooltip, icon: const Icon(Icons.expand_circle_down_rounded), iconSize: 40, // Web has the same color in light and dark mode. @@ -880,27 +905,12 @@ class RecipientHeader extends StatelessWidget { final Message message; final Narrow narrow; - static bool _containsDifferentChannels(Narrow narrow) { - switch (narrow) { - case CombinedFeedNarrow(): - case MentionsNarrow(): - case StarredMessagesNarrow(): - return true; - - case ChannelNarrow(): - case TopicNarrow(): - case DmNarrow(): - return false; - } - } - @override Widget build(BuildContext context) { final message = this.message; return switch (message) { - StreamMessage() => StreamMessageRecipientHeader(message: message, - showStream: _containsDifferentChannels(narrow)), - DmMessage() => DmRecipientHeader(message: message), + StreamMessage() => StreamMessageRecipientHeader(message: message, narrow: narrow), + DmMessage() => DmRecipientHeader(message: message, narrow: narrow), }; } } @@ -1014,11 +1024,25 @@ class StreamMessageRecipientHeader extends StatelessWidget { const StreamMessageRecipientHeader({ super.key, required this.message, - required this.showStream, + required this.narrow, }); final StreamMessage message; - final bool showStream; + final Narrow narrow; + + static bool _containsDifferentChannels(Narrow narrow) { + switch (narrow) { + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + return true; + + case ChannelNarrow(): + case TopicNarrow(): + case DmNarrow(): + return false; + } + } @override Widget build(BuildContext context) { @@ -1027,6 +1051,7 @@ class StreamMessageRecipientHeader extends StatelessWidget { // https://github.com/zulip/zulip-mobile/issues/5511 final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); final topic = message.topic; @@ -1045,13 +1070,13 @@ class StreamMessageRecipientHeader extends StatelessWidget { } final Widget streamWidget; - if (!showStream) { + if (!_containsDifferentChannels(narrow)) { streamWidget = const SizedBox(width: 16); } else { final stream = store.streams[message.streamId]; final streamName = stream?.name ?? message.displayRecipient - ?? '(unknown channel)'; // TODO(log) + ?? zulipLocalizations.unknownChannelName; // TODO(log) streamWidget = GestureDetector( onTap: () => Navigator.push(context, @@ -1091,7 +1116,7 @@ class StreamMessageRecipientHeader extends StatelessWidget { child: Row( children: [ Flexible( - child: Text(topic, + child: Text(topic.displayName, // TODO: Give a way to see the whole topic (maybe a // long-press interaction?) overflow: TextOverflow.ellipsis, @@ -1105,11 +1130,18 @@ class StreamMessageRecipientHeader extends StatelessWidget { ])); return GestureDetector( - onTap: () => Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: TopicNarrow.ofMessage(message))), + // When already in a topic narrow, disable tap interaction that would just + // push a MessageListPage for the same topic narrow. + // TODO(#1039) simplify by removing topic-narrow condition if we remove + // recipient headers in topic narrows + onTap: narrow is TopicNarrow ? null + : () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: TopicNarrow.ofMessage(message))), onLongPress: () => showTopicActionSheet(context, - channelId: message.streamId, topic: topic), + channelId: message.streamId, + topic: topic, + someMessageIdInTopic: message.id), child: ColoredBox( color: backgroundColor, child: Row( @@ -1126,9 +1158,14 @@ class StreamMessageRecipientHeader extends StatelessWidget { } class DmRecipientHeader extends StatelessWidget { - const DmRecipientHeader({super.key, required this.message}); + const DmRecipientHeader({ + super.key, + required this.message, + required this.narrow, + }); final DmMessage message; + final Narrow narrow; @override Widget build(BuildContext context) { @@ -1149,9 +1186,14 @@ class DmRecipientHeader extends StatelessWidget { final messageListTheme = MessageListTheme.of(context); return GestureDetector( - onTap: () => Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: DmNarrow.ofMessage(message, selfUserId: store.selfUserId))), + // When already in a DM narrow, disable tap interaction that would just + // push a MessageListPage for the same DM narrow. + // TODO(#1244) simplify by removing DM-narrow condition if we remove + // recipient headers in DM narrows + onTap: narrow is DmNarrow ? null + : () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: DmNarrow.ofMessage(message, selfUserId: store.selfUserId))), child: ColoredBox( color: messageListTheme.dmRecipientHeaderBg, child: Padding( @@ -1384,5 +1426,5 @@ class MessageWithPossibleSender extends StatelessWidget { } } -// TODO web seems to ignore locale in formatting time, but we could do better +// TODO(i18n): web seems to ignore locale in formatting time, but we could do better final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); diff --git a/lib/widgets/page.dart b/lib/widgets/page.dart index bebb37c22c..a2c6fe52a1 100644 --- a/lib/widgets/page.dart +++ b/lib/widgets/page.dart @@ -3,6 +3,30 @@ import 'package:flutter/material.dart'; import 'store.dart'; +/// An [InheritedWidget] for near the root of a page's widget subtree, +/// providing its [BuildContext]. +/// +/// Useful when needing a context that persists through the page's lifespan, +/// e.g. for a show-action-sheet function +/// whose buttons use a context to close the sheet +/// or show an error dialog / snackbar asynchronously. +/// +/// (In this scenario, it would be buggy to use the context of the element +/// that was long-pressed, +/// if the element can unmount as part of handling a Zulip event.) +class PageRoot extends InheritedWidget { + const PageRoot({super.key, required super.child}); + + @override + bool updateShouldNotify(covariant PageRoot oldWidget) => false; + + static BuildContext contextOf(BuildContext context) { + final element = context.getElementForInheritedWidgetOfExactType(); + assert(element != null, 'No PageRoot ancestor'); + return element!; + } +} + /// A page route that always builds the same widget. /// /// This is useful for making the route more transparent for a test to inspect. @@ -11,6 +35,12 @@ abstract class WidgetRoute extends PageRoute { Widget get page; } +/// A page route that specifies a particular Zulip account to use, by ID. +abstract class AccountRoute extends PageRoute { + /// The [Account.id] of the account to use for this page. + int get accountId; +} + /// A [MaterialPageRoute] that always builds the same widget. /// /// This is useful for making the route more transparent for a test to inspect. @@ -32,8 +62,10 @@ class MaterialWidgetRoute extends MaterialPageRoute implem } /// A mixin for providing a given account's per-account store on a page route. -mixin AccountPageRouteMixin on PageRoute { +mixin AccountPageRouteMixin on PageRoute implements AccountRoute { + @override int get accountId; + Widget? get loadingPlaceholderPage; @override @@ -42,7 +74,10 @@ mixin AccountPageRouteMixin on PageRoute { accountId: accountId, placeholder: loadingPlaceholderPage ?? const LoadingPlaceholderPage(), routeToRemoveOnLogout: this, - child: super.buildPage(context, animation, secondaryAnimation)); + // PageRoot goes under PerAccountStoreWidget, so the provided context + // can be used for PerAccountStoreWidget.of. + child: PageRoot( + child: super.buildPage(context, animation, secondaryAnimation))); } } diff --git a/lib/widgets/poll.dart b/lib/widgets/poll.dart index 07123fe196..dc340d08b8 100644 --- a/lib/widgets/poll.dart +++ b/lib/widgets/poll.dart @@ -126,8 +126,8 @@ class _PollWidgetState extends State { children: [ Text(option.text, style: textStyleBold.copyWith(fontSize: 16)), if (option.voters.isNotEmpty) - // TODO(i18n): Localize parenthesis characters. - Text('($voterNames)', style: textStyleVoterNames), + Text(zulipLocalizations.pollVoterNames(voterNames), + style: textStyleVoterNames), ]))), ]); } diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 57dd76a0ab..327910f6c0 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -30,7 +30,7 @@ class ProfilePage extends StatelessWidget { final int userId; - static Route buildRoute({int? accountId, BuildContext? context, + static AccountRoute buildRoute({int? accountId, BuildContext? context, required int userId}) { return MaterialAccountWidgetRoute(accountId: accountId, context: context, page: ProfilePage(userId: userId)); @@ -121,17 +121,18 @@ class _ProfileErrorPage extends StatelessWidget { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); return Scaffold( - appBar: ZulipAppBar(title: const Text('Error')), - body: const SingleChildScrollView( + appBar: ZulipAppBar(title: Text(zulipLocalizations.errorDialogTitle)), + body: SingleChildScrollView( child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 32), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error), - SizedBox(width: 4), - Text('Could not show user profile.'), + const Icon(Icons.error), + const SizedBox(width: 4), + Text(zulipLocalizations.errorCouldNotShowUserProfile), ])))); } } @@ -199,7 +200,7 @@ class _ProfileDataTable extends StatelessWidget { // TODO(server): The value's format is undocumented, but empirically // it's a date in ISO format, like 2000-01-01. // That's readable as is, but: - // TODO format this date using user's locale. + // TODO(i18n) format this date using user's locale. return _TextWidget(text: value); case CustomProfileFieldType.shortText: @@ -290,8 +291,9 @@ class _UserWidget extends StatelessWidget { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); final user = store.users[userId]; - final fullName = user?.fullName ?? '(unknown user)'; + final fullName = user?.fullName ?? zulipLocalizations.unknownUserName; return InkWell( onTap: () => Navigator.push(context, ProfilePage.buildRoute(context: context, diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index c9d3131591..ddc32861a3 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -1,5 +1,6 @@ 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'; @@ -81,6 +82,7 @@ class RecentDmConversationsItem extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final selfUser = store.users[store.selfUserId]!; + final zulipLocalizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); final String title; @@ -94,13 +96,15 @@ class RecentDmConversationsItem extends StatelessWidget { // (should we offer a "spam folder" style summary screen of recent // 1:1 DM conversations from muted users?) final otherUser = store.users[otherUserId]; - title = otherUser?.fullName ?? '(unknown user)'; + title = otherUser?.fullName ?? zulipLocalizations.unknownUserName; avatar = AvatarImage(userId: otherUserId, size: _avatarSize); default: // TODO(i18n): List formatting, like you can do in JavaScript: // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) // // 'Chris、Greg、Alya' - title = narrow.otherRecipientIds.map((id) => store.users[id]?.fullName ?? '(unknown user)').join(', '); + title = narrow.otherRecipientIds.map( + (id) => store.users[id]?.fullName ?? zulipLocalizations.unknownUserName + ).join(', '); avatar = ColoredBox(color: designVariables.groupDmConversationIconBg, child: Center( child: Icon(color: designVariables.groupDmConversationIcon, diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 356a056094..24eeb8d518 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -489,6 +489,11 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper if (_header != null) adoptChild(_header!); } + /// This sliver's child sliver, a modified [RenderSliverList]. + /// + /// The child manages the items in the list (deferring to [RenderSliverList]); + /// and identifies which list item, if any, should be consulted + /// for a sticky header. _RenderSliverStickyHeaderListInner? get child => _child; _RenderSliverStickyHeaderListInner? _child; set child(_RenderSliverStickyHeaderListInner? value) { @@ -552,44 +557,74 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper @override void performLayout() { + // First, lay out the child sliver. This does all the normal work of + // [RenderSliverList], then calls [_rebuildHeader] on this sliver + // so that [header] and [_headerEndBound] are up to date. assert(child != null); child!.layout(constraints, parentUsesSize: true); SliverGeometry geometry = child!.geometry!; + if (geometry.scrollOffsetCorrection != null) { + this.geometry = geometry; + return; + } + + // We assume [child]'s geometry is free of certain complications. + // Probably most or all of these *could* be handled if necessary, just at + // the cost of further complicating this code. Fortunately they aren't, + // because [RenderSliverList.performLayout] never has these complications. + assert(geometry.paintOrigin == 0); + assert(geometry.layoutExtent == geometry.paintExtent); + assert(geometry.hitTestExtent == geometry.paintExtent); + assert(geometry.visible == (geometry.paintExtent > 0)); + assert(geometry.maxScrollObstructionExtent == 0); + assert(geometry.crossAxisExtent == null); + final childExtent = geometry.layoutExtent; + if (header != null) { header!.layout(constraints.asBoxConstraints(), parentUsesSize: true); - final headerExtent = header!.size.onAxis(constraints.axis); + final double headerOffset; if (_headerEndBound == null) { - final paintedHeaderSize = calculatePaintOffset(constraints, from: 0, to: headerExtent); - final cacheExtent = calculateCacheOffset(constraints, from: 0, to: headerExtent); - - assert(0 <= paintedHeaderSize && paintedHeaderSize.isFinite); + // The header's item has [StickyHeaderItem.allowOverflow] true. + // Show the header in full, with one edge at the edge of the viewport, + // even if the (visible part of the) item is smaller than the header, + // and even if the whole child sliver is smaller than the header. + final paintedHeaderSize = calculatePaintOffset(constraints, from: 0, to: headerExtent); geometry = SliverGeometry( // TODO review interaction with other slivers scrollExtent: geometry.scrollExtent, - layoutExtent: geometry.layoutExtent, - paintExtent: math.max(geometry.paintExtent, paintedHeaderSize), - cacheExtent: math.max(geometry.cacheExtent, cacheExtent), + layoutExtent: childExtent, + paintExtent: math.max(childExtent, paintedHeaderSize), maxPaintExtent: math.max(geometry.maxPaintExtent, headerExtent), - hitTestExtent: math.max(geometry.hitTestExtent, paintedHeaderSize), hasVisualOverflow: geometry.hasVisualOverflow || headerExtent > constraints.remainingPaintExtent, + + // The cache extent is an extension of layout, not paint; it controls + // where the next sliver should start laying out content. (See + // [SliverConstraints.remainingCacheExtent].) The header isn't meant + // to affect where the next sliver gets laid out, so it shouldn't + // affect the cache extent. + cacheExtent: geometry.cacheExtent, ); headerOffset = _headerAtCoordinateEnd() - ? geometry.layoutExtent - headerExtent + ? childExtent - headerExtent : 0.0; } else { + // The header's item has [StickyHeaderItem.allowOverflow] false. + // Keep the header within the item, pushing the header partly out of + // the viewport if the item's visible part is smaller than the header. + // The limiting edge of the header's item, // in the outer, non-scrolling coordinates. final endBoundAbsolute = axisDirectionIsReversed(constraints.growthAxisDirection) - ? geometry.layoutExtent - (_headerEndBound! - constraints.scrollOffset) + ? childExtent - (_headerEndBound! - constraints.scrollOffset) : _headerEndBound! - constraints.scrollOffset; headerOffset = _headerAtCoordinateEnd() - ? math.max(geometry.layoutExtent - headerExtent, endBoundAbsolute) + ? math.max(childExtent - headerExtent, endBoundAbsolute) : math.min(0.0, endBoundAbsolute - headerExtent); } @@ -706,7 +741,10 @@ class _RenderSliverStickyHeaderListInner extends RenderSliverList { /// /// This means (child start) < (viewport end) <= (child end). RenderBox? _findChildAtEnd() { - final endOffset = constraints.scrollOffset + constraints.viewportMainAxisExtent; + /// The end of the visible area available to this sliver, + /// in this sliver's "scroll offset" coordinates. + final endOffset = constraints.scrollOffset + + constraints.remainingPaintExtent; RenderBox? child; for (child = lastChild; ; child = childBefore(child)) { @@ -736,10 +774,23 @@ class _RenderSliverStickyHeaderListInner extends RenderSliverList { final RenderBox? child; switch (widget.headerPlacement._byGrowth(constraints.growthDirection)) { + case _HeaderGrowthPlacement.growthStart: + if (constraints.remainingPaintExtent < constraints.viewportMainAxisExtent) { + // Part of the viewport is occupied already by other slivers. The way + // a RenderViewport does layout means that the already-occupied part is + // the part that's before this sliver in the growth direction. + // Which means that's the place where the header would go. + child = null; + } else { + child = _findChildAtStart(); + } case _HeaderGrowthPlacement.growthEnd: + // The edge this sliver wants to place a header at is the one where + // this sliver is free to run all the way to the viewport's edge; any + // further slivers in that direction will be laid out after this one. + // So if this sliver placed a child there, it's at the edge of the + // whole viewport and should determine a header. child = _findChildAtEnd(); - case _HeaderGrowthPlacement.growthStart: - child = _findChildAtStart(); } (parent! as _RenderSliverStickyHeaderList)._rebuildHeader(child); diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index d2faf03b2c..a51e722b51 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../api/model/model.dart'; +import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; import '../model/unreads.dart'; import 'icons.dart'; @@ -41,10 +42,22 @@ class _SubscriptionListPageBodyState extends State wit }); } + static final _startsWithEmojiRegex = RegExp(r'^\p{Emoji}', unicode: true); + void _sortSubs(List list) { list.sort((a, b) { if (a.isMuted && !b.isMuted) return 1; if (!a.isMuted && b.isMuted) return -1; + + // A user gave feedback wanting zulip-flutter to match web in putting + // emoji-prefixed channels first; see #1202. + // For matching web's ordering completely, see: + // https://github.com/zulip/zulip-flutter/issues/1165 + final aStartsWithEmoji = _startsWithEmojiRegex.hasMatch(a.name); + final bStartsWithEmoji = _startsWithEmojiRegex.hasMatch(b.name); + if (aStartsWithEmoji && !bStartsWithEmoji) return -1; + if (!aStartsWithEmoji && bStartsWithEmoji) return 1; + // TODO(i18n): add locale-aware sorting return a.name.toLowerCase().compareTo(b.name.toLowerCase()); }); @@ -65,10 +78,8 @@ class _SubscriptionListPageBodyState extends State wit // TODO: Implement collapsible topics - // TODO(i18n): localize strings on page - // Strings here left unlocalized as they likely will not - // exist in the settled design. final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); final List pinned = []; final List unpinned = []; @@ -90,11 +101,11 @@ class _SubscriptionListPageBodyState extends State wit if (pinned.isEmpty && unpinned.isEmpty) const _NoSubscriptionsItem(), if (pinned.isNotEmpty) ...[ - const _SubscriptionListHeader(label: "Pinned"), + _SubscriptionListHeader(label: zulipLocalizations.pinnedSubscriptionsLabel), _SubscriptionList(unreadsModel: unreadsModel, subscriptions: pinned), ], if (unpinned.isNotEmpty) ...[ - const _SubscriptionListHeader(label: "Unpinned"), + _SubscriptionListHeader(label: zulipLocalizations.unpinnedSubscriptionsLabel), _SubscriptionList(unreadsModel: unreadsModel, subscriptions: unpinned), ], @@ -112,11 +123,12 @@ class _NoSubscriptionsItem extends StatelessWidget { @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("No channels found", + child: Text(zulipLocalizations.subscriptionListNoChannels, textAlign: TextAlign.center, style: TextStyle( color: designVariables.subscriptionListHeaderText, diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 0ce1d4cdb4..ec8ad8aecc 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../api/model/model.dart'; +import 'compose_box.dart'; import 'content.dart'; import 'emoji_reaction.dart'; import 'message_list.dart'; @@ -30,8 +31,9 @@ ThemeData zulipThemeData(BuildContext context) { themeExtensions = [ ContentTheme.light(context), designVariables, - EmojiReactionTheme.light(), - MessageListTheme.light(), + EmojiReactionTheme.light, + MessageListTheme.light, + ComposeBoxTheme.light, ]; } case Brightness.dark: { @@ -39,8 +41,9 @@ ThemeData zulipThemeData(BuildContext context) { themeExtensions = [ ContentTheme.dark(context), designVariables, - EmojiReactionTheme.dark(), - MessageListTheme.dark(), + EmojiReactionTheme.dark, + MessageListTheme.dark, + ComposeBoxTheme.dark, ]; } } @@ -175,7 +178,7 @@ class DesignVariables extends ThemeExtension { bgMenuButtonActive: Colors.black.withValues(alpha: 0.2), bgMenuButtonSelected: Colors.black.withValues(alpha: 0.25), bgTopBar: const Color(0xff242424), - borderBar: Colors.black.withValues(alpha: 0.5), + borderBar: const Color(0xffffffff).withValues(alpha: 0.1), borderMenuButtonSelected: Colors.white.withValues(alpha: 0.1), btnLabelAttLowIntDanger: const Color(0xffff8b7c), btnLabelAttMediumIntDanger: const Color(0xffff8b7c), diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 8c7b011db1..7125e9452b 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -3,32 +3,32 @@ PODS: - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS - - Firebase/CoreOnly (11.4.2): - - FirebaseCore (= 11.4.2) - - Firebase/Messaging (11.4.2): + - Firebase/CoreOnly (11.6.0): + - FirebaseCore (~> 11.6.0) + - Firebase/Messaging (11.6.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 11.4.0) - - firebase_core (3.9.0): - - Firebase/CoreOnly (~> 11.4.0) + - FirebaseMessaging (~> 11.6.0) + - firebase_core (3.10.0): + - Firebase/CoreOnly (~> 11.6.0) - FlutterMacOS - - firebase_messaging (15.1.6): - - Firebase/CoreOnly (~> 11.4.0) - - Firebase/Messaging (~> 11.4.0) + - firebase_messaging (15.2.0): + - Firebase/CoreOnly (~> 11.6.0) + - Firebase/Messaging (~> 11.6.0) - firebase_core - FlutterMacOS - - FirebaseCore (11.4.2): - - FirebaseCoreInternal (< 12.0, >= 11.4.2) + - FirebaseCore (11.6.0): + - FirebaseCoreInternal (~> 11.6.0) - GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Logger (~> 8.0) - FirebaseCoreInternal (11.6.0): - "GoogleUtilities/NSData+zlib (~> 8.0)" - - FirebaseInstallations (11.4.0): - - FirebaseCore (~> 11.0) + - FirebaseInstallations (11.6.0): + - FirebaseCore (~> 11.6.0) - GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0) - PromisesObjC (~> 2.4) - - FirebaseMessaging (11.4.0): - - FirebaseCore (~> 11.0) + - FirebaseMessaging (11.6.0): + - FirebaseCore (~> 11.6.0) - FirebaseInstallations (~> 11.0) - GoogleDataTransport (~> 10.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0) @@ -158,15 +158,15 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos SPEC CHECKSUMS: - device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 + device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215 file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d - Firebase: 7fd5466678d964be78fbf536d8a3385da19c4828 - firebase_core: 1dfe1f4d02ad78be0277e320aa3d8384cf46231f - firebase_messaging: 61f678060b69a7ae1013e3a939ec8e1c56ef6fcf - FirebaseCore: 6b32c57269bd999aab34354c3923d92a6e5f3f84 + Firebase: 374a441a91ead896215703a674d58cdb3e9d772b + firebase_core: 6d9bb8b0ea817e8fe0d928177d42275b45fdba6f + firebase_messaging: ae8e88b586e4d50abc7cac5bacf74d21967fd226 + FirebaseCore: 48b0dd707581cf9c1a1220da68223fb0a562afaa FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2 - FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414 - FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2 + FirebaseInstallations: efc0946fc756e4d22d8113f7c761948120322e8c + FirebaseMessaging: e1aca1fcc23e8b9eddb0e33f375ff90944623021 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart index 3976899279..708ae4efb5 100644 --- a/pigeon/notifications.dart +++ b/pigeon/notifications.dart @@ -129,9 +129,7 @@ class MessagingStyle { final Person user; final String? conversationTitle; - // TODO(pigeon): Make list item non-nullable, once pigeon supports non-nullable type arguments. - // https://github.com/flutter/flutter/issues/97848 - final List messages; + final List messages; final bool isGroupConversation; } @@ -142,7 +140,7 @@ class Notification { Notification({required this.group, required this.extras}); final String group; - final Map extras; + final Map extras; // Various other properties too; add them if needed. } @@ -267,7 +265,7 @@ abstract class AndroidNotificationHostApi { PendingIntent? contentIntent, String? contentText, String? contentTitle, - Map? extras, + Map? extras, String? groupKey, InboxStyle? inboxStyle, bool? isGroupSummary, diff --git a/pubspec.lock b/pubspec.lock index e98a6a5339..4ee3317f8d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: daa1d780fdecf8af925680c06c86563cdd445deea995d5c9176f1302a2b10bbe + sha256: "27899c95f9e7ec06c8310e6e0eac967707714b9f1450c4a58fa00ca011a4a8ae" url: "https://pub.dev" source: hosted - version: "1.3.48" + version: "1.3.49" _macros: dependency: transitive description: dart @@ -30,14 +30,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.11.0" - analyzer_plugin: - dependency: transitive - description: - name: analyzer_plugin - sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" - url: "https://pub.dev" - source: hosted - version: "0.11.3" app_settings: dependency: "direct main" description: @@ -138,10 +130,10 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" charcode: dependency: transitive description: @@ -194,10 +186,10 @@ packages: dependency: "direct main" description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.19.1" color_models: dependency: "direct overridden" description: @@ -267,10 +259,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 + sha256: "4fa68e53e26ab17b70ca39f072c285562cfc1589df5bb1e9295db90f6645f431" url: "https://pub.dev" source: hosted - version: "10.1.2" + version: "11.2.0" device_info_plus_platform_interface: dependency: transitive description: @@ -283,18 +275,18 @@ packages: dependency: "direct main" description: name: drift - sha256: c2d073d35ad441730812f4ea05b5dd031fb81c5f9786a4f5fb77ecd6307b6f74 + sha256: af3941e4d544727b2eb80590eb64e9cb8d77cd68c7690265502ea6a2427aa621 url: "https://pub.dev" source: hosted - version: "2.22.1" + version: "2.23.1" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: f4ab5d6976b1e31551ceb82ff597a505bda7818ff4f7be08a1da9d55eb6e730c + sha256: fa98fdbb7303a1b5b2dc110cb516eda2253a5d291680f8cbc72b1af24099f7f9 url: "https://pub.dev" source: hosted - version: "2.22.1" + version: "2.23.1" fake_async: dependency: "direct dev" description: @@ -323,10 +315,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: c2376a6aae82358a9f9ccdd7d1f4006d08faa39a2767cce01031d9f593a8bd3b + sha256: c904b4ab56d53385563c7c39d8e9fa9af086f91495dfc48717ad84a42c3cf204 url: "https://pub.dev" source: hosted - version: "8.1.6" + version: "8.1.7" file_selector_linux: dependency: transitive description: @@ -363,10 +355,10 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: "15d761b95dfa2906dfcc31b7fc6fe293188533d1a3ffe78389ba9e69bd7fdbde" + sha256: "0307c1fde82e2b8b97e0be2dab93612aff9a72f31ebe9bfac66ed8b37ef7c568" url: "https://pub.dev" source: hosted - version: "3.9.0" + version: "3.10.0" firebase_core_platform_interface: dependency: transitive description: @@ -387,26 +379,26 @@ packages: dependency: "direct main" description: name: firebase_messaging - sha256: "151a3ee68736abf293aab66d1317ade53c88abe1db09c75a0460aebf7767bbdf" + sha256: "48a8a59197c1c5174060ba9aa1e0036e9b5a0d28a0cc22d19c1fcabc67fafe3c" url: "https://pub.dev" source: hosted - version: "15.1.6" + version: "15.2.0" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: f331ee51e40c243f90cc7bc059222dfec4e5df53125b08d31fb28961b00d2a9d + sha256: "9770a8e91f54296829dcaa61ce9b7c2f9ae9abbf99976dd3103a60470d5264dd" url: "https://pub.dev" source: hosted - version: "4.5.49" + version: "4.6.0" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: efaf3fdc54cd77e0eedb8e75f7f01c808828c64d052ddbf94d3009974e47d30f + sha256: "329ca4ef45ec616abe6f1d5e58feed0934a50840a65aa327052354ad3c64ed77" url: "https://pub.dev" source: hosted - version: "3.9.5" + version: "3.10.0" fixnum: dependency: transitive description: @@ -446,10 +438,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -530,10 +522,10 @@ packages: dependency: "direct main" description: name: http_parser - sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.2" image_picker: dependency: "direct main" description: @@ -546,10 +538,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: fa8141602fde3f7e2f81dbf043613eb44dfa325fa0bcf93c0f142c9f7a2c193e + sha256: b62d34a506e12bb965e824b6db4fbf709ee4589cf5d3e99b45ab2287b008ee0c url: "https://pub.dev" source: hosted - version: "0.8.12+18" + version: "0.8.12+20" image_picker_for_web: dependency: transitive description: @@ -562,10 +554,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "4f0568120c6fcc0aaa04511cb9f9f4d29fc3d0139884b1d06be88dcec7641d6b" + sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" url: "https://pub.dev" source: hosted - version: "0.8.12+1" + version: "0.8.12+2" image_picker_linux: dependency: transitive description: @@ -586,10 +578,10 @@ packages: dependency: transitive description: name: image_picker_platform_interface - sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" + sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" url: "https://pub.dev" source: hosted - version: "2.10.0" + version: "2.10.1" image_picker_windows: dependency: transitive description: @@ -647,10 +639,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: c2fcb3920cf2b6ae6845954186420fca40bc0a8abcc84903b7801f17d7050d7c + sha256: b0a98230538fe5d0b60a22fb6bf1b6cb03471b53e3324ff6069c591679dd59c9 url: "https://pub.dev" source: hosted - version: "6.9.0" + version: "6.9.3" leak_tracker: dependency: transitive description: @@ -687,10 +679,10 @@ packages: dependency: transitive description: name: lints - sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.1.1" logging: dependency: transitive description: @@ -711,10 +703,10 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -727,18 +719,18 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" mime: dependency: "direct main" description: name: mime - sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.6" + version: "2.0.0" node_preamble: dependency: transitive description: @@ -839,18 +831,18 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.1.0" pigeon: dependency: "direct dev" description: name: pigeon - sha256: ba3727eabe6c23876605d05062de35ab861227ff87a77379898ecd0b8951ef49 + sha256: "694073e4677d631a5aa2c633c5944140c0e7f361cbf3e55b1709dd11688713bb" url: "https://pub.dev" source: hosted - version: "20.0.2" + version: "22.7.2" platform: dependency: transitive description: @@ -903,10 +895,10 @@ packages: dependency: transitive description: name: pubspec_parse - sha256: "81876843eb50dc2e1e5b151792c9a985c5ed2536914115ed04e9c8528f6647b0" + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" recase: dependency: transitive description: @@ -972,10 +964,10 @@ packages: dependency: transitive description: name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "2.0.0" source_helper: dependency: transitive description: @@ -1020,10 +1012,10 @@ packages: dependency: "direct main" description: name: sqlite3 - sha256: cb7f4e9dc1b52b1fa350f7b3d41c662e75fc3d399555fa4e5efcf267e9a4fbb5 + sha256: c284434c408d207863800341298cadfde23abe074a0f01b19c9d8cce4edb8eaa url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" sqlite3_flutter_libs: dependency: "direct main" description: @@ -1044,18 +1036,18 @@ packages: dependency: "direct dev" description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: @@ -1068,10 +1060,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "0bd04f5bb74fcd6ff0606a888a30e917af9bd52820b178eaa464beb11dca84b6" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" sync_http: dependency: transitive description: @@ -1084,10 +1076,10 @@ packages: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test: dependency: "direct dev" description: @@ -1180,18 +1172,18 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.4.0" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" uuid: dependency: transitive description: @@ -1220,18 +1212,18 @@ packages: dependency: transitive description: name: video_player_android - sha256: "391e092ba4abe2f93b3e625bd6b6a6ec7d7414279462c1c0ee42b5ab8d0a0898" + sha256: "7018dbcb395e2bca0b9a898e73989e67c0c4a5db269528e1b036ca38bcca0d0b" url: "https://pub.dev" source: hosted - version: "2.7.16" + version: "2.7.17" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "33224c19775fd244be2d6e3dbd8e1826ab162877bd61123bf71890772119a2b7" + sha256: "61c54fb08fee52861d819a9b3b8e30b92456dad43a875434c677c892eb7772de" url: "https://pub.dev" source: hosted - version: "2.6.5" + version: "2.6.6" video_player_platform_interface: dependency: "direct dev" description: @@ -1324,10 +1316,10 @@ packages: dependency: transitive description: name: win32 - sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69" + sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.10.0" win32_registry: dependency: transitive description: @@ -1368,5 +1360,5 @@ packages: source: path version: "0.0.1" sdks: - dart: ">=3.7.0-267.0.dev <4.0.0" - flutter: ">=3.28.0-2.0.pre.38575" + dart: ">=3.8.0-24.0.dev <4.0.0" + flutter: ">=3.29.0-1.0.pre.105" diff --git a/pubspec.yaml b/pubspec.yaml index c81b52a367..cc1d93cca8 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.24+24 +version: 0.0.26+26 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.7.0-267.0.dev <4.0.0' - flutter: '>=3.28.0-2.0.pre.38575' # 65ff060283e19423e9538c18c24e44495b70aeff + sdk: '>=3.8.0-24.0.dev <4.0.0' + flutter: '>=3.29.0-1.0.pre.105' # c1ffaa9d9deb3e0853176271922e0b1c1356d21f # To update dependencies, see instructions in README.md. dependencies: @@ -39,7 +39,7 @@ dependencies: collection: ^1.17.2 convert: ^3.1.1 crypto: ^3.0.3 - device_info_plus: ^10.0.1 + device_info_plus: ^11.2.0 drift: ^2.5.0 file_picker: ^8.0.0+1 firebase_core: ^3.3.0 @@ -50,7 +50,7 @@ dependencies: http_parser: ^4.0.2 image_picker: ^1.0.0 json_annotation: ^4.9.0 - mime: ^1.0.5 + mime: ^2.0.0 package_info_plus: ^8.0.0 path: ^1.8.3 path_provider: ^2.0.13 @@ -97,11 +97,11 @@ dev_dependencies: drift_dev: ^2.5.2 fake_async: ^1.3.1 flutter_checks: ^0.1.1 - flutter_lints: ^4.0.0 + flutter_lints: ^5.0.0 ini: ^2.1.0 json_serializable: ^6.5.4 legacy_checks: ^0.1.0 - pigeon: ^20.0.1 + pigeon: ^22.7.2 plugin_platform_interface: ^2.1.8 stack_trace: ^1.11.1 test: ^1.23.1 diff --git a/test/api/model/events_checks.dart b/test/api/model/events_checks.dart index f95c6b4362..c1fa0a117f 100644 --- a/test/api/model/events_checks.dart +++ b/test/api/model/events_checks.dart @@ -51,8 +51,8 @@ extension UpdateMessageEventChecks on Subject { Subject get origStreamId => has((e) => e.origStreamId, 'origStreamId'); Subject get newStreamId => has((e) => e.newStreamId, 'newStreamId'); Subject get propagateMode => has((e) => e.propagateMode, 'propagateMode'); - Subject get origTopic => has((e) => e.origTopic, 'origTopic'); - Subject get newTopic => has((e) => e.newTopic, 'newTopic'); + Subject get origTopic => has((e) => e.origTopic, 'origTopic'); + Subject get newTopic => has((e) => e.newTopic, 'newTopic'); Subject get origContent => has((e) => e.origContent, 'origContent'); Subject get origRenderedContent => has((e) => e.origRenderedContent, 'origRenderedContent'); Subject get content => has((e) => e.content, 'content'); @@ -77,7 +77,7 @@ extension TypingEventChecks on Subject { Subject get senderId => has((e) => e.senderId, 'senderId'); Subject?> get recipientIds => has((e) => e.recipientIds, 'recipientIds'); Subject get streamId => has((e) => e.streamId, 'streamId'); - Subject get topic => has((e) => e.topic, 'topic'); + Subject get topic => has((e) => e.topic, 'topic'); } extension HeartbeatEventChecks on Subject { diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index 0f1f52f843..51b36350cc 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -116,8 +116,8 @@ void main() { 'orig_subject': 'foo', 'subject': 'bar', }) as UpdateMessageEvent) - ..origTopic.equals('foo') - ..newTopic.equals('bar'); + ..origTopic.equals(const TopicName('foo')) + ..newTopic.equals(const TopicName('bar')); }); }); diff --git a/test/api/model/initial_snapshot_test.dart b/test/api/model/initial_snapshot_test.dart index 7b441de779..3228602fff 100644 --- a/test/api/model/initial_snapshot_test.dart +++ b/test/api/model/initial_snapshot_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/api/model/model.dart'; import '../../stdlib_checks.dart'; @@ -20,7 +21,7 @@ void main() { check(snapshot.channels).single.jsonEquals( UnreadChannelSnapshot( - topic: 'topic name', streamId: 1, + topic: const TopicName('topic name'), streamId: 1, unreadMessageIds: [1, 2])); }); diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 6a3eee8c8c..8b39b1ad57 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -38,7 +38,6 @@ extension MessageChecks on Subject { Subject get senderFullName => has((e) => e.senderFullName, 'senderFullName'); Subject get senderId => has((e) => e.senderId, 'senderId'); Subject get senderRealmStr => has((e) => e.senderRealmStr, 'senderRealmStr'); - Subject get topic => has((e) => e.topic, 'topic'); Subject get poll => has((e) => e.poll, 'poll'); Subject get timestamp => has((e) => e.timestamp, 'timestamp'); Subject get type => has((e) => e.type, 'type'); @@ -47,8 +46,14 @@ extension MessageChecks on Subject { Subject get matchTopic => has((e) => e.matchTopic, 'matchTopic'); } +extension TopicNameChecks on Subject { + Subject get apiName => has((x) => x.apiName, 'apiName'); + Subject get displayName => has((x) => x.displayName, 'displayName'); +} + extension StreamMessageChecks on Subject { Subject get displayRecipient => has((e) => e.displayRecipient, 'displayRecipient'); + Subject get topic => has((e) => e.topic, 'topic'); } extension ReactionsChecks on Subject { diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index b3636b5ed6..95737c173f 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -89,7 +89,8 @@ void main() { check(baseStreamJson()).not((it) => it.containsKey('topic')); check(Message.fromJson(baseStreamJson() ..['subject'] = 'hello' - )).topic.equals('hello'); + )).isA() + .topic.equals(const TopicName('hello')); }); test('match_subject -> matchTopic', () { @@ -125,6 +126,43 @@ void main() { // MessageEditState group. }); + group('TopicName', () { + test('unresolve', () { + void doCheck(TopicName input, TopicName expected) { + final output = input.unresolve(); + check(output).apiName.equals(expected.apiName); + } + + doCheck(eg.t('some topic'), eg.t('some topic')); + doCheck(eg.t('Some Topic'), eg.t('Some Topic')); + doCheck(eg.t('✔ some topic'), eg.t('some topic')); + doCheck(eg.t('✔ Some Topic'), eg.t('Some Topic')); + + doCheck(eg.t('Some ✔ Topic'), eg.t('Some ✔ Topic')); + doCheck(eg.t('✔ Some ✔ Topic'), eg.t('Some ✔ Topic')); + + doCheck(eg.t('✔ ✔✔✔ some topic'), eg.t('some topic')); + doCheck(eg.t('✔ ✔ ✔✔some topic'), eg.t('some topic')); + }); + + test('isSameAs', () { + void doCheck(TopicName topicA, TopicName topicB, bool expected) { + check(topicA.isSameAs(topicB)).equals(expected); + } + + doCheck(eg.t('some topic'), eg.t('some topic'), true); + doCheck(eg.t('SOME TOPIC'), eg.t('SOME TOPIC'), true); + doCheck(eg.t('Some Topic'), eg.t('sOME tOPIC'), true); + doCheck(eg.t('✔ a'), eg.t('✔ a'), true); + + doCheck(eg.t('✔ some topic'), eg.t('some topic'), false); + doCheck(eg.t('SOME TOPIC'), eg.t('✔ SOME TOPIC'), false); + doCheck(eg.t('✔ Some Topic'), eg.t('sOME tOPIC'), false); + + doCheck(eg.t('✔ a'), eg.t('✔ b'), false); + }); + }); + group('DmMessage', () { final Map baseJson = Map.unmodifiable(deepToJson( eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]), @@ -287,10 +325,35 @@ void main() { ]); }); + // Technically the topic *was* unresolved, so MessageEditState.none + // would be valid and preferable -- if it didn't need more intense + // computation than we're comfortable with in a hot codepath, i.e., + // a regex test instead of a simple `startsWith` / `substring` check. + // See comment on the implementation, and discussion: + // https://github.com/zulip/zulip-flutter/pull/1242#discussion_r1917592157 test('Unresolving topic with a weird prefix -> moved', () { checkEditState(MessageEditState.moved, [{'prev_topic': '✔ ✔old_topic', 'topic': 'old_topic'}]); }); + + // Similar reasoning as in the previous test. + // Also, Zulip doesn't produce topics with a weird resolved-topic prefix, + // so this case can only be produced by unusual input in an + // edit/move-topic UI. A "moved" marker seems like a fine response + // in that circumstance. + test('Resolving topic with a weird prefix -> moved', () { + checkEditState(MessageEditState.moved, + [{'prev_topic': 'old_topic', 'topic': '✔ ✔old_topic'}]); + }); + + // Similar reasoning as the previous test, including that this case had to + // involve unusual input in an edit/move-topic UI. + // Here the computation burden would have come from calling + // [TopicName.canonicalize]. + test('Topic was resolved but with changed case -> moved', () { + checkEditState(MessageEditState.moved, + [{'prev_topic': 'old ToPiC', 'topic': '✔ OLD tOpIc'}]); + }); }); }); } diff --git a/test/api/notifications_test.dart b/test/api/notifications_test.dart index fd4465f33a..53240d7b09 100644 --- a/test/api/notifications_test.dart +++ b/test/api/notifications_test.dart @@ -1,5 +1,6 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/notifications.dart'; import '../stdlib_checks.dart'; @@ -81,7 +82,7 @@ void main() { ..recipient.isA().which((it) => it ..streamId.equals(42) ..streamName.equals(streamJson['stream']!) - ..topic.equals(streamJson['topic']!)) + ..topic.jsonEquals(streamJson['topic']!)) ..content.equals(streamJson['content']!) ..time.equals(1546300800); @@ -279,7 +280,7 @@ extension MessageFcmMessageChecks on Subject { extension FcmMessageChannelRecipientChecks on Subject { Subject get streamId => has((x) => x.streamId, 'streamId'); Subject get streamName => has((x) => x.streamName, 'streamName'); - Subject get topic => has((x) => x.topic, 'topic'); + Subject get topic => has((x) => x.topic, 'topic'); } extension FcmMessageDmRecipientChecks on Subject { diff --git a/test/api/route/channels_test.dart b/test/api/route/channels_test.dart index d86c9447fe..011dc508c5 100644 --- a/test/api/route/channels_test.dart +++ b/test/api/route/channels_test.dart @@ -12,7 +12,7 @@ void main() { return FakeApiConnection.with_((connection) async { connection.prepare(json: {}); await updateUserTopic(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.followed); check(connection.takeRequests()).single.isA() ..method.equals('POST') @@ -28,7 +28,7 @@ void main() { test('updateUserTopic only accepts valid visibility policy', () { return FakeApiConnection.with_((connection) async { check(() => updateUserTopic(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.unknown), ).throws(); }); @@ -38,7 +38,7 @@ void main() { return FakeApiConnection.with_((connection) async { connection.prepare(json: {}); await updateUserTopicCompat(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.followed); check(connection.takeRequests()).single.isA() ..method.equals('POST') @@ -55,7 +55,7 @@ void main() { test('updateUserTopic throws AssertionError when FL < 170', () { return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async { check(() => updateUserTopic(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.muted), ).throws(); }); @@ -64,7 +64,7 @@ void main() { test('updateUserTopicCompat throws UnsupportedError on unsupported policy', () { return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async { check(() => updateUserTopicCompat(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.followed), ).throws(); }); @@ -74,7 +74,7 @@ void main() { return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async { connection.prepare(json: {}); await updateUserTopicCompat(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.none); check(connection.takeRequests()).single.isA() ..method.equals('PATCH') @@ -91,7 +91,7 @@ void main() { return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async { connection.prepare(json: {}); await updateUserTopicCompat(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.muted); check(connection.takeRequests()).single.isA() ..method.equals('PATCH') diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index c634c87ff7..4da4334bae 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -184,7 +184,7 @@ void main() { checkNarrow(const ChannelNarrow(12).apiEncode(), jsonEncode([ {'operator': 'stream', 'operand': 12}, ])); - checkNarrow(const TopicNarrow(12, 'stuff').apiEncode(), jsonEncode([ + checkNarrow(eg.topicNarrow(12, 'stuff').apiEncode(), jsonEncode([ {'operator': 'stream', 'operand': 12}, {'operator': 'topic', 'operand': 'stuff'}, ])); @@ -328,7 +328,7 @@ void main() { test('smoke', () { return FakeApiConnection.with_((connection) async { await checkSendMessage(connection, - destination: const StreamDestination(streamId, topic), content: content, + destination: StreamDestination(streamId, eg.t(topic)), content: content, queueId: 'abc:123', localId: '456', readBySender: true, @@ -347,7 +347,7 @@ void main() { test('to stream', () { return FakeApiConnection.with_((connection) async { await checkSendMessage(connection, - destination: const StreamDestination(streamId, topic), content: content, + destination: StreamDestination(streamId, eg.t(topic)), content: content, readBySender: true, expectedBodyFields: { 'type': 'stream', @@ -391,7 +391,7 @@ void main() { test('when readBySender is null, sends a User-Agent we know the server will recognize', () { return FakeApiConnection.with_((connection) async { await checkSendMessage(connection, - destination: const StreamDestination(streamId, topic), content: content, + destination: StreamDestination(streamId, eg.t(topic)), content: content, readBySender: null, expectedBodyFields: { 'type': 'stream', @@ -406,7 +406,7 @@ void main() { test('legacy: when server does not support readBySender, sends a User-Agent the server will recognize', () { return FakeApiConnection.with_(zulipFeatureLevel: 235, (connection) async { await checkSendMessage(connection, - destination: const StreamDestination(streamId, topic), content: content, + destination: StreamDestination(streamId, eg.t(topic)), content: content, readBySender: true, expectedBodyFields: { 'type': 'stream', @@ -420,6 +420,67 @@ void main() { }); }); + group('updateMessage', () { + Future checkUpdateMessage( + FakeApiConnection connection, { + required int messageId, + TopicName? topic, + PropagateMode? propagateMode, + bool? sendNotificationToOldThread, + bool? sendNotificationToNewThread, + String? content, + int? streamId, + required Map expected, + }) async { + final result = await updateMessage(connection, + messageId: messageId, + topic: topic, + propagateMode: propagateMode, + sendNotificationToOldThread: sendNotificationToOldThread, + sendNotificationToNewThread: sendNotificationToNewThread, + content: content, + streamId: streamId, + ); + check(connection.lastRequest).isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/messages/$messageId') + ..bodyFields.deepEquals(expected); + return result; + } + + test('topic/content change', () { + // A separate test exercises `streamId`; + // the API doesn't allow changing channel and content at the same time. + return FakeApiConnection.with_((connection) async { + connection.prepare(json: UpdateMessageResult().toJson()); + await checkUpdateMessage(connection, + messageId: eg.streamMessage().id, + topic: eg.t('new topic'), + propagateMode: PropagateMode.changeAll, + sendNotificationToOldThread: true, + sendNotificationToNewThread: true, + content: 'asdf', + expected: { + 'topic': 'new topic', + 'propagate_mode': 'change_all', + 'send_notification_to_old_thread': 'true', + 'send_notification_to_new_thread': 'true', + 'content': 'asdf', + }); + }); + }); + + test('channel change', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: UpdateMessageResult().toJson()); + await checkUpdateMessage(connection, + messageId: eg.streamMessage().id, + streamId: 1, + expected: {'stream_id': '1'}); + }); + }); + }); + group('uploadFile', () { Future checkUploadFile(FakeApiConnection connection, { required List> content, @@ -743,7 +804,7 @@ void main() { }) async { connection.prepare(json: {}); await markTopicAsRead(connection, - streamId: streamId, topicName: topicName); + streamId: streamId, topicName: eg.t(topicName)); check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/mark_topic_as_read') diff --git a/test/api/route/typing_test.dart b/test/api/route/typing_test.dart index 0d933e6f54..551f1a5a4e 100644 --- a/test/api/route/typing_test.dart +++ b/test/api/route/typing_test.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; 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/api/route/messages.dart'; import 'package:zulip/api/route/typing.dart'; @@ -31,7 +32,7 @@ void main() { Future checkSetTypingStatusForTopic(TypingOp op, String expectedOp) { return FakeApiConnection.with_((connection) { return checkSetTypingStatus(connection, op, - destination: const StreamDestination(streamId, topic), + destination: const StreamDestination(streamId, TopicName(topic)), expectedBodyFields: { 'op': expectedOp, 'type': 'channel', @@ -64,7 +65,7 @@ void main() { test('legacy: use "stream" instead of "channel"', () { return FakeApiConnection.with_(zulipFeatureLevel: 247, (connection) { return checkSetTypingStatus(connection, TypingOp.start, - destination: const StreamDestination(streamId, topic), + destination: const StreamDestination(streamId, TopicName(topic)), expectedBodyFields: { 'op': 'start', 'type': 'stream', @@ -77,7 +78,7 @@ void main() { test('legacy: use to=[streamId] instead of stream_id=streamId', () { return FakeApiConnection.with_(zulipFeatureLevel: 214, (connection) { return checkSetTypingStatus(connection, TypingOp.start, - destination: const StreamDestination(streamId, topic), + destination: const StreamDestination(streamId, TopicName(topic)), expectedBodyFields: { 'op': 'start', 'type': 'stream', diff --git a/test/example_data.dart b/test/example_data.dart index d071307ec4..6b84bf185c 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -241,7 +241,8 @@ const _stream = stream; GetStreamTopicsEntry getStreamTopicsEntry({int? maxId, String? name}) { maxId ??= 123; - return GetStreamTopicsEntry(maxId: maxId, name: name ?? 'Test Topic #$maxId'); + return GetStreamTopicsEntry(maxId: maxId, + name: TopicName(name ?? 'Test Topic #$maxId')); } /// Construct an example subscription from a stream. @@ -283,11 +284,20 @@ Subscription subscription( ); } +/// The [TopicName] constructor, but shorter. +/// +/// Useful in test code that mentions a lot of topics in a compact format. +TopicName t(String apiName) => TopicName(apiName); + +TopicNarrow topicNarrow(int channelId, String topicName) { + return TopicNarrow(channelId, TopicName(topicName)); +} + UserTopicItem userTopicItem( ZulipStream stream, String topic, UserTopicVisibilityPolicy policy) { return UserTopicItem( streamId: stream.streamId, - topicName: topic, + topicName: TopicName(topic), lastUpdated: 1234567890, visibilityPolicy: policy, ); @@ -519,6 +529,18 @@ Submessage submessage({ // Aggregate data structures. // +UnreadChannelSnapshot unreadChannelMsgs({ + required String topic, + required int streamId, + required List unreadMessageIds, +}) { + return UnreadChannelSnapshot( + topic: TopicName(topic), + streamId: streamId, + unreadMessageIds: unreadMessageIds, + ); +} + UnreadMessagesSnapshot unreadMsgs({ int? count, List? dms, @@ -547,7 +569,7 @@ UserTopicEvent userTopicEvent( return UserTopicEvent( id: 1, streamId: streamId, - topicName: topic, + topicName: TopicName(topic), lastUpdated: 1234567890, visibilityPolicy: visibilityPolicy, ); @@ -605,8 +627,8 @@ UpdateMessageEvent _updateMessageMoveEvent( List messageIds, { required int origStreamId, int? newStreamId, - required String origTopic, - String? newTopic, + required TopicName origTopic, + TopicName? newTopic, String? origContent, String? newContent, required List flags, @@ -642,12 +664,15 @@ UpdateMessageEvent _updateMessageMoveEvent( UpdateMessageEvent updateMessageEventMoveFrom({ required List origMessages, int? newStreamId, - String? newTopic, + TopicName? newTopic, + String? newTopicStr, String? newContent, PropagateMode propagateMode = PropagateMode.changeOne, }) { _checkPositive(newStreamId, 'stream ID'); assert(origMessages.isNotEmpty); + assert(newTopic == null || newTopicStr == null); + newTopic ??= newTopicStr == null ? null : TopicName(newTopicStr); final origMessage = origMessages.first; // Only present on content change. final origContent = (newContent != null) ? origMessage.content : null; @@ -667,12 +692,15 @@ UpdateMessageEvent updateMessageEventMoveFrom({ UpdateMessageEvent updateMessageEventMoveTo({ required List newMessages, int? origStreamId, - String? origTopic, + TopicName? origTopic, + String? origTopicStr, String? origContent, PropagateMode propagateMode = PropagateMode.changeOne, }) { _checkPositive(origStreamId, 'stream ID'); assert(newMessages.isNotEmpty); + assert(origTopic == null || origTopicStr == null); + origTopic ??= origTopicStr == null ? null : TopicName(origTopicStr); final newMessage = newMessages.first; // Only present on topic move. final newTopic = (origTopic != null) ? newMessage.topic : null; @@ -828,6 +856,8 @@ InitialSnapshot initialSnapshot({ List? streams, UserSettings? userSettings, List? userTopics, + RealmWildcardMentionPolicy? realmWildcardMentionPolicy, + bool? realmMandatoryTopics, int? realmWaitingPeriodThreshold, Map? realmDefaultExternalAccounts, int? maxFileUploadSizeMib, @@ -862,6 +892,8 @@ InitialSnapshot initialSnapshot({ emojiset: Emojiset.google, ), userTopics: userTopics, + realmWildcardMentionPolicy: realmWildcardMentionPolicy ?? RealmWildcardMentionPolicy.everyone, + realmMandatoryTopics: realmMandatoryTopics ?? true, realmWaitingPeriodThreshold: realmWaitingPeriodThreshold ?? 0, realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {}, maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25, diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index 4e4ae7d986..505f5189f2 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -66,6 +66,12 @@ extension TextChecks on Subject { Subject get style => has((t) => t.style, 'style'); } +extension TextEditingValueChecks on Subject { + Subject get text => has((x) => x.text, 'text'); + Subject get selection => has((x) => x.selection, 'selection'); + Subject get composing => has((x) => x.composing, 'composing'); +} + extension TextEditingControllerChecks on Subject { Subject get text => has((t) => t.text, 'text'); } diff --git a/test/model/autocomplete_checks.dart b/test/model/autocomplete_checks.dart index cb94735894..ec8acbe500 100644 --- a/test/model/autocomplete_checks.dart +++ b/test/model/autocomplete_checks.dart @@ -1,4 +1,5 @@ import 'package:checks/checks.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/autocomplete.dart'; import 'package:zulip/widgets/compose_box.dart'; @@ -20,5 +21,5 @@ extension UserMentionAutocompleteResultChecks on Subject { - Subject get topic => has((r) => r.topic, 'topic'); + Subject get topic => has((r) => r.topic, 'topic'); } diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index 9d6667ca3f..da05030493 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -7,8 +7,11 @@ import 'package:test/scaffolding.dart'; 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/generated/l10n/zulip_localizations.dart'; import 'package:zulip/model/autocomplete.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/widgets/compose_box.dart'; @@ -21,6 +24,11 @@ import 'autocomplete_checks.dart'; typedef MarkedTextParse = ({int? expectedSyntaxStart, TextEditingValue value}); +final zulipLocalizations = GlobalLocalizations.zulipLocalizations; +final zulipLocalizationsArabic = + lookupZulipLocalizations(ZulipLocalizations.supportedLocales + .firstWhere((locale) => locale.languageCode == 'ar')); + void main() { ({int? expectedSyntaxStart, TextEditingValue value}) parseMarkedText(String markedText) { final TextSelection selection; @@ -258,8 +266,8 @@ void main() { final store = eg.store(); await store.addUsers([eg.selfUser, eg.otherUser, eg.thirdUser]); - final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('Third')); + final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations, + narrow: narrow, query: MentionAutocompleteQuery('Third')); bool done = false; view.addListener(() { done = true; }); await Future(() {}); @@ -288,8 +296,8 @@ void main() { check(searchDone).isFalse(); }); - final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('Third')); + final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations, + narrow: narrow, query: MentionAutocompleteQuery('Third')); view.addListener(() { searchDone = true; }); @@ -312,8 +320,8 @@ void main() { } bool done = false; - final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('User 2222')); + final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations, + narrow: narrow, query: MentionAutocompleteQuery('User 2222')); view.addListener(() { done = true; }); await Future(() {}); @@ -335,8 +343,8 @@ void main() { } bool done = false; - final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('User 1111')); + final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations, + narrow: narrow, query: MentionAutocompleteQuery('User 1111')); view.addListener(() { done = true; }); await Future(() {}); @@ -370,8 +378,8 @@ void main() { } bool done = false; - final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('User 110')); + final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations, + narrow: narrow, query: MentionAutocompleteQuery('User 110')); view.addListener(() { done = true; }); await Future(() {}); @@ -481,10 +489,11 @@ void main() { } int compareAB({required String? topic}) { + final realTopic = topic == null ? null : TopicName(topic); final resultAB = MentionAutocompleteView.compareByRecency(userA, userB, - streamId: stream.streamId, topic: topic, store: store); + streamId: stream.streamId, topic: realTopic, store: store); final resultBA = MentionAutocompleteView.compareByRecency(userB, userA, - streamId: stream.streamId, topic: topic, store: store); + streamId: stream.streamId, topic: realTopic, store: store); switch (resultAB) { case <0: check(resultBA).isGreaterThan(0); case >0: check(resultBA).isLessThan(0); @@ -624,8 +633,8 @@ void main() { group('ranking across signals', () { void checkPrecedes(Narrow narrow, User userA, Iterable usersB) { - final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('')); + final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations, + narrow: narrow, query: MentionAutocompleteQuery('')); for (final userB in usersB) { check(view.debugCompareUsers(userA, userB)).isLessThan(0); check(view.debugCompareUsers(userB, userA)).isGreaterThan(0); @@ -633,8 +642,8 @@ void main() { } void checkRankEqual(Narrow narrow, List users) { - final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('')); + final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations, + narrow: narrow, query: MentionAutocompleteQuery('')); for (int i = 0; i < users.length; i++) { for (int j = i + 1; j < users.length; j++) { check(view.debugCompareUsers(users[i], users[j])).equals(0); @@ -659,7 +668,7 @@ void main() { eg.user(fullName: 'b', isBot: true), ]; final stream = eg.stream(); - final narrow = TopicNarrow(stream.streamId, 'this'); + final narrow = eg.topicNarrow(stream.streamId, 'this'); await prepare(users: users, messages: [ eg.streamMessage(sender: users[1], stream: stream, topic: 'this'), eg.streamMessage(sender: users[0], stream: stream, topic: 'this'), @@ -751,46 +760,51 @@ void main() { test('CombinedFeedNarrow gives error', () async { await prepare(users: [eg.user(), eg.user()], messages: []); const narrow = CombinedFeedNarrow(); - check(() => MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery(''))) + check(() => MentionAutocompleteView.init(store: store, localizations: zulipLocalizations, + narrow: narrow, query: MentionAutocompleteQuery(''))) .throws(); }); test('MentionsNarrow gives error', () async { await prepare(users: [eg.user(), eg.user()], messages: []); const narrow = MentionsNarrow(); - check(() => MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery(''))) + check(() => MentionAutocompleteView.init(store: store, localizations: zulipLocalizations, + narrow: narrow, query: MentionAutocompleteQuery(''))) .throws(); }); test('StarredMessagesNarrow gives error', () async { await prepare(users: [eg.user(), eg.user()], messages: []); const narrow = StarredMessagesNarrow(); - check(() => MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery(''))) + check(() => MentionAutocompleteView.init(store: store, localizations: zulipLocalizations, + narrow: narrow, query: MentionAutocompleteQuery(''))) .throws(); }); }); test('final results end-to-end', () async { - Future> getResults( + Future> getResults( Narrow narrow, MentionAutocompleteQuery query) async { bool done = false; - final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: query); + final view = MentionAutocompleteView.init(store: store, + localizations: zulipLocalizations, narrow: narrow, query: query); view.addListener(() { done = true; }); await Future(() {}); check(done).isTrue(); - final results = view.results - .map((e) => (e as UserMentionAutocompleteResult).userId); + final results = view.results; view.dispose(); return results; } + Iterable getUsersFromResults(Iterable results) + => results.map((e) => (e as UserMentionAutocompleteResult).userId); + + Iterable getWildcardOptionsFromResults(Iterable results) + => results.map((e) => (e as WildcardMentionAutocompleteResult).wildcardOption); + final stream = eg.stream(); const topic = 'topic'; - final topicNarrow = TopicNarrow(stream.streamId, topic); + final topicNarrow = eg.topicNarrow(stream.streamId, topic); final users = [ eg.user(userId: 1, fullName: 'User One'), @@ -811,20 +825,133 @@ void main() { RecentDmConversation(userIds: [1, 2], maxMessageId: 100), ]); - // Check the ranking of the full list of users. + // Check the ranking of the full list of mentions. // The order should be: - // 1. Users most recent in the current topic/stream. - // 2. Users most recent in the DM conversations. - // 3. Human vs. Bot users (human users come first). - // 4. Alphabetical order by name. - check(await getResults(topicNarrow, MentionAutocompleteQuery(''))) + // 1. Wildcards before individual users. + // 2. Users most recent in the current topic/stream. + // 3. Users most recent in the DM conversations. + // 4. Human vs. Bot users (human users come first). + // 5. Users by name alphabetical order. + final results1 = await getResults(topicNarrow, MentionAutocompleteQuery('')); + check(getWildcardOptionsFromResults(results1.take(2))) + .deepEquals([WildcardMentionOption.all, WildcardMentionOption.topic]); + check(getUsersFromResults(results1.skip(2))) .deepEquals([1, 5, 4, 2, 7, 3, 6]); // Check the ranking applies also to results filtered by a query. - check(await getResults(topicNarrow, MentionAutocompleteQuery('t'))) - .deepEquals([2, 3]); - check(await getResults(topicNarrow, MentionAutocompleteQuery('f'))) - .deepEquals([5, 4]); + final results2 = await getResults(topicNarrow, MentionAutocompleteQuery('t')); + check(getWildcardOptionsFromResults(results2.take(2))) + .deepEquals([WildcardMentionOption.stream, WildcardMentionOption.topic]); + check(getUsersFromResults(results2.skip(2))).deepEquals([2, 3]); + final results3 = await getResults(topicNarrow, MentionAutocompleteQuery('f')); + check(getWildcardOptionsFromResults(results3.take(0))).deepEquals([]); + check(getUsersFromResults(results3.skip(0))).deepEquals([5, 4]); + }); + }); + + group('MentionAutocompleteView.computeWildcardMentionResults', () { + Iterable getWildcardOptionsFor(String rawQuery, { + bool isSilent = false, + required Narrow narrow, + int? zulipFeatureLevel, + ZulipLocalizations? localizations, + }) { + final store = eg.store( + account: eg.account(user: eg.selfUser, zulipFeatureLevel: zulipFeatureLevel), + initialSnapshot: eg.initialSnapshot(zulipFeatureLevel: zulipFeatureLevel)); + localizations ??= zulipLocalizations; + final view = MentionAutocompleteView.init(store: store, localizations: localizations, + narrow: narrow, query: MentionAutocompleteQuery(rawQuery, silent: isSilent)); + final results = []; + view.computeWildcardMentionResults(results: results, + isComposingChannelMessage: narrow is ChannelNarrow + || narrow is TopicNarrow); + view.dispose(); + return results.map((e) => (e as WildcardMentionAutocompleteResult).wildcardOption); + } + + const channelNarrow = ChannelNarrow(1); + const topicNarrow = TopicNarrow(1, TopicName('topic')); + final dmNarrow = DmNarrow.withUser(10, selfUserId: 5); + + final testCases = [ + ('', channelNarrow, [WildcardMentionOption.all, WildcardMentionOption.topic]), + ('', topicNarrow, [WildcardMentionOption.all, WildcardMentionOption.topic]), + ('', dmNarrow, [WildcardMentionOption.all]), + + ('c', channelNarrow, [WildcardMentionOption.channel, WildcardMentionOption.topic]), + ('ch', topicNarrow, [WildcardMentionOption.channel]), + ('str', channelNarrow, [WildcardMentionOption.stream]), + ('e', topicNarrow, [WildcardMentionOption.everyone]), + ('everyone', channelNarrow, [WildcardMentionOption.everyone]), + ('t', topicNarrow, [WildcardMentionOption.stream, WildcardMentionOption.topic]), + ('topic', channelNarrow, [WildcardMentionOption.topic]), + ('topic etc', topicNarrow, []), + + ('a', dmNarrow, [WildcardMentionOption.all]), + ('every', dmNarrow, [WildcardMentionOption.everyone]), + ('channel', dmNarrow, []), + ('stream', dmNarrow, []), + ('topic', dmNarrow, []), + ]; + + for (final (String query, Narrow narrow, List wildcardOptions) in testCases) { + test('query "$query" in ${narrow.runtimeType} -> $wildcardOptions', () async { + check(getWildcardOptionsFor(query, narrow: narrow)).deepEquals(wildcardOptions); + }); + } + + final localizedTestCases = [ + ('ال', channelNarrow, [WildcardMentionOption.all, WildcardMentionOption.topic]), + ('الجميع', topicNarrow, [WildcardMentionOption.all]), + ('الموضوع', channelNarrow, [WildcardMentionOption.topic]), + ('ق', topicNarrow, [WildcardMentionOption.channel]), + ('دفق', channelNarrow, [WildcardMentionOption.stream]), + ('الكل', dmNarrow, [WildcardMentionOption.everyone]), + + ('top', channelNarrow, [WildcardMentionOption.topic]), + ('channel', topicNarrow, [WildcardMentionOption.channel]), + ('every', dmNarrow, [WildcardMentionOption.everyone]), + ]; + + for (final (String localizedQuery, Narrow narrow, List wildcardOptions) in localizedTestCases) { + test('different locale -> query "$localizedQuery" in ${narrow.runtimeType} -> $wildcardOptions', () async { + check(getWildcardOptionsFor(localizedQuery, narrow: narrow, + localizations: zulipLocalizationsArabic)).deepEquals(wildcardOptions); + }); + } + + test('no wildcards for a silent mention', () { + check(getWildcardOptionsFor('', isSilent: true, narrow: channelNarrow)) + .isEmpty(); + check(getWildcardOptionsFor('all', isSilent: true, narrow: topicNarrow)) + .isEmpty(); + check(getWildcardOptionsFor('everyone', isSilent: true, narrow: dmNarrow)) + .isEmpty(); + }); + + test('${WildcardMentionOption.channel} is available FL-247 onwards', () { + check(getWildcardOptionsFor('channel', + narrow: channelNarrow, zulipFeatureLevel: 247)) + .deepEquals([WildcardMentionOption.channel]); + }); + + test('${WildcardMentionOption.channel} is not available before FL-247', () { + check(getWildcardOptionsFor('channel', + narrow: channelNarrow, zulipFeatureLevel: 246)) + .deepEquals([]); + }); + + test('${WildcardMentionOption.topic} is available FL-224 onwards', () { + check(getWildcardOptionsFor('topic', + narrow: channelNarrow, zulipFeatureLevel: 224)) + .deepEquals([WildcardMentionOption.topic]); + }); + + test('${WildcardMentionOption.topic} is not available before FL-224', () { + check(getWildcardOptionsFor('topic', + narrow: channelNarrow, zulipFeatureLevel: 223)) + .deepEquals([]); }); }); @@ -834,7 +961,8 @@ void main() { final description = 'topic-input with text: $markedText produces: ${expectedQuery?.raw ?? 'No Query!'}'; test(description, () { - final controller = ComposeTopicController(); + final store = eg.store(); + final controller = ComposeTopicController(store: store); controller.value = parsed.value; if (expectedQuery == null) { check(controller).autocompleteIntent.isNull(); @@ -900,7 +1028,7 @@ void main() { group('TopicAutocompleteQuery.testTopic', () { void doCheck(String rawQuery, String topic, bool expected) { - final result = TopicAutocompleteQuery(rawQuery).testTopic(topic); + final result = TopicAutocompleteQuery(rawQuery).testTopic(eg.t(topic)); expected ? check(result).isTrue() : check(result).isFalse(); } diff --git a/test/model/binding.dart b/test/model/binding.dart index badbbcf7e0..039d6c3787 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -4,6 +4,7 @@ import 'package:clock/clock.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; +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'; @@ -398,6 +399,7 @@ class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { timeSensitive: AppleNotificationSetting.disabled, criticalAlert: AppleNotificationSetting.disabled, sound: AppleNotificationSetting.enabled, + providesAppNotificationSettings: AppleNotificationSetting.disabled, ); List takeRequestPermissionCalls() { @@ -416,6 +418,7 @@ class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { bool criticalAlert = false, bool provisional = false, bool sound = true, + bool providesAppNotificationSettings = false, }) async { _requestPermissionCalls.add(( alert: alert, @@ -425,6 +428,7 @@ class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { criticalAlert: criticalAlert, provisional: provisional, sound: sound, + providesAppNotificationSettings: providesAppNotificationSettings, )); return requestPermissionResult; } @@ -505,9 +509,24 @@ typedef FirebaseMessagingRequestPermissionCall = ({ bool criticalAlert, bool provisional, bool sound, + bool providesAppNotificationSettings, }); class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { + // TODO(?): Find a better way to handle this. This member is exported from + // the Pigeon generated class but are not used for this fake class, + // so return the default value. + @override + // ignore: non_constant_identifier_names + final BinaryMessenger? pigeonVar_binaryMessenger = null; + + // TODO(?): Find a better way to handle this. This member is exported from + // the Pigeon generated class but are not used for this fake class, + // so return the default value. + @override + // ignore: non_constant_identifier_names + final String pigeonVar_messageChannelSuffix = ''; + /// Lists currently active channels, result is aggregated from calls made to /// [createNotificationChannel] and [deleteNotificationChannel], /// order of creation is preserved. @@ -532,7 +551,7 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { } @override - Future> getNotificationChannels() async { + Future> getNotificationChannels() async { return _activeChannels.values.toList(growable: false); } @@ -567,7 +586,7 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { } @override - Future> listStoredSoundsInNotificationsDirectory() async { + Future> listStoredSoundsInNotificationsDirectory() async { return _storedNotificationSounds.toList(growable: false); } @@ -631,7 +650,7 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { PendingIntent? contentIntent, String? contentText, String? contentTitle, - Map? extras, + Map? extras, String? groupKey, InboxStyle? inboxStyle, bool? isGroupSummary, @@ -671,7 +690,7 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { isGroupConversation: messagingStyle.isGroupConversation, messages: messagingStyle.messages.map((message) => MessagingStyleMessage( - text: message!.text, + text: message.text, timestampMs: message.timestampMs, person: Person( key: message.person.key, @@ -686,14 +705,14 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { _activeNotificationsMessagingStyle[tag]; @override - Future> getActiveNotifications({required List desiredExtras}) async { + Future> getActiveNotifications({required List desiredExtras}) async { return _activeNotifications.values.map((statusNotif) { final notificationExtras = statusNotif.notification.extras; - statusNotif.notification.extras = Map.fromEntries( - desiredExtras - .map((key) => MapEntry(key, notificationExtras[key])) - .where((entry) => entry.value != null) - ); + statusNotif.notification.extras = { + for (final key in desiredExtras) + if (notificationExtras[key] != null) + key: notificationExtras[key]!, + }; return statusNotif; }).toList(growable: false); } diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart index 14a9f69ea3..f22ac7cc8f 100644 --- a/test/model/channel_test.dart +++ b/test/model/channel_test.dart @@ -123,14 +123,14 @@ void main() { group('getter topicVisibilityPolicy', () { test('with nothing for stream', () { final store = eg.store(); - check(store.topicVisibilityPolicy(stream1.streamId, 'topic')) + check(store.topicVisibilityPolicy(stream1.streamId, eg.t('topic'))) .equals(UserTopicVisibilityPolicy.none); }); test('with nothing for topic', () async { final store = eg.store(); await store.addUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.muted); - check(store.topicVisibilityPolicy(stream1.streamId, 'topic')) + check(store.topicVisibilityPolicy(stream1.streamId, eg.t('topic'))) .equals(UserTopicVisibilityPolicy.none); }); @@ -142,7 +142,7 @@ void main() { UserTopicVisibilityPolicy.followed, ]) { await store.addUserTopic(stream1, 'topic', policy); - check(store.topicVisibilityPolicy(stream1.streamId, 'topic')) + check(store.topicVisibilityPolicy(stream1.streamId, eg.t('topic'))) .equals(policy); } }); @@ -153,23 +153,23 @@ void main() { final store = eg.store(); await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1)); - check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue(); - check(store.isTopicVisible (stream1.streamId, 'topic')).isTrue(); + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isTrue(); }); test('with policy none, stream muted', () async { final store = eg.store(); await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); - check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue(); - check(store.isTopicVisible (stream1.streamId, 'topic')).isFalse(); + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isFalse(); }); test('with policy none, stream unsubscribed', () async { final store = eg.store(); await store.addStream(stream1); - check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue(); - check(store.isTopicVisible (stream1.streamId, 'topic')).isFalse(); + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isFalse(); }); test('with policy muted', () async { @@ -177,8 +177,8 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1)); await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); - check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isFalse(); - check(store.isTopicVisible (stream1.streamId, 'topic')).isFalse(); + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isFalse(); + check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isFalse(); }); test('with policy unmuted', () async { @@ -186,8 +186,8 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.unmuted); - check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue(); - check(store.isTopicVisible (stream1.streamId, 'topic')).isTrue(); + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isTrue(); }); test('with policy followed', () async { @@ -195,8 +195,8 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.followed); - check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue(); - check(store.isTopicVisible (stream1.streamId, 'topic')).isTrue(); + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isTrue(); }); }); @@ -265,16 +265,16 @@ void main() { eg.subscription(stream1, isMuted: streamMuted)); } await store.handleEvent(mkEvent(oldPolicy)); - final oldVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, 'topic'); - final oldVisible = store.isTopicVisible(stream1.streamId, 'topic'); + final oldVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, eg.t('topic')); + final oldVisible = store.isTopicVisible(stream1.streamId, eg.t('topic')); final event = mkEvent(newPolicy); final willChangeInStream = store.willChangeIfTopicVisibleInStream(event); final willChange = store.willChangeIfTopicVisible(event); await store.handleEvent(event); - final newVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, 'topic'); - final newVisible = store.isTopicVisible(stream1.streamId, 'topic'); + 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; @@ -384,13 +384,13 @@ void main() { eg.userTopicItem(stream, 'topic 2', UserTopicVisibilityPolicy.unmuted), eg.userTopicItem(stream, 'topic 3', UserTopicVisibilityPolicy.followed), ])); - check(store.topicVisibilityPolicy(stream.streamId, 'topic 1')) + check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 1'))) .equals(UserTopicVisibilityPolicy.muted); - check(store.topicVisibilityPolicy(stream.streamId, 'topic 2')) + check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 2'))) .equals(UserTopicVisibilityPolicy.unmuted); - check(store.topicVisibilityPolicy(stream.streamId, 'topic 3')) + check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 3'))) .equals(UserTopicVisibilityPolicy.followed); - check(store.topicVisibilityPolicy(stream.streamId, 'topic 4')) + check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 4'))) .equals(UserTopicVisibilityPolicy.none); }); }); diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index 9d6387cd5c..73fa9452d7 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -1,6 +1,8 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/model/compose.dart'; +import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/store.dart'; import '../example_data.dart' as eg; import 'test_store.dart'; @@ -221,27 +223,54 @@ hello }); group('mention', () { - final user = eg.user(userId: 123, fullName: 'Full Name'); - test('not silent', () { - check(mention(user, silent: false)).equals('@**Full Name|123**'); + group('user', () { + final user = eg.user(userId: 123, fullName: 'Full Name'); + test('not silent', () { + check(userMention(user, silent: false)).equals('@**Full Name|123**'); + }); + test('silent', () { + check(userMention(user, silent: true)).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.users)).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.users)).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.users)).equals('@_**Full Name**'); + }); }); - test('silent', () { - check(mention(user, silent: true)).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(mention(user, silent: true, users: store.users)).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(mention(user, silent: true, users: store.users)).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(mention(user, silent: true, users: store.users)).equals('@_**Full Name**'); + + test('wildcard', () { + PerAccountStore store({int? zulipFeatureLevel}) { + return eg.store( + account: eg.account(user: eg.selfUser, + zulipFeatureLevel: zulipFeatureLevel), + initialSnapshot: eg.initialSnapshot( + zulipFeatureLevel: zulipFeatureLevel)); + } + + check(wildcardMention(WildcardMentionOption.all, store: store())) + .equals('@**all**'); + check(wildcardMention(WildcardMentionOption.everyone, store: store())) + .equals('@**everyone**'); + check(wildcardMention(WildcardMentionOption.channel, store: store())) + .equals('@**channel**'); + check(wildcardMention(WildcardMentionOption.stream, + store: store(zulipFeatureLevel: 247))) + .equals('@**channel**'); + check(wildcardMention(WildcardMentionOption.stream, + store: store(zulipFeatureLevel: 246))) + .equals('@**stream**'); + check(wildcardMention(WildcardMentionOption.topic, store: store())) + .equals('@**topic**'); }); }); @@ -260,7 +289,8 @@ hello await store.addStream(stream); await store.addUser(sender); - check(quoteAndReplyPlaceholder(store, message: message)).equals(''' + check(quoteAndReplyPlaceholder( + GlobalLocalizations.zulipLocalizations, store, message: message)).equals(''' @_**Full Name|123** [said](${eg.selfAccount.realmUrl}#narrow/stream/1-test-here/topic/some.20topic/near/${message.id}): *(loading message ${message.id})* '''); diff --git a/test/model/internal_link_test.dart b/test/model/internal_link_test.dart index 3949339321..eb91e35855 100644 --- a/test/model/internal_link_test.dart +++ b/test/model/internal_link_test.dart @@ -72,7 +72,7 @@ void main() { await store.addStream(eg.stream(streamId: streamId, name: name)); final narrow = topic == null ? ChannelNarrow(streamId) - : TopicNarrow(streamId, topic); + : eg.topicNarrow(streamId, topic); check(narrowLink(store, narrow, nearMessageId: nearMessageId)) .equals(store.realmUrl.resolve(expectedFragment)); } @@ -281,28 +281,28 @@ void main() { }); group('"/#narrow/stream/<...>/topic/<...>" returns expected TopicNarrow', () { - const testCases = [ - ('/#narrow/stream/check/topic/test', TopicNarrow(1, 'test')), - ('/#narrow/stream/mobile/subject/topic/near/378333', TopicNarrow(3, 'topic')), - ('/#narrow/stream/mobile/subject/topic/with/1', TopicNarrow(3, 'topic')), - ('/#narrow/stream/mobile/topic/topic/', TopicNarrow(3, 'topic')), - ('/#narrow/stream/stream/topic/topic/near/1', TopicNarrow(5, 'topic')), - ('/#narrow/stream/stream/topic/topic/with/22', TopicNarrow(5, 'topic')), - ('/#narrow/stream/stream/subject/topic/near/1', TopicNarrow(5, 'topic')), - ('/#narrow/stream/stream/subject/topic/with/333', TopicNarrow(5, 'topic')), - ('/#narrow/stream/stream/subject/topic', TopicNarrow(5, 'topic')), + final testCases = [ + ('/#narrow/stream/check/topic/test', eg.topicNarrow(1, 'test')), + ('/#narrow/stream/mobile/subject/topic/near/378333', eg.topicNarrow(3, 'topic')), + ('/#narrow/stream/mobile/subject/topic/with/1', eg.topicNarrow(3, 'topic')), + ('/#narrow/stream/mobile/topic/topic/', eg.topicNarrow(3, 'topic')), + ('/#narrow/stream/stream/topic/topic/near/1', eg.topicNarrow(5, 'topic')), + ('/#narrow/stream/stream/topic/topic/with/22', eg.topicNarrow(5, 'topic')), + ('/#narrow/stream/stream/subject/topic/near/1', eg.topicNarrow(5, 'topic')), + ('/#narrow/stream/stream/subject/topic/with/333', eg.topicNarrow(5, 'topic')), + ('/#narrow/stream/stream/subject/topic', eg.topicNarrow(5, 'topic')), ]; testExpectedNarrows(testCases, streams: streams); }); group('Both `stream` and `channel` can be used interchangeably', () { - const testCases = [ - ('/#narrow/stream/check', ChannelNarrow(1)), - ('/#narrow/channel/check', ChannelNarrow(1)), - ('/#narrow/stream/check/topic/test', TopicNarrow(1, 'test')), - ('/#narrow/channel/check/topic/test', TopicNarrow(1, 'test')), - ('/#narrow/stream/check/topic/test/near/378333', TopicNarrow(1, 'test')), - ('/#narrow/channel/check/topic/test/near/378333', TopicNarrow(1, 'test')), + final testCases = [ + ('/#narrow/stream/check', const ChannelNarrow(1)), + ('/#narrow/channel/check', const ChannelNarrow(1)), + ('/#narrow/stream/check/topic/test', eg.topicNarrow(1, 'test')), + ('/#narrow/channel/check/topic/test', eg.topicNarrow(1, 'test')), + ('/#narrow/stream/check/topic/test/near/378333', eg.topicNarrow(1, 'test')), + ('/#narrow/channel/check/topic/test/near/378333', eg.topicNarrow(1, 'test')), ]; testExpectedNarrows(testCases, streams: streams); }); @@ -414,13 +414,13 @@ void main() { eg.stream(streamId: 2, name: 'some stream'), eg.stream(streamId: 3, name: 'some.stream'), ]; - const testCases = [ - ('/#narrow/stream/some_stream', ChannelNarrow(1)), - ('/#narrow/stream/some.20stream', ChannelNarrow(2)), - ('/#narrow/stream/some.2Estream', ChannelNarrow(3)), - ('/#narrow/stream/some_stream/topic/some_topic', TopicNarrow(1, 'some_topic')), - ('/#narrow/stream/some_stream/topic/some.20topic', TopicNarrow(1, 'some topic')), - ('/#narrow/stream/some_stream/topic/some.2Etopic', TopicNarrow(1, 'some.topic')), + final testCases = [ + ('/#narrow/stream/some_stream', const ChannelNarrow(1)), + ('/#narrow/stream/some.20stream', const ChannelNarrow(2)), + ('/#narrow/stream/some.2Estream', const ChannelNarrow(3)), + ('/#narrow/stream/some_stream/topic/some_topic', eg.topicNarrow(1, 'some_topic')), + ('/#narrow/stream/some_stream/topic/some.20topic', eg.topicNarrow(1, 'some topic')), + ('/#narrow/stream/some_stream/topic/some.2Etopic', eg.topicNarrow(1, 'some.topic')), ]; testExpectedNarrows(testCases, streams: streams); }); @@ -518,8 +518,8 @@ void main() { return '#narrow/stream/${stream.streamId}-${stream.name}/topic/$operand'; } final testCases = [ - (mkUrlString('(no.20topic)'), TopicNarrow(stream.streamId, '(no topic)')), - (mkUrlString('lunch'), TopicNarrow(stream.streamId, 'lunch')), + (mkUrlString('(no.20topic)'), eg.topicNarrow(stream.streamId, '(no topic)')), + (mkUrlString('lunch'), eg.topicNarrow(stream.streamId, 'lunch')), ]; testExpectedNarrows(testCases, streams: [stream]); }); @@ -529,12 +529,12 @@ void main() { return '#narrow/stream/${stream.name}/topic/$operand'; } final testCases = [ - (mkUrlString('(no.20topic)'), TopicNarrow(stream.streamId, '(no topic)')), - (mkUrlString('google.2Ecom'), TopicNarrow(stream.streamId, 'google.com')), + (mkUrlString('(no.20topic)'), eg.topicNarrow(stream.streamId, '(no topic)')), + (mkUrlString('google.2Ecom'), eg.topicNarrow(stream.streamId, 'google.com')), (mkUrlString('google.com'), null), - (mkUrlString('topic.20name'), TopicNarrow(stream.streamId, 'topic name')), - (mkUrlString('stream'), TopicNarrow(stream.streamId, 'stream')), - (mkUrlString('topic'), TopicNarrow(stream.streamId, 'topic')), + (mkUrlString('topic.20name'), eg.topicNarrow(stream.streamId, 'topic name')), + (mkUrlString('stream'), eg.topicNarrow(stream.streamId, 'stream')), + (mkUrlString('topic'), eg.topicNarrow(stream.streamId, 'topic')), ]; testExpectedNarrows(testCases, streams: [stream]); }); diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index e92b2489b3..6a1d103c84 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -440,7 +440,7 @@ void main() { }); test('in TopicNarrow, stay visible', () async { - await prepare(narrow: TopicNarrow(stream.streamId, topic)); + await prepare(narrow: eg.topicNarrow(stream.streamId, topic)); await prepareMutes(); await prepareMessages(foundOldest: true, messages: [ eg.streamMessage(id: 1, stream: stream, topic: topic), @@ -720,7 +720,7 @@ void main() { await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: movedMessages, - newTopic: 'new', + newTopicStr: 'new', )); checkHasMessages(initialMessages + movedMessages); checkNotified(count: 2); @@ -738,7 +738,7 @@ void main() { await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: movedMessages, - newTopic: 'new', + newTopicStr: 'new', )); checkHasMessages(initialMessages + movedMessages); checkNotified(count: 2); @@ -752,7 +752,7 @@ void main() { messages: initialMessages + movedMessages, ).toJson()); await store.handleEvent(eg.updateMessageEventMoveTo( - origTopic: 'orig topic', + origTopicStr: 'orig topic', origStreamId: otherStream.streamId, newMessages: movedMessages, )); @@ -770,7 +770,7 @@ void main() { await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: movedMessages, - newTopic: 'new', + newTopicStr: 'new', newStreamId: otherStream.streamId, )); checkHasMessages(initialMessages); @@ -793,7 +793,7 @@ void main() { await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: otherChannelMovedMessages, - newTopic: 'new', + newTopicStr: 'new', )); checkHasMessages(initialMessages); checkNotNotified(); @@ -807,7 +807,7 @@ void main() { ).toJson()); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: movedMessages, - newTopic: 'new', + newTopicStr: 'new', newStreamId: otherStream.streamId, propagateMode: propagateMode, )); @@ -832,7 +832,7 @@ void main() { }); group('in topic narrow', () { - final narrow = TopicNarrow(stream.streamId, 'topic'); + final narrow = eg.topicNarrow(stream.streamId, 'topic'); final initialMessages = List.generate(5, (i) => eg.streamMessage(stream: stream, topic: 'topic')); final movedMessages = List.generate(5, (i) => eg.streamMessage(stream: stream, topic: 'topic')); final otherTopicMovedMessages = List.generate(5, (i) => eg.streamMessage(stream: stream, topic: 'other topic')); @@ -855,7 +855,7 @@ void main() { ).toJson()); await store.handleEvent(eg.updateMessageEventMoveTo( origStreamId: origStreamId, - origTopic: origTopic, + origTopicStr: origTopic, newMessages: movedMessages, )); check(model).fetched.isFalse(); @@ -883,7 +883,7 @@ void main() { await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: movedMessages, newStreamId: newStreamId, - newTopic: newTopic, + newTopicStr: newTopic, )); checkHasMessages(initialMessages); checkNotifiedOnce(); @@ -896,7 +896,7 @@ void main() { await prepareNarrow(narrow, initialMessages); await store.handleEvent(eg.updateMessageEventMoveTo( - origTopic: 'other', + origTopicStr: 'other', newMessages: otherTopicMovedMessages, )); check(model).fetched.isTrue(); @@ -925,7 +925,7 @@ void main() { ).toJson()); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: movedMessages, - newTopic: 'new', + newTopicStr: 'new', newStreamId: otherStream.streamId, propagateMode: propagateMode, )); @@ -937,21 +937,21 @@ void main() { handleMoveEvent(PropagateMode.changeOne); checkNotNotified(); checkHasMessages(initialMessages); - check(model).narrow.equals(TopicNarrow(stream.streamId, 'topic')); + check(model).narrow.equals(eg.topicNarrow(stream.streamId, 'topic')); }); test('follow to the new narrow when propagateMode = changeLater', () { handleMoveEvent(PropagateMode.changeLater); checkNotifiedOnce(); checkHasMessages(movedMessages); - check(model).narrow.equals(TopicNarrow(otherStream.streamId, 'new')); + check(model).narrow.equals(eg.topicNarrow(otherStream.streamId, 'new')); }); test('follow to the new narrow when propagateMode = changeAll', () { handleMoveEvent(PropagateMode.changeAll); checkNotifiedOnce(); checkHasMessages(movedMessages); - check(model).narrow.equals(TopicNarrow(otherStream.streamId, 'new')); + check(model).narrow.equals(eg.topicNarrow(otherStream.streamId, 'new')); }); test('handle move event before initial fetch', () => awaitFakeAsync((async) async { @@ -969,11 +969,11 @@ void main() { check(model).fetched.isFalse(); checkHasMessages([]); await store.handleEvent(eg.updateMessageEventMoveTo( - origTopic: 'topic', + origTopicStr: 'topic', newMessages: [followedMessage], propagateMode: PropagateMode.changeAll, )); - check(model).narrow.equals(TopicNarrow(stream.streamId, 'new')); + check(model).narrow.equals(eg.topicNarrow(stream.streamId, 'new')); async.elapse(const Duration(seconds: 2)); checkHasMessages([followedMessage]); @@ -1255,7 +1255,7 @@ void main() { int notifiedCount2 = 0; final model2 = MessageListView.init(store: store, - narrow: TopicNarrow(stream.streamId, 'hello')) + narrow: eg.topicNarrow(stream.streamId, 'hello')) ..addListener(() => notifiedCount2++); for (final m in [model1, model2]) { @@ -1481,7 +1481,7 @@ void main() { test('in TopicNarrow', () async { final stream = eg.stream(); - await prepare(narrow: TopicNarrow(stream.streamId, 'A')); + 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); diff --git a/test/model/message_test.dart b/test/model/message_test.dart index c1fad15ac7..43f17be61a 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -320,7 +320,7 @@ void main() { final originalDisplayRecipient = origMessages[0].displayRecipient!; await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: origMessages, - newTopic: 'new topic')); + newTopicStr: 'new topic')); checkNotified(count: 2); check(store).messages.values.every(((message) => message.isA() @@ -332,7 +332,7 @@ void main() { await prepareOrigMessages(origTopic: 'new topic'); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: origMessages, - newTopic: '✔ new topic')); + newTopicStr: '✔ new topic')); checkNotified(count: 2); check(store).messages.values.every(((message) => message.editState.equals(MessageEditState.none))); }); @@ -341,7 +341,7 @@ void main() { await prepareOrigMessages(origTopic: '✔ new topic'); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: origMessages, - newTopic: 'new topic')); + newTopicStr: 'new topic')); checkNotified(count: 2); check(store).messages.values.every(((message) => message.editState.equals(MessageEditState.none))); }); @@ -350,7 +350,7 @@ void main() { await prepareOrigMessages(origTopic: 'new topic'); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: origMessages, - newTopic: '✔ new topic 2')); + newTopicStr: '✔ new topic 2')); checkNotified(count: 2); check(store).messages.values.every(((message) => message.editState.equals(MessageEditState.moved))); }); @@ -359,7 +359,7 @@ void main() { await prepareOrigMessages(origTopic: '✔ new topic'); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: origMessages, - newTopic: 'new topic 2')); + newTopicStr: 'new topic 2')); checkNotified(count: 2); check(store).messages.values.every(((message) => message.editState.equals(MessageEditState.moved))); }); diff --git a/test/model/narrow_checks.dart b/test/model/narrow_checks.dart index 241547d782..ce65de854d 100644 --- a/test/model/narrow_checks.dart +++ b/test/model/narrow_checks.dart @@ -1,5 +1,6 @@ import 'package:checks/checks.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; import 'package:zulip/model/narrow.dart'; @@ -14,5 +15,5 @@ extension DmNarrowChecks on Subject { extension TopicNarrowChecks on Subject { Subject get streamId => has((x) => x.streamId, 'streamId'); - Subject get topic => has((x) => x.topic, 'topic'); + Subject get topic => has((x) => x.topic, 'topic'); } diff --git a/test/model/recent_senders_test.dart b/test/model/recent_senders_test.dart index 2da516e11c..602362cbcc 100644 --- a/test/model/recent_senders_test.dart +++ b/test/model/recent_senders_test.dart @@ -7,7 +7,7 @@ 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.'); @@ -199,15 +199,15 @@ void main() { model.handleMessages(messages); check(model.latestMessageIdOfSenderInTopic(streamId: 1, - topic: 'a', senderId: 10)).equals(300); + topic: eg.t('a'), senderId: 10)).equals(300); // No message of user 20 in topic "a". check(model.latestMessageIdOfSenderInTopic(streamId: 1, - topic: 'a', senderId: 20)).equals(null); + topic: eg.t('a'), senderId: 20)).equals(null); // No message in topic "b" at all. check(model.latestMessageIdOfSenderInTopic(streamId: 1, - topic: 'b', senderId: 10)).equals(null); + topic: eg.t('b'), senderId: 10)).equals(null); // No message in stream 2 at all. check(model.latestMessageIdOfSenderInTopic(streamId: 2, - topic: 'a', senderId: 10)).equals(null); + topic: eg.t('a'), senderId: 10)).equals(null); }); } diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index 3495aa0359..f2ef63cd4b 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -31,6 +31,7 @@ extension PerAccountStoreChecks on Subject { Subject get isLoading => has((x) => x.isLoading, 'isLoading'); Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); Subject get zulipVersion => has((x) => x.zulipVersion, 'zulipVersion'); + Subject get realmMandatoryTopics => has((x) => x.realmMandatoryTopics, 'realmMandatoryTopics'); Subject get maxFileUploadSizeMib => has((x) => x.maxFileUploadSizeMib, 'maxFileUploadSizeMib'); Subject> get realmDefaultExternalAccounts => has((x) => x.realmDefaultExternalAccounts, 'realmDefaultExternalAccounts'); Subject> get customProfileFields => has((x) => x.customProfileFields, 'customProfileFields'); diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 10e8698360..c8c6b4c266 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -386,7 +386,7 @@ void main() { final stream = eg.stream(); connection.prepare(json: SendMessageResult(id: 12345).toJson()); await store.sendMessage( - destination: StreamDestination(stream.streamId, 'world'), + destination: StreamDestination(stream.streamId, eg.t('world')), content: 'hello'); check(connection.takeRequests()).single.isA() ..method.equals('POST') diff --git a/test/model/test_store.dart b/test/model/test_store.dart index 2322e78d7a..b6887cbed7 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -85,6 +85,9 @@ class TestGlobalStore extends GlobalStore { /// [PerAccountStore] when [perAccount] is subsequently called for this /// account, in particular when a [PerAccountStoreWidget] is mounted. Future add(Account account, InitialSnapshot initialSnapshot) async { + assert(initialSnapshot.zulipVersion == account.zulipVersion); + assert(initialSnapshot.zulipMergeBase == account.zulipMergeBase); + assert(initialSnapshot.zulipFeatureLevel == account.zulipFeatureLevel); await insertAccount(account.toCompanion(false)); assert(!_initialSnapshots.containsKey(account.id)); _initialSnapshots[account.id] = initialSnapshot; diff --git a/test/model/typing_status_test.dart b/test/model/typing_status_test.dart index 45348afb01..4061301366 100644 --- a/test/model/typing_status_test.dart +++ b/test/model/typing_status_test.dart @@ -35,7 +35,7 @@ void checkSetTypingStatusRequests( 'type': 'channel', 'op': op.toJson(), 'stream_id': narrow.streamId.toString(), - 'topic': narrow.topic}), + 'topic': narrow.topic.apiName}), DmNarrow() => conditionTypingRequest({ 'type': 'direct', 'op': op.toJson(), @@ -94,7 +94,7 @@ void main() { } final stream = eg.stream(); - final topicNarrow = TopicNarrow(stream.streamId, 'foo'); + final topicNarrow = eg.topicNarrow(stream.streamId, 'foo'); final dmNarrow = DmNarrow.withUser(eg.otherUser.userId, selfUserId: eg.selfUser.userId); final groupNarrow = DmNarrow.withOtherUsers( @@ -272,7 +272,7 @@ void main() { final channel = eg.stream(); await store.addStream(channel); await store.addSubscription(eg.subscription(channel)); - narrow = TopicNarrow(channel.streamId, 'topic'); + narrow = eg.topicNarrow(channel.streamId, 'topic'); } /// Prepares store and triggers a "typing started" notice. diff --git a/test/model/unreads_checks.dart b/test/model/unreads_checks.dart index 836e497b2b..ac4d64846a 100644 --- a/test/model/unreads_checks.dart +++ b/test/model/unreads_checks.dart @@ -1,10 +1,11 @@ import 'package:checks/checks.dart'; import 'package:collection/collection.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/unreads.dart'; extension UnreadsChecks on Subject { - Subject>>> get streams => has((u) => u.streams, 'streams'); + Subject>>> get streams => has((u) => u.streams, 'streams'); Subject>> get dms => has((u) => u.dms, 'dms'); Subject> get mentions => has((u) => u.mentions, 'mentions'); Subject get oldUnreadsMissing => has((u) => u.oldUnreadsMissing, 'oldUnreadsMissing'); diff --git a/test/model/unreads_test.dart b/test/model/unreads_test.dart index f5eab5a845..40e074dcaa 100644 --- a/test/model/unreads_test.dart +++ b/test/model/unreads_test.dart @@ -58,7 +58,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) { @@ -114,10 +114,10 @@ void main() { prepare(initial: UnreadMessagesSnapshot( count: 0, channels: [ - UnreadChannelSnapshot(streamId: stream1.streamId, topic: 'a', unreadMessageIds: [1, 2]), - UnreadChannelSnapshot(streamId: stream1.streamId, topic: 'b', unreadMessageIds: [3, 4]), - UnreadChannelSnapshot(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [5, 6]), - UnreadChannelSnapshot(streamId: stream2.streamId, topic: 'c', unreadMessageIds: [7, 8]), + eg.unreadChannelMsgs(streamId: stream1.streamId, topic: 'a', unreadMessageIds: [1, 2]), + 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]), ], dms: [ UnreadDmSnapshot(otherUserId: 1, unreadMessageIds: [9, 10]), @@ -204,7 +204,7 @@ void main() { prepare(); fillWithMessages(List.generate(7, (i) => eg.streamMessage( stream: stream, topic: 'a', flags: []))); - check(model.countInTopicNarrow(stream.streamId, 'a')).equals(7); + check(model.countInTopicNarrow(stream.streamId, eg.t('a'))).equals(7); }); test('countInDmNarrow', () { @@ -538,7 +538,7 @@ void main() { messageIds: [11, 12], messageType: MessageType.stream, streamId: stream1.streamId, - topic: 'a', + topic: eg.t('a'), )); checkNotifiedOnce(); checkMatchesMessages(expectedRemainingMessages..removeAll([message11, message12])); @@ -547,7 +547,7 @@ void main() { messageIds: [13, 14], messageType: MessageType.stream, streamId: stream2.streamId, - topic: 'b', + topic: eg.t('b'), )); checkNotifiedOnce(); checkMatchesMessages(expectedRemainingMessages..removeAll([message13, message14])); @@ -1029,7 +1029,7 @@ void main() { type: MessageType.stream, mentioned: false, streamId: stream.streamId, - topic: topic, + topic: eg.t(topic), userIds: null, ), // message 2 and 3 have their details missing diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index cb49a5f8a8..b1c56b55b1 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -540,21 +540,47 @@ void main() { messageStyleMessages: [data1], expectedIsGroupConversation: true, expectedTitle: '#${stream.name} > $topicA', - expectedTagComponent: 'stream:${stream.streamId}:$topicA'); + expectedTagComponent: 'stream:${stream.streamId}:${topicA.toLowerCase()}'); receiveFcmMessage(async, data2); checkNotification(data2, messageStyleMessages: [data2], expectedIsGroupConversation: true, expectedTitle: '#${stream.name} > $topicB', - expectedTagComponent: 'stream:${stream.streamId}:$topicB'); + expectedTagComponent: 'stream:${stream.streamId}:${topicB.toLowerCase()}'); receiveFcmMessage(async, data3); checkNotification(data3, messageStyleMessages: [data1, data3], expectedIsGroupConversation: true, expectedTitle: '#${stream.name} > $topicA', - expectedTagComponent: 'stream:${stream.streamId}:$topicA'); + expectedTagComponent: 'stream:${stream.streamId}:${topicA.toLowerCase()}'); + }))); + + test('stream message: topic changes only case', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(); + final stream = eg.stream(); + const topic1 = 'A ToPic'; + const topic2 = 'a TOpic'; + final message1 = eg.streamMessage(topic: topic1, stream: stream); + final data1 = messageFcmMessage(message1, streamName: stream.name); + final message2 = eg.streamMessage(topic: topic2, stream: stream); + final data2 = messageFcmMessage(message2, streamName: stream.name); + + receiveFcmMessage(async, data1); + checkNotification(data1, + messageStyleMessages: [data1], + expectedIsGroupConversation: true, + expectedTitle: '#${stream.name} > $topic1', + expectedTagComponent: 'stream:${stream.streamId}:a topic'); + + receiveFcmMessage(async, data2); + checkNotification(data2, + messageStyleMessages: [data1, data2], + expectedIsGroupConversation: true, + // Title updates with latest casing of topic. + expectedTitle: '#${stream.name} > $topic2', + expectedTagComponent: 'stream:${stream.streamId}:a topic'); }))); test('stream message: conversation stays same when stream is renamed', () => runWithHttpClient(() => awaitFakeAsync((async) async { @@ -781,6 +807,7 @@ void main() { final data2 = messageFcmMessage(message2, streamName: stream.name); final message3 = eg.streamMessage(stream: stream, topic: topicA); final data3 = messageFcmMessage(message3, streamName: stream.name); + final conversationKey = 'stream:${stream.streamId}:${topicA.toLowerCase()}'; final expectedGroupKey = '${data1.realmUrl}|${data1.userId}'; check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); @@ -789,14 +816,14 @@ void main() { receiveFcmMessage(async, data2); receiveFcmMessage(async, data3); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ - conditionActiveNotif(data3, 'stream:${stream.streamId}:$topicA'), + conditionActiveNotif(data3, conversationKey), conditionSummaryActiveNotif(expectedGroupKey), ]); // A RemoveFcmMessage for the first two messages; the notification stays. receiveFcmMessage(async, removeFcmMessage([message1, message2])); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ - conditionActiveNotif(data3, 'stream:${stream.streamId}:$topicA'), + conditionActiveNotif(data3, conversationKey), conditionSummaryActiveNotif(expectedGroupKey), ]); @@ -808,13 +835,17 @@ void main() { test('remove: clears summary notification only if all conversation notifications are cleared', () => runWithHttpClient(() => awaitFakeAsync((async) async { await init(); final stream = eg.stream(); + const topicA = 'Topic A'; final message1 = eg.streamMessage(stream: stream, topic: topicA); final data1 = messageFcmMessage(message1, streamName: stream.name); + final conversationKey1 = 'stream:${stream.streamId}:${topicA.toLowerCase()}'; + final expectedGroupKey = '${data1.realmUrl}|${data1.userId}'; + const topicB = 'Topic B'; final message2 = eg.streamMessage(stream: stream, topic: topicB); final data2 = messageFcmMessage(message2, streamName: stream.name); - final expectedGroupKey = '${data1.realmUrl}|${data1.userId}'; + final conversationKey2 = 'stream:${stream.streamId}:${topicB.toLowerCase()}'; check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); @@ -822,16 +853,16 @@ void main() { receiveFcmMessage(async, data1); receiveFcmMessage(async, data2); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ - conditionActiveNotif(data1, 'stream:${stream.streamId}:$topicA'), + conditionActiveNotif(data1, conversationKey1), conditionSummaryActiveNotif(expectedGroupKey), - conditionActiveNotif(data2, 'stream:${stream.streamId}:$topicB'), + conditionActiveNotif(data2, conversationKey2), ]); // A RemoveFcmMessage for first conversation; only clears the first conversation notif. receiveFcmMessage(async, removeFcmMessage([message1])); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ conditionSummaryActiveNotif(expectedGroupKey), - conditionActiveNotif(data2, 'stream:${stream.streamId}:$topicB'), + conditionActiveNotif(data2, conversationKey2), ]); // Then a RemoveFcmMessage for the only remaining conversation; @@ -844,6 +875,7 @@ void main() { await init(); final stream = eg.stream(); const topic = 'Some Topic'; + final conversationKey = 'stream:${stream.streamId}:some topic'; final account1 = eg.account( realmUrl: Uri.parse('https://1.chat.example'), @@ -868,15 +900,15 @@ void main() { receiveFcmMessage(async, data1); receiveFcmMessage(async, data2); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ - conditionActiveNotif(data1, 'stream:${stream.streamId}:$topic'), + conditionActiveNotif(data1, conversationKey), conditionSummaryActiveNotif(groupKey1), - conditionActiveNotif(data2, 'stream:${stream.streamId}:$topic'), + conditionActiveNotif(data2, conversationKey), conditionSummaryActiveNotif(groupKey2), ]); receiveFcmMessage(async, removeFcmMessage([message1], account: account1)); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ - conditionActiveNotif(data2, 'stream:${stream.streamId}:$topic'), + conditionActiveNotif(data2, conversationKey), conditionSummaryActiveNotif(groupKey2), ]); @@ -889,6 +921,7 @@ void main() { final realmUrl = eg.realmUrl; final stream = eg.stream(); const topic = 'Some Topic'; + final conversationKey = 'stream:${stream.streamId}:some topic'; final account1 = eg.account(id: 1001, user: eg.user(userId: 1001), realmUrl: realmUrl); final message1 = eg.streamMessage(id: 1000, stream: stream, topic: topic); @@ -907,15 +940,15 @@ void main() { receiveFcmMessage(async, data1); receiveFcmMessage(async, data2); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ - conditionActiveNotif(data1, 'stream:${stream.streamId}:$topic'), + conditionActiveNotif(data1, conversationKey), conditionSummaryActiveNotif(groupKey1), - conditionActiveNotif(data2, 'stream:${stream.streamId}:$topic'), + conditionActiveNotif(data2, conversationKey), conditionSummaryActiveNotif(groupKey2), ]); receiveFcmMessage(async, removeFcmMessage([message1], account: account1)); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ - conditionActiveNotif(data2, 'stream:${stream.streamId}:$topic'), + conditionActiveNotif(data2, conversationKey), conditionSummaryActiveNotif(groupKey2), ]); @@ -927,11 +960,12 @@ void main() { group('NotificationDisplayManager open', () { late List> pushedRoutes; - void takeStartingRoutes({bool withAccount = true}) { + void takeStartingRoutes({Account? account, bool withAccount = true}) { + account ??= eg.selfAccount; final expected = >[ if (withAccount) (it) => it.isA() - ..accountId.equals(eg.selfAccount.id) + ..accountId.equals(account!.id) ..page.isA() else (it) => it.isA().page.isA(), @@ -1003,6 +1037,21 @@ void main() { 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()); @@ -1079,11 +1128,12 @@ void main() { 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(); + 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. @@ -1096,6 +1146,36 @@ void main() { 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', () { @@ -1116,7 +1196,7 @@ void main() { payload = NotificationOpenPayload( realmUrl: Uri.parse('http://chat.example'), userId: 1001, - narrow: const TopicNarrow(1, 'topic A'), + narrow: eg.topicNarrow(1, 'topic A'), ); url = payload.buildUrl(); check(NotificationOpenPayload.parseUrl(url)) @@ -1146,7 +1226,7 @@ void main() { final url = NotificationOpenPayload( realmUrl: Uri.parse('http://chat.example'), userId: 1001, - narrow: const TopicNarrow(1, 'topic A'), + narrow: eg.topicNarrow(1, 'topic A'), ).buildUrl(); check(url) ..scheme.equals('zulip') @@ -1194,7 +1274,7 @@ void main() { ..userId.equals(1001) ..narrow.which((it) => it.isA() ..streamId.equals(1) - ..topic.equals('topic A')); + ..topic.equals(eg.t('topic A'))); }); test('parse: fails when missing any expected query parameters', () { diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index cf19bc3e9f..7da94cfd36 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -1,12 +1,14 @@ import 'dart:convert'; import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.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/events.dart'; +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/api/route/messages.dart'; @@ -106,27 +108,58 @@ void main() { connection.prepare(httpStatus: 400, json: fakeResponseJson); } - group('showTopicActionSheet', () { - final channel = eg.stream(); - const topic = 'my topic'; - final message = eg.streamMessage( - stream: channel, topic: topic, sender: eg.otherUser); + group('topic action sheet', () { + final someChannel = eg.stream(); + const someTopic = 'my topic'; + final someMessage = eg.streamMessage( + stream: someChannel, topic: someTopic, sender: eg.otherUser); + + Future prepare({ + ZulipStream? channel, + String topic = someTopic, + bool isChannelSubscribed = true, + bool? isChannelMuted, + UserTopicVisibilityPolicy? visibilityPolicy, + UnreadMessagesSnapshot? unreadMsgs, + int? zulipFeatureLevel, + }) async { + final effectiveChannel = channel ?? someChannel; + assert(isChannelSubscribed || isChannelMuted == null); - Future prepare() async { addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + await testBinding.globalStore.add(account, eg.initialSnapshot( realmUsers: [eg.selfUser, eg.otherUser], - streams: [channel], - subscriptions: [eg.subscription(channel)])); + streams: [effectiveChannel], + subscriptions: isChannelSubscribed + ? [eg.subscription(effectiveChannel, isMuted: isChannelMuted ?? false)] + : null, + userTopics: visibilityPolicy != null + ? [eg.userTopicItem(effectiveChannel, topic, visibilityPolicy)] + : null, + unreadMsgs: unreadMsgs, + zulipFeatureLevel: zulipFeatureLevel)); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); connection = store.connection as FakeApiConnection; - - await store.addMessage(message); } - testWidgets('show from inbox', (tester) async { - await prepare(); + Future showFromInbox(WidgetTester tester, { + String topic = someTopic, + }) async { + final channelIdsWithUnreads = store.unreads.streams.keys; + final hasTopicWithUnreads = channelIdsWithUnreads.any((streamId) => + store.unreads.countInTopicNarrow(streamId, TopicName(topic)) > 0); + if (!hasTopicWithUnreads) { + throw FlutterError.fromParts([ + ErrorSummary('showFromInbox called without an unread message'), + ErrorHint( + 'Before calling showFromInbox, ensure that [Unreads] ' + 'has an unread message in the relevant topic. ', + ), + ]); + } + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: const HomePage())); await tester.pump(); @@ -135,271 +168,497 @@ void main() { await tester.longPress(find.text(topic)); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); - check(find.byType(BottomSheet)).findsOne(); - check(find.text('Follow topic')).findsOne(); - }); + } + + Future showFromAppBar(WidgetTester tester, { + ZulipStream? channel, + String topic = someTopic, + List? messages, + }) async { + final effectiveChannel = channel ?? someChannel; + final effectiveMessages = messages ?? [someMessage]; + assert(effectiveMessages.every((m) => m.topic.apiName == topic)); - testWidgets('show from app bar', (tester) async { - await prepare(); connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [message]).toJson()); + foundOldest: true, messages: effectiveMessages).toJson()); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage( - initNarrow: TopicNarrow(channel.streamId, topic)))); + initNarrow: eg.topicNarrow(effectiveChannel.streamId, topic)))); // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); - await tester.longPress(find.byType(ZulipAppBar)); + final topicRow = find.descendant( + of: find.byType(ZulipAppBar), + matching: find.text(topic)); + await tester.longPress(topicRow); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); - check(find.byType(BottomSheet)).findsOne(); - check(find.text('Follow topic')).findsOne(); - }); + } + + Future showFromRecipientHeader(WidgetTester tester, { + StreamMessage? message, + }) async { + final effectiveMessage = message ?? someMessage; - testWidgets('show from recipient header', (tester) async { - await prepare(); connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [message]).toJson()); + foundOldest: true, messages: [effectiveMessage]).toJson()); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: const MessageListPage(initNarrow: CombinedFeedNarrow()))); // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); await tester.longPress(find.descendant( - of: find.byType(RecipientHeader), matching: find.text(topic))); + of: find.byType(RecipientHeader), + matching: find.text(effectiveMessage.topic.displayName))); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); - check(find.byType(BottomSheet)).findsOne(); - check(find.text('Follow topic')).findsOne(); - }); - }); + } - group('UserTopicUpdateButton', () { - late ZulipStream channel; - late String topic; - - final mute = find.text('Mute topic'); - final unmute = find.text('Unmute topic'); - final follow = find.text('Follow topic'); - final unfollow = find.text('Unfollow topic'); - - /// Prepare store and bring up a topic action sheet. - /// - /// If `isChannelMuted` is `null`, the user is not subscribed to the - /// channel. - Future setupToTopicActionSheet(WidgetTester tester, { - required bool? isChannelMuted, - required UserTopicVisibilityPolicy visibilityPolicy, - int? zulipFeatureLevel, - }) async { - addTearDown(testBinding.reset); + final actionSheetFinder = find.byType(BottomSheet); + Finder findButtonForLabel(String label) => + find.descendant(of: actionSheetFinder, matching: find.text(label)); - channel = eg.stream(); - topic = 'isChannelMuted: $isChannelMuted, policy: $visibilityPolicy'; + group('showTopicActionSheet', () { + void checkButtons() { + check(actionSheetFinder).findsOne(); - final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); - final subscriptions = isChannelMuted == null ? [] - : [eg.subscription(channel, isMuted: isChannelMuted)]; - await testBinding.globalStore.add(account, eg.initialSnapshot( - realmUsers: [eg.selfUser], - streams: [channel], - subscriptions: subscriptions, - userTopics: [eg.userTopicItem(channel, topic, visibilityPolicy)], - zulipFeatureLevel: zulipFeatureLevel)); - store = await testBinding.globalStore.perAccount(account.id); - connection = store.connection as FakeApiConnection; + void checkButton(String label) { + check(findButtonForLabel(label)).findsOne(); + } - connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [ - eg.streamMessage(stream: channel, topic: topic)]).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: account.id, - child: MessageListPage( - initNarrow: TopicNarrow(channel.streamId, topic)))); - await tester.pumpAndSettle(); + checkButton('Follow topic'); + checkButton('Mark as resolved'); + } - await tester.longPress(find.descendant( - of: find.byType(RecipientHeader), matching: find.text(topic))); - // sheet appears onscreen; default duration of bottom-sheet enter animation - await tester.pump(const Duration(milliseconds: 250)); - } + testWidgets('show from inbox; message in Unreads but not in MessageStore', (tester) async { + await prepare(unreadMsgs: eg.unreadMsgs(count: 1, + channels: [eg.unreadChannelMsgs( + streamId: someChannel.streamId, + topic: someTopic, + unreadMessageIds: [someMessage.id], + )])); + await showFromInbox(tester); + check(store.unreads.isUnread(someMessage.id)).isNotNull().isTrue(); + check(store.messages).not((it) => it.containsKey(someMessage.id)); + checkButtons(); + }); + + testWidgets('show from inbox; message in Unreads and in MessageStore', (tester) async { + await prepare(); + await store.addMessage(someMessage); + await showFromInbox(tester); + check(store.unreads.isUnread(someMessage.id)).isNotNull().isTrue(); + check(store.messages)[someMessage.id].isNotNull(); + checkButtons(); + }); - void checkButtons(List expectedButtonFinders) { - if (expectedButtonFinders.isEmpty) { - check(find.byType(BottomSheet)).findsNothing(); - return; + testWidgets('show from app bar', (tester) async { + await prepare(); + await showFromAppBar(tester); + checkButtons(); + }); + + testWidgets('show from app bar: resolve/unresolve not offered when msglist empty', (tester) async { + await prepare(); + await showFromAppBar(tester, messages: []); + check(findButtonForLabel('Mark as resolved')).findsNothing(); + check(findButtonForLabel('Mark as unresolved')).findsNothing(); + }); + + testWidgets('show from recipient header', (tester) async { + await prepare(); + await showFromRecipientHeader(tester); + checkButtons(); + }); + }); + + group('UserTopicUpdateButton', () { + late String topic; + + final mute = findButtonForLabel('Mute topic'); + final unmute = findButtonForLabel('Unmute topic'); + final follow = findButtonForLabel('Follow topic'); + final unfollow = findButtonForLabel('Unfollow topic'); + + /// Prepare store and bring up a topic action sheet. + /// + /// If `isChannelMuted` is `null`, the user is not subscribed to the + /// channel. + Future setupToTopicActionSheet(WidgetTester tester, { + required bool? isChannelMuted, + required UserTopicVisibilityPolicy visibilityPolicy, + int? zulipFeatureLevel, + }) async { + addTearDown(testBinding.reset); + + topic = 'isChannelMuted: $isChannelMuted, policy: $visibilityPolicy'; + await prepare( + channel: someChannel, + topic: topic, + isChannelSubscribed: isChannelMuted != null, // shorthand; see dartdoc + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy, + zulipFeatureLevel: zulipFeatureLevel, + ); + + final message = eg.streamMessage( + stream: someChannel, topic: topic, sender: eg.otherUser); + await showFromAppBar(tester, + channel: someChannel, topic: topic, messages: [message]); } - check(find.byType(BottomSheet)).findsOne(); - for (final buttonFinder in expectedButtonFinders) { - check(buttonFinder).findsOne(); + void checkButtons(List expectedButtonFinders) { + check(actionSheetFinder).findsOne(); + + for (final buttonFinder in expectedButtonFinders) { + check(buttonFinder).findsOne(); + } + check(find.bySubtype()) + .findsExactly(expectedButtonFinders.length); } - check(find.bySubtype()) - .findsExactly(expectedButtonFinders.length); - } - void checkUpdateUserTopicRequest(UserTopicVisibilityPolicy expectedPolicy) async { - check(connection.lastRequest).isA() - ..url.path.equals('/api/v1/user_topics') - ..bodyFields.deepEquals({ - 'stream_id': '${channel.streamId}', - 'topic': topic, - 'visibility_policy': jsonEncode(expectedPolicy), - }); - } + void checkUpdateUserTopicRequest(UserTopicVisibilityPolicy expectedPolicy) async { + check(connection.lastRequest).isA() + ..url.path.equals('/api/v1/user_topics') + ..bodyFields.deepEquals({ + 'stream_id': '${someChannel.streamId}', + 'topic': topic, + 'visibility_policy': jsonEncode(expectedPolicy), + }); + } - testWidgets('unmuteInMutedChannel', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: true, - visibilityPolicy: UserTopicVisibilityPolicy.none); - await tester.tap(unmute); - await tester.pump(); - checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.unmuted); - }); + testWidgets('unmuteInMutedChannel', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: true, + visibilityPolicy: UserTopicVisibilityPolicy.none); + await tester.tap(unmute); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.unmuted); + }); - testWidgets('unmute', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: false, - visibilityPolicy: UserTopicVisibilityPolicy.muted); - await tester.tap(unmute); - await tester.pump(); - checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.none); - }); + testWidgets('unmute', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.muted); + await tester.tap(unmute); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.none); + }); - testWidgets('mute', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: false, - visibilityPolicy: UserTopicVisibilityPolicy.none); - await tester.tap(mute); - await tester.pump(); - checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.muted); - }); + testWidgets('mute', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.none); + await tester.tap(mute); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.muted); + }); - testWidgets('follow', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: false, - visibilityPolicy: UserTopicVisibilityPolicy.none); - await tester.tap(follow); - await tester.pump(); - checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.followed); - }); + testWidgets('follow', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.none); + await tester.tap(follow); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.followed); + }); - testWidgets('unfollow', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: false, - visibilityPolicy: UserTopicVisibilityPolicy.followed); - await tester.tap(unfollow); - await tester.pump(); - checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.none); - }); + testWidgets('unfollow', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.followed); + await tester.tap(unfollow); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.none); + }); - testWidgets('request fails with an error dialog', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: false, - visibilityPolicy: UserTopicVisibilityPolicy.followed); + testWidgets('request fails with an error dialog', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.followed); - connection.prepare(httpStatus: 400, json: { - 'result': 'error', 'code': 'BAD_REQUEST', 'msg': ''}); - await tester.tap(unfollow); - await tester.pumpAndSettle(); + connection.prepare(httpStatus: 400, json: { + 'result': 'error', 'code': 'BAD_REQUEST', 'msg': ''}); + await tester.tap(unfollow); + await tester.pumpAndSettle(); + + checkErrorDialog(tester, expectedTitle: 'Failed to unfollow topic'); + }); + + group('check expected buttons', () { + final testCases = [ + (false, UserTopicVisibilityPolicy.muted, [unmute, follow]), + (false, UserTopicVisibilityPolicy.none, [mute, follow]), + (false, UserTopicVisibilityPolicy.unmuted, [mute, follow]), + (false, UserTopicVisibilityPolicy.followed, [mute, unfollow]), + + (true, UserTopicVisibilityPolicy.muted, [unmute, follow]), + (true, UserTopicVisibilityPolicy.none, [unmute, follow]), + (true, UserTopicVisibilityPolicy.unmuted, [mute, follow]), + (true, UserTopicVisibilityPolicy.followed, [mute, unfollow]), + + (null, UserTopicVisibilityPolicy.none, []), + ]; + + for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { + final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; + testWidgets(description, (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy); + checkButtons(buttons); + }); + } + }); + + group('legacy: follow is unsupported when FL < 219', () { + final testCases = [ + (false, UserTopicVisibilityPolicy.muted, [unmute]), + (false, UserTopicVisibilityPolicy.none, [mute]), + (false, UserTopicVisibilityPolicy.unmuted, [mute]), + (false, UserTopicVisibilityPolicy.followed, [mute]), + + (true, UserTopicVisibilityPolicy.muted, [unmute]), + (true, UserTopicVisibilityPolicy.none, [unmute]), + (true, UserTopicVisibilityPolicy.unmuted, [mute]), + (true, UserTopicVisibilityPolicy.followed, [mute]), + + (null, UserTopicVisibilityPolicy.none, []), + ]; + + for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { + final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; + testWidgets(description, (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy, + zulipFeatureLevel: 218); + checkButtons(buttons); + }); + } + }); - checkErrorDialog(tester, expectedTitle: 'Failed to unfollow topic'); + group('legacy: unmute is unsupported when FL < 170', () { + final testCases = [ + (false, UserTopicVisibilityPolicy.muted, [unmute]), + (false, UserTopicVisibilityPolicy.none, [mute]), + (false, UserTopicVisibilityPolicy.unmuted, [mute]), + (false, UserTopicVisibilityPolicy.followed, [mute]), + + (true, UserTopicVisibilityPolicy.muted, []), + (true, UserTopicVisibilityPolicy.none, []), + (true, UserTopicVisibilityPolicy.unmuted, []), + (true, UserTopicVisibilityPolicy.followed, []), + + (null, UserTopicVisibilityPolicy.none, []), + ]; + + for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { + final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; + testWidgets(description, (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy, + zulipFeatureLevel: 169); + checkButtons(buttons); + }); + } + }); }); - group('check expected buttons', () { - final testCases = [ - (false, UserTopicVisibilityPolicy.muted, [unmute, follow]), - (false, UserTopicVisibilityPolicy.none, [mute, follow]), - (false, UserTopicVisibilityPolicy.unmuted, [mute, follow]), - (false, UserTopicVisibilityPolicy.followed, [mute, unfollow]), - - (true, UserTopicVisibilityPolicy.muted, [unmute, follow]), - (true, UserTopicVisibilityPolicy.none, [unmute, follow]), - (true, UserTopicVisibilityPolicy.unmuted, [mute, follow]), - (true, UserTopicVisibilityPolicy.followed, [mute, unfollow]), - - (null, UserTopicVisibilityPolicy.none, []), - ]; - - for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { - final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; - testWidgets(description, (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: isChannelMuted, - visibilityPolicy: visibilityPolicy); - checkButtons(buttons); - }); + group('ResolveUnresolveButton', () { + void checkRequest(int messageId, String topic) { + check(connection.takeRequests()).single.isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/messages/$messageId') + ..bodyFields.deepEquals({ + 'topic': topic, + 'propagate_mode': 'change_all', + 'send_notification_to_old_thread': 'false', + 'send_notification_to_new_thread': 'true', + }); } + + testWidgets('resolve: happy path from inbox; message in Unreads but not MessageStore', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: 'zulip'); + await prepare( + topic: 'zulip', + unreadMsgs: eg.unreadMsgs(count: 1, + channels: [eg.unreadChannelMsgs( + streamId: someChannel.streamId, + topic: 'zulip', + unreadMessageIds: [message.id], + )])); + await showFromInbox(tester, topic: 'zulip'); + check(store.messages).not((it) => it.containsKey(message.id)); + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(findButtonForLabel('Mark as resolved')); + await tester.pumpAndSettle(); + + checkNoErrorDialog(tester); + checkRequest(message.id, '✔ zulip'); + }); + + testWidgets('resolve: happy path from inbox; message in Unreads and MessageStore', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: 'zulip'); + await prepare(topic: 'zulip'); + await store.addMessage(message); + await showFromInbox(tester, topic: 'zulip'); + check(store.unreads.isUnread(message.id)).isNotNull().isTrue(); + check(store.messages)[message.id].isNotNull(); + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(findButtonForLabel('Mark as resolved')); + await tester.pumpAndSettle(); + + checkNoErrorDialog(tester); + checkRequest(message.id, '✔ zulip'); + }); + + testWidgets('unresolve: happy path', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: '✔ zulip'); + await prepare(topic: '✔ zulip'); + await showFromAppBar(tester, topic: '✔ zulip', messages: [message]); + connection.takeRequests(); + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(findButtonForLabel('Mark as unresolved')); + await tester.pumpAndSettle(); + + checkNoErrorDialog(tester); + checkRequest(message.id, 'zulip'); + }); + + testWidgets('unresolve: weird prefix', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: '✔ ✔ zulip'); + await prepare(topic: '✔ ✔ zulip'); + await showFromAppBar(tester, topic: '✔ ✔ zulip', messages: [message]); + connection.takeRequests(); + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(findButtonForLabel('Mark as unresolved')); + await tester.pumpAndSettle(); + + checkNoErrorDialog(tester); + checkRequest(message.id, 'zulip'); + }); + + testWidgets('resolve: request fails', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: 'zulip'); + await prepare(topic: 'zulip'); + await showFromRecipientHeader(tester, message: message); + connection.takeRequests(); + connection.prepare(exception: http.ClientException('Oops')); + await tester.tap(findButtonForLabel('Mark as resolved')); + await tester.pumpAndSettle(); + checkRequest(message.id, '✔ zulip'); + + checkErrorDialog(tester, + expectedTitle: 'Failed to mark topic as resolved'); + }); + + testWidgets('unresolve: request fails', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: '✔ zulip'); + await prepare(topic: '✔ zulip'); + await showFromRecipientHeader(tester, message: message); + connection.takeRequests(); + connection.prepare(exception: http.ClientException('Oops')); + await tester.tap(findButtonForLabel('Mark as unresolved')); + await tester.pumpAndSettle(); + checkRequest(message.id, 'zulip'); + + checkErrorDialog(tester, + expectedTitle: 'Failed to mark topic as unresolved'); + }); }); + }); - group('legacy: follow is unsupported when FL < 219', () { - final testCases = [ - (false, UserTopicVisibilityPolicy.muted, [unmute]), - (false, UserTopicVisibilityPolicy.none, [mute]), - (false, UserTopicVisibilityPolicy.unmuted, [mute]), - (false, UserTopicVisibilityPolicy.followed, [mute]), - - (true, UserTopicVisibilityPolicy.muted, [unmute]), - (true, UserTopicVisibilityPolicy.none, [unmute]), - (true, UserTopicVisibilityPolicy.unmuted, [mute]), - (true, UserTopicVisibilityPolicy.followed, [mute]), - - (null, UserTopicVisibilityPolicy.none, []), - ]; - - for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { - final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; - testWidgets(description, (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: isChannelMuted, - visibilityPolicy: visibilityPolicy, - zulipFeatureLevel: 218); - checkButtons(buttons); + group('message action sheet', () { + group('ReactionButtons', () { + final popularCandidates = EmojiStore.popularEmojiCandidates; + + for (final emoji in popularCandidates) { + final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; + + Future tapButton(WidgetTester tester) async { + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: find.text(emojiDisplay.emojiUnicode))); + } + + testWidgets('${emoji.emojiName} adding success', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..bodyFields.deepEquals({ + 'reaction_type': 'unicode_emoji', + 'emoji_code': emoji.emojiCode, + 'emoji_name': emoji.emojiName, + }); + }); + + testWidgets('${emoji.emojiName} removing success', (tester) async { + final message = eg.streamMessage( + reactions: [Reaction( + emojiName: emoji.emojiName, + emojiCode: emoji.emojiCode, + reactionType: ReactionType.unicodeEmoji, + userId: eg.selfAccount.userId)] + ); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..bodyFields.deepEquals({ + 'reaction_type': 'unicode_emoji', + 'emoji_code': emoji.emojiCode, + 'emoji_name': emoji.emojiName, + }); }); - } - }); - group('legacy: unmute is unsupported when FL < 170', () { - final testCases = [ - (false, UserTopicVisibilityPolicy.muted, [unmute]), - (false, UserTopicVisibilityPolicy.none, [mute]), - (false, UserTopicVisibilityPolicy.unmuted, [mute]), - (false, UserTopicVisibilityPolicy.followed, [mute]), - - (true, UserTopicVisibilityPolicy.muted, []), - (true, UserTopicVisibilityPolicy.none, []), - (true, UserTopicVisibilityPolicy.unmuted, []), - (true, UserTopicVisibilityPolicy.followed, []), - - (null, UserTopicVisibilityPolicy.none, []), - ]; - - for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { - final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; - testWidgets(description, (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: isChannelMuted, - visibilityPolicy: visibilityPolicy, - zulipFeatureLevel: 169); - checkButtons(buttons); + testWidgets('${emoji.emojiName} request has an error', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(httpStatus: 400, json: { + 'code': 'BAD_REQUEST', + 'msg': 'Invalid message(s)', + 'result': 'error', + }); + await tapButton(tester); + await tester.pump(Duration.zero); // error arrives; error dialog shows + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Adding reaction failed', + expectedMessage: 'Invalid message(s)'))); }); } }); - }); - - group('ReactionButtons', () { - final popularCandidates = EmojiStore.popularEmojiCandidates; - for (final emoji in popularCandidates) { - final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; - - Future tapButton(WidgetTester tester) async { + group('StarButton', () { + Future tapButton(WidgetTester tester, {bool starred = false}) async { + // Starred messages include the same icon so we need to + // match only by descendants of [BottomSheet]. + await tester.ensureVisible(find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(starred ? ZulipIcons.star_filled : ZulipIcons.star, skipOffstage: false))); await tester.tap(find.descendant( of: find.byType(BottomSheet), - matching: find.text(emojiDisplay.emojiUnicode))); + matching: find.byIcon(starred ? ZulipIcons.star_filled : ZulipIcons.star))); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e } - testWidgets('${emoji.emojiName} adding success', (tester) async { - final message = eg.streamMessage(); + testWidgets('star success', (tester) async { + final message = eg.streamMessage(flags: []); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); connection.prepare(json: {}); @@ -408,41 +667,36 @@ void main() { check(connection.lastRequest).isA() ..method.equals('POST') - ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..url.path.equals('/api/v1/messages/flags') ..bodyFields.deepEquals({ - 'reaction_type': 'unicode_emoji', - 'emoji_code': emoji.emojiCode, - 'emoji_name': emoji.emojiName, - }); + 'messages': jsonEncode([message.id]), + 'op': 'add', + 'flag': 'starred', + }); }); - testWidgets('${emoji.emojiName} removing success', (tester) async { - final message = eg.streamMessage( - reactions: [Reaction( - emojiName: emoji.emojiName, - emojiCode: emoji.emojiCode, - reactionType: ReactionType.unicodeEmoji, - userId: eg.selfAccount.userId)] - ); + testWidgets('unstar success', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.starred]); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); connection.prepare(json: {}); - await tapButton(tester); + await tapButton(tester, starred: true); await tester.pump(Duration.zero); check(connection.lastRequest).isA() - ..method.equals('DELETE') - ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags') ..bodyFields.deepEquals({ - 'reaction_type': 'unicode_emoji', - 'emoji_code': emoji.emojiCode, - 'emoji_name': emoji.emojiName, - }); + 'messages': jsonEncode([message.id]), + 'op': 'remove', + 'flag': 'starred', + }); }); - testWidgets('${emoji.emojiName} request has an error', (tester) async { - final message = eg.streamMessage(); + testWidgets('star request has an error', (tester) async { + final message = eg.streamMessage(flags: []); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; connection.prepare(httpStatus: 400, json: { 'code': 'BAD_REQUEST', @@ -453,180 +707,94 @@ void main() { await tester.pump(Duration.zero); // error arrives; error dialog shows await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Adding reaction failed', + expectedTitle: zulipLocalizations.errorStarMessageFailedTitle, expectedMessage: 'Invalid message(s)'))); }); - } - }); - group('StarButton', () { - Future tapButton(WidgetTester tester, {bool starred = false}) async { - // Starred messages include the same icon so we need to - // match only by descendants of [BottomSheet]. - await tester.ensureVisible(find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(starred ? ZulipIcons.star_filled : ZulipIcons.star, skipOffstage: false))); - await tester.tap(find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(starred ? ZulipIcons.star_filled : ZulipIcons.star))); - await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e - } - - testWidgets('star success', (tester) async { - final message = eg.streamMessage(flags: []); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - connection.prepare(json: {}); - await tapButton(tester); - await tester.pump(Duration.zero); - - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags') - ..bodyFields.deepEquals({ - 'messages': jsonEncode([message.id]), - 'op': 'add', - 'flag': 'starred', - }); - }); + testWidgets('unstar request has an error', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.starred]); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - testWidgets('unstar success', (tester) async { - final message = eg.streamMessage(flags: [MessageFlag.starred]); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - connection.prepare(json: {}); - await tapButton(tester, starred: true); - await tester.pump(Duration.zero); - - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags') - ..bodyFields.deepEquals({ - 'messages': jsonEncode([message.id]), - 'op': 'remove', - 'flag': 'starred', + connection.prepare(httpStatus: 400, json: { + 'code': 'BAD_REQUEST', + 'msg': 'Invalid message(s)', + 'result': 'error', }); - }); - - testWidgets('star request has an error', (tester) async { - final message = eg.streamMessage(flags: []); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - - connection.prepare(httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }); - await tapButton(tester); - await tester.pump(Duration.zero); // error arrives; error dialog shows - - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorStarMessageFailedTitle, - expectedMessage: 'Invalid message(s)'))); - }); - - testWidgets('unstar request has an error', (tester) async { - final message = eg.streamMessage(flags: [MessageFlag.starred]); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + await tapButton(tester, starred: true); + await tester.pump(Duration.zero); // error arrives; error dialog shows - connection.prepare(httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorUnstarMessageFailedTitle, + expectedMessage: 'Invalid message(s)'))); }); - await tapButton(tester, starred: true); - await tester.pump(Duration.zero); // error arrives; error dialog shows - - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorUnstarMessageFailedTitle, - expectedMessage: 'Invalid message(s)'))); }); - }); - - group('QuoteAndReplyButton', () { - ComposeBoxController? findComposeBoxController(WidgetTester tester) { - return tester.stateList(find.byType(ComposeBox)) - .singleOrNull?.controller; - } - Widget? findQuoteAndReplyButton(WidgetTester tester) { - return tester.widgetList(find.byIcon(ZulipIcons.format_quote)).singleOrNull; - } + group('QuoteAndReplyButton', () { + ComposeBoxController? findComposeBoxController(WidgetTester tester) { + return tester.stateList(find.byType(ComposeBox)) + .singleOrNull?.controller; + } - /// Simulates tapping the quote-and-reply button in the message action sheet. - /// - /// Checks that there is a quote-and-reply button. - Future tapQuoteAndReplyButton(WidgetTester tester) async { - await tester.ensureVisible(find.byIcon(ZulipIcons.format_quote, skipOffstage: false)); - final quoteAndReplyButton = findQuoteAndReplyButton(tester); - check(quoteAndReplyButton).isNotNull(); - TypingNotifier.debugEnable = false; - addTearDown(TypingNotifier.debugReset); - await tester.tap(find.byWidget(quoteAndReplyButton!)); - await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e - } + Widget? findQuoteAndReplyButton(WidgetTester tester) { + return tester.widgetList(find.byIcon(ZulipIcons.format_quote)).singleOrNull; + } - void checkLoadingState(PerAccountStore store, ComposeContentController contentController, { - required TextEditingValue valueBefore, - required Message message, - }) { - check(contentController).value.equals((ComposeContentController() - ..value = valueBefore - ..insertPadded(quoteAndReplyPlaceholder(store, message: message)) - ).value); - check(contentController).validationErrors.contains(ContentValidationError.quoteAndReplyInProgress); - } + /// Simulates tapping the quote-and-reply button in the message action sheet. + /// + /// Checks that there is a quote-and-reply button. + Future tapQuoteAndReplyButton(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(ZulipIcons.format_quote, skipOffstage: false)); + final quoteAndReplyButton = findQuoteAndReplyButton(tester); + check(quoteAndReplyButton).isNotNull(); + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + await tester.tap(find.byWidget(quoteAndReplyButton!)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } - void checkSuccessState(PerAccountStore store, ComposeContentController contentController, { - required TextEditingValue valueBefore, - required Message message, - required String rawContent, - }) { - final builder = ComposeContentController() - ..value = valueBefore - ..insertPadded(quoteAndReply(store, message: message, rawContent: rawContent)); - if (!valueBefore.selection.isValid) { - // (At the end of the process, we focus the input, which puts a cursor - // at text's end, if there was no cursor at the time.) - builder.selection = TextSelection.collapsed(offset: builder.text.length); + void checkLoadingState(PerAccountStore store, ComposeContentController contentController, { + required TextEditingValue valueBefore, + required Message message, + }) { + check(contentController).value.equals((ComposeContentController() + ..value = valueBefore + ..insertPadded(quoteAndReplyPlaceholder( + GlobalLocalizations.zulipLocalizations, store, message: message)) + ).value); + check(contentController).validationErrors.contains(ContentValidationError.quoteAndReplyInProgress); } - check(contentController).value.equals(builder.value); - check(contentController).not((it) => it.validationErrors.contains(ContentValidationError.quoteAndReplyInProgress)); - } - testWidgets('in channel narrow', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: ChannelNarrow(message.streamId)); - - final composeBoxController = findComposeBoxController(tester) as StreamComposeBoxController; - final contentController = composeBoxController.content; - - // Ensure channel-topics are loaded before testing quote & reply behavior - connection.prepare(body: - jsonEncode(GetStreamTopicsResult(topics: [eg.getStreamTopicsEntry()]).toJson())); - final topicController = composeBoxController.topic; - topicController.value = const TextEditingValue(text: kNoTopicTopic); - - final valueBefore = contentController.value; - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); - await tapQuoteAndReplyButton(tester); - checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); - await tester.pump(Duration.zero); // message is fetched; compose box updates - check(composeBoxController.contentFocusNode.hasFocus).isTrue(); - checkSuccessState(store, contentController, - valueBefore: valueBefore, message: message, rawContent: 'Hello world'); - }); + void checkSuccessState(PerAccountStore store, ComposeContentController contentController, { + required TextEditingValue valueBefore, + required Message message, + required String rawContent, + }) { + final builder = ComposeContentController() + ..value = valueBefore + ..insertPadded(quoteAndReply(store, message: message, rawContent: rawContent)); + if (!valueBefore.selection.isValid) { + // (At the end of the process, we focus the input, which puts a cursor + // at text's end, if there was no cursor at the time.) + builder.selection = TextSelection.collapsed(offset: builder.text.length); + } + check(contentController).value.equals(builder.value); + check(contentController).not((it) => it.validationErrors.contains(ContentValidationError.quoteAndReplyInProgress)); + } - group('in topic narrow', () { - testWidgets('smoke', (tester) async { + testWidgets('in channel narrow', (tester) async { final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + await setupToMessageActionSheet(tester, message: message, narrow: ChannelNarrow(message.streamId)); - final composeBoxController = findComposeBoxController(tester)!; + final composeBoxController = findComposeBoxController(tester) as StreamComposeBoxController; final contentController = composeBoxController.content; + // Ensure channel-topics are loaded before testing quote & reply behavior + connection.prepare(body: + jsonEncode(GetStreamTopicsResult(topics: [eg.getStreamTopicsEntry()]).toJson())); + final topicController = composeBoxController.topic; + topicController.value = const TextEditingValue(text: kNoTopicTopic); + final valueBefore = contentController.value; prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); await tapQuoteAndReplyButton(tester); @@ -637,383 +805,402 @@ void main() { valueBefore: valueBefore, message: message, rawContent: 'Hello world'); }); - testWidgets('no error if user lost posting permission after action sheet opened', (tester) async { - final stream = eg.stream(); - final message = eg.streamMessage(stream: stream); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + group('in topic narrow', () { + testWidgets('smoke', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + final composeBoxController = findComposeBoxController(tester)!; + final contentController = composeBoxController.content; + + final valueBefore = contentController.value; + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + await tester.pump(Duration.zero); // message is fetched; compose box updates + check(composeBoxController.contentFocusNode.hasFocus).isTrue(); + checkSuccessState(store, contentController, + valueBefore: valueBefore, message: message, rawContent: 'Hello world'); + }); - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, - role: UserRole.guest)); - await store.handleEvent(eg.channelUpdateEvent(stream, - property: ChannelPropertyName.channelPostPolicy, - value: ChannelPostPolicy.administrators)); - await tester.pump(); + testWidgets('no error if user lost posting permission after action sheet opened', (tester) async { + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - await tapQuoteAndReplyButton(tester); - // no error + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, + role: UserRole.guest)); + await store.handleEvent(eg.channelUpdateEvent(stream, + property: ChannelPropertyName.channelPostPolicy, + value: ChannelPostPolicy.administrators)); + await tester.pump(); + + await tapQuoteAndReplyButton(tester); + // no error + }); }); - }); - group('in DM narrow', () { - testWidgets('smoke', (tester) async { - final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); - await setupToMessageActionSheet(tester, - message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + group('in DM narrow', () { + testWidgets('smoke', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + await setupToMessageActionSheet(tester, + message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + + final composeBoxController = findComposeBoxController(tester)!; + final contentController = composeBoxController.content; + + final valueBefore = contentController.value; + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + await tester.pump(Duration.zero); // message is fetched; compose box updates + check(composeBoxController.contentFocusNode.hasFocus).isTrue(); + checkSuccessState(store, contentController, + valueBefore: valueBefore, message: message, rawContent: 'Hello world'); + }); + + 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]); + await setupToMessageActionSheet(tester, + message: message, + narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + + prepareRawContentResponseSuccess( + message: message, + rawContent: 'Hello world', + delay: const Duration(seconds: 5), + ); + await tapQuoteAndReplyButton(tester); + await tester.pump(const Duration(seconds: 1)); // message not yet fetched + + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.otherUser.userId, + isActive: false)); + await tester.pump(); + // no error + await tester.pump(const Duration(seconds: 4)); + }); + }); + + testWidgets('request has an error', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); final composeBoxController = findComposeBoxController(tester)!; final contentController = composeBoxController.content; - final valueBefore = contentController.value; - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + final valueBefore = contentController.value = TextEditingValue.empty; + prepareRawContentResponseError(); await tapQuoteAndReplyButton(tester); checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); - await tester.pump(Duration.zero); // message is fetched; compose box updates - check(composeBoxController.contentFocusNode.hasFocus).isTrue(); - checkSuccessState(store, contentController, - valueBefore: valueBefore, message: message, rawContent: 'Hello world'); + await tester.pump(Duration.zero); // error arrives; error dialog shows + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Quotation failed', + expectedMessage: 'That message does not seem to exist.', + ))); + + check(contentController.value).equals(const TextEditingValue( + // The placeholder was removed. (A newline from the placeholder's + // insertPadded remains; I guess ideally we'd try to prevent that.) + text: '\n', + + // (At the end of the process, we focus the input.) + selection: TextSelection.collapsed(offset: 1), // + )); }); - 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]); - await setupToMessageActionSheet(tester, - message: message, - narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + testWidgets('not offered in CombinedFeedNarrow (composing to reply is not yet supported)', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: const CombinedFeedNarrow()); + check(findQuoteAndReplyButton(tester)).isNull(); + }); - prepareRawContentResponseSuccess( - message: message, - rawContent: 'Hello world', - delay: const Duration(seconds: 5), - ); - await tapQuoteAndReplyButton(tester); - await tester.pump(const Duration(seconds: 1)); // message not yet fetched + testWidgets('not offered in MentionsNarrow (composing to reply is not yet supported)', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.mentioned]); + await setupToMessageActionSheet(tester, message: message, narrow: const MentionsNarrow()); + check(findQuoteAndReplyButton(tester)).isNull(); + }); - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.otherUser.userId, - isActive: false)); - await tester.pump(); - // no error - await tester.pump(const Duration(seconds: 4)); + testWidgets('not offered in StarredMessagesNarrow (composing to reply is not yet supported)', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.starred]); + await setupToMessageActionSheet(tester, message: message, narrow: const StarredMessagesNarrow()); + check(findQuoteAndReplyButton(tester)).isNull(); }); }); - testWidgets('request has an error', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - final composeBoxController = findComposeBoxController(tester)!; - final contentController = composeBoxController.content; - - final valueBefore = contentController.value = TextEditingValue.empty; - prepareRawContentResponseError(); - await tapQuoteAndReplyButton(tester); - checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); - await tester.pump(Duration.zero); // error arrives; error dialog shows + group('MarkAsUnread', () { + testWidgets('not visible if message is not read', (tester) async { + final unreadMessage = eg.streamMessage(flags: []); + await setupToMessageActionSheet(tester, message: unreadMessage, narrow: TopicNarrow.ofMessage(unreadMessage)); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Quotation failed', - expectedMessage: 'That message does not seem to exist.', - ))); + check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).isEmpty(); + }); - check(contentController.value).equals(const TextEditingValue( - // The placeholder was removed. (A newline from the placeholder's - // insertPadded remains; I guess ideally we'd try to prevent that.) - text: '\n', + testWidgets('visible if message is read', (tester) async { + final readMessage = eg.streamMessage(flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, message: readMessage, narrow: TopicNarrow.ofMessage(readMessage)); - // (At the end of the process, we focus the input.) - selection: TextSelection.collapsed(offset: 1), // - )); - }); + check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).single; + }); - testWidgets('not offered in CombinedFeedNarrow (composing to reply is not yet supported)', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: const CombinedFeedNarrow()); - check(findQuoteAndReplyButton(tester)).isNull(); - }); + group('onPressed', () { + testWidgets('smoke test', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: 1, lastProcessedId: 1980, + foundOldest: true, foundNewest: true).toJson()); + + await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + await tester.pumpAndSettle(); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': '${message.id}', + 'include_anchor': 'true', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(TopicNarrow.ofMessage(message).apiEncode()), + 'op': 'remove', + 'flag': 'read', + }); + }); - testWidgets('not offered in MentionsNarrow (composing to reply is not yet supported)', (tester) async { - final message = eg.streamMessage(flags: [MessageFlag.mentioned]); - await setupToMessageActionSheet(tester, message: message, narrow: const MentionsNarrow()); - check(findQuoteAndReplyButton(tester)).isNull(); - }); + testWidgets('on topic move, acts on new topic', (tester) async { + final stream = eg.stream(); + const topic = 'old topic'; + final message = eg.streamMessage(flags: [MessageFlag.read], + stream: stream, topic: topic); + await setupToMessageActionSheet(tester, message: message, + narrow: TopicNarrow.ofMessage(message)); + + // Get the action sheet fully deployed while the old narrow applies. + // (This way we maximize the range of potential bugs this test can catch, + // by giving the code maximum opportunity to latch onto the old topic.) + await tester.pumpAndSettle(); + + final newStream = eg.stream(); + const newTopic = 'other topic'; + // This result isn't quite realistic for this request: it should get + // the updated channel/stream ID and topic, because we don't even + // start the request until after we get the move event. + // But constructing the right result is annoying at the moment, and + // it doesn't matter anyway: [MessageStoreImpl.reconcileMessages] will + // keep the version updated by the event. If that somehow changes in + // some future refactor, it'll cause this test to fail. + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await store.handleEvent(eg.updateMessageEventMoveFrom( + newStreamId: newStream.streamId, newTopicStr: newTopic, + propagateMode: PropagateMode.changeAll, + origMessages: [message])); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: 1, lastProcessedId: 1980, + foundOldest: true, foundNewest: true).toJson()); + await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + await tester.pumpAndSettle(); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields['narrow'].equals( + jsonEncode(eg.topicNarrow(newStream.streamId, newTopic).apiEncode())); + }); - testWidgets('not offered in StarredMessagesNarrow (composing to reply is not yet supported)', (tester) async { - final message = eg.streamMessage(flags: [MessageFlag.starred]); - await setupToMessageActionSheet(tester, message: message, narrow: const StarredMessagesNarrow()); - check(findQuoteAndReplyButton(tester)).isNull(); - }); - }); + testWidgets('shows error when fails', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - group('MarkAsUnread', () { - testWidgets('not visible if message is not read', (tester) async { - final unreadMessage = eg.streamMessage(flags: []); - await setupToMessageActionSheet(tester, message: unreadMessage, narrow: TopicNarrow.ofMessage(unreadMessage)); + connection.prepare(exception: http.ClientException('Oops')); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).isEmpty(); + await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + await tester.pumpAndSettle(); + checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorMarkAsUnreadFailedTitle, + expectedMessage: 'NetworkException: Oops (ClientException: Oops)'); + }); + }); }); - testWidgets('visible if message is read', (tester) async { - final readMessage = eg.streamMessage(flags: [MessageFlag.read]); - await setupToMessageActionSheet(tester, message: readMessage, narrow: TopicNarrow.ofMessage(readMessage)); + group('CopyMessageTextButton', () { + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + MockClipboard().handleMethodCall, + ); + }); - check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).single; - }); + Future tapCopyMessageTextButton(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(ZulipIcons.copy, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.copy)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } - group('onPressed', () { - testWidgets('smoke test', (tester) async { - final message = eg.streamMessage(flags: [MessageFlag.read]); + testWidgets('success', (tester) async { + final message = eg.streamMessage(); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: 1, lastProcessedId: 1980, - foundOldest: true, foundNewest: true).toJson()); - - await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); - await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); - await tester.pumpAndSettle(); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': '${message.id}', - 'include_anchor': 'true', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(TopicNarrow.ofMessage(message).apiEncode()), - 'op': 'remove', - 'flag': 'read', - }); - }); - - testWidgets('on topic move, acts on new topic', (tester) async { - final stream = eg.stream(); - const topic = 'old topic'; - final message = eg.streamMessage(flags: [MessageFlag.read], - stream: stream, topic: topic); - await setupToMessageActionSheet(tester, message: message, - narrow: TopicNarrow.ofMessage(message)); - - // Get the action sheet fully deployed while the old narrow applies. - // (This way we maximize the range of potential bugs this test can catch, - // by giving the code maximum opportunity to latch onto the old topic.) - await tester.pumpAndSettle(); - - final newStream = eg.stream(); - const newTopic = 'other topic'; - // This result isn't quite realistic for this request: it should get - // the updated channel/stream ID and topic, because we don't even - // start the request until after we get the move event. - // But constructing the right result is annoying at the moment, and - // it doesn't matter anyway: [MessageStoreImpl.reconcileMessages] will - // keep the version updated by the event. If that somehow changes in - // some future refactor, it'll cause this test to fail. - connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [message]).toJson()); - await store.handleEvent(eg.updateMessageEventMoveFrom( - newStreamId: newStream.streamId, newTopic: newTopic, - propagateMode: PropagateMode.changeAll, - origMessages: [message])); - - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: 1, lastProcessedId: 1980, - foundOldest: true, foundNewest: true).toJson()); - await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); - await tester.pumpAndSettle(); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields['narrow'].equals( - jsonEncode(TopicNarrow(newStream.streamId, newTopic).apiEncode())); + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapCopyMessageTextButton(tester); + await tester.pump(Duration.zero); + check(await Clipboard.getData('text/plain')).isNotNull().text.equals('Hello world'); }); - testWidgets('shows error when fails', (tester) async { - final message = eg.streamMessage(flags: [MessageFlag.read]); + testWidgets('can show snackbar on success', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/732 + testBinding.deviceInfoResult = const IosDeviceInfo(systemVersion: '16.0'); + + final message = eg.streamMessage(); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - connection.prepare(exception: http.ClientException('Oops')); + // Make the request take a bit of time to complete… + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world', + delay: const Duration(milliseconds: 500)); + await tapCopyMessageTextButton(tester); + // … and pump a frame to finish the NavigationState.pop animation… + await tester.pump(const Duration(milliseconds: 250)); + // … before the request finishes. This is the repro condition for #732. + await tester.pump(const Duration(milliseconds: 250)); + + final snackbar = tester.widget(find.byType(SnackBar)); + check(snackbar.behavior).equals(SnackBarBehavior.floating); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - - await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); - await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); - await tester.pumpAndSettle(); - checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorMarkAsUnreadFailedTitle, - expectedMessage: 'NetworkException: Oops (ClientException: Oops)'); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(snackbar.content), + matching: find.text(zulipLocalizations.successMessageTextCopied))); }); - }); - }); - - group('CopyMessageTextButton', () { - setUp(() async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - MockClipboard().handleMethodCall, - ); - }); - - Future tapCopyMessageTextButton(WidgetTester tester) async { - await tester.ensureVisible(find.byIcon(ZulipIcons.copy, skipOffstage: false)); - await tester.tap(find.byIcon(ZulipIcons.copy)); - await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e - } - - testWidgets('success', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); - await tapCopyMessageTextButton(tester); - await tester.pump(Duration.zero); - check(await Clipboard.getData('text/plain')).isNotNull().text.equals('Hello world'); - }); - - testWidgets('can show snackbar on success', (tester) async { - // Regression test for: https://github.com/zulip/zulip-flutter/issues/732 - testBinding.deviceInfoResult = const IosDeviceInfo(systemVersion: '16.0'); - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - // Make the request take a bit of time to complete… - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world', - delay: const Duration(milliseconds: 500)); - await tapCopyMessageTextButton(tester); - // … and pump a frame to finish the NavigationState.pop animation… - await tester.pump(const Duration(milliseconds: 250)); - // … before the request finishes. This is the repro condition for #732. - await tester.pump(const Duration(milliseconds: 250)); - - final snackbar = tester.widget(find.byType(SnackBar)); - check(snackbar.behavior).equals(SnackBarBehavior.floating); - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(snackbar.content), - matching: find.text(zulipLocalizations.successMessageTextCopied))); - }); - - testWidgets('request has an error', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + testWidgets('request has an error', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - prepareRawContentResponseError(); - await tapCopyMessageTextButton(tester); - await tester.pump(Duration.zero); // error arrives; error dialog shows + prepareRawContentResponseError(); + await tapCopyMessageTextButton(tester); + await tester.pump(Duration.zero); // error arrives; error dialog shows - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Copying failed', - expectedMessage: 'That message does not seem to exist.', - ))); - check(await Clipboard.getData('text/plain')).isNull(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Copying failed', + expectedMessage: 'That message does not seem to exist.', + ))); + check(await Clipboard.getData('text/plain')).isNull(); + }); }); - }); - group('CopyMessageLinkButton', () { - setUp(() async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - MockClipboard().handleMethodCall, - ); - }); + group('CopyMessageLinkButton', () { + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + MockClipboard().handleMethodCall, + ); + }); - Future tapCopyMessageLinkButton(WidgetTester tester) async { - await tester.ensureVisible(find.byIcon(Icons.link, skipOffstage: false)); - await tester.tap(find.byIcon(Icons.link)); - await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e - } + Future tapCopyMessageLinkButton(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(Icons.link, skipOffstage: false)); + await tester.tap(find.byIcon(Icons.link)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } - testWidgets('copies message link to clipboard', (tester) async { - final message = eg.streamMessage(); - final narrow = TopicNarrow.ofMessage(message); - await setupToMessageActionSheet(tester, message: message, narrow: narrow); + testWidgets('copies message link to clipboard', (tester) async { + final message = eg.streamMessage(); + final narrow = TopicNarrow.ofMessage(message); + await setupToMessageActionSheet(tester, message: message, narrow: narrow); - await tapCopyMessageLinkButton(tester); - await tester.pump(Duration.zero); - final expectedLink = narrowLink(store, narrow, nearMessageId: message.id).toString(); - check(await Clipboard.getData('text/plain')).isNotNull().text.equals(expectedLink); + await tapCopyMessageLinkButton(tester); + await tester.pump(Duration.zero); + final expectedLink = narrowLink(store, narrow, nearMessageId: message.id).toString(); + check(await Clipboard.getData('text/plain')).isNotNull().text.equals(expectedLink); + }); }); - }); - group('ShareButton', () { - // Tests should call this. - MockSharePlus setupMockSharePlus() { - final mock = MockSharePlus(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - MethodChannelShare.channel, - mock.handleMethodCall, - ); - return mock; - } + group('ShareButton', () { + // Tests should call this. + MockSharePlus setupMockSharePlus() { + final mock = MockSharePlus(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannelShare.channel, + mock.handleMethodCall, + ); + return mock; + } - Future tapShareButton(WidgetTester tester) async { - await tester.ensureVisible(find.byIcon(ZulipIcons.share, skipOffstage: false)); - await tester.tap(find.byIcon(ZulipIcons.share)); - await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e - } + Future tapShareButton(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(ZulipIcons.share, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.share)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } - testWidgets('request succeeds; sharing succeeds', (tester) async { - final mockSharePlus = setupMockSharePlus(); - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + testWidgets('request succeeds; sharing succeeds', (tester) async { + final mockSharePlus = setupMockSharePlus(); + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); - await tapShareButton(tester); - await tester.pump(Duration.zero); - check(mockSharePlus.sharedString).equals('Hello world'); - }); + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapShareButton(tester); + await tester.pump(Duration.zero); + check(mockSharePlus.sharedString).equals('Hello world'); + }); - testWidgets('request succeeds; sharing fails', (tester) async { - final mockSharePlus = setupMockSharePlus(); - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + testWidgets('request succeeds; sharing fails', (tester) async { + final mockSharePlus = setupMockSharePlus(); + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); - mockSharePlus.resultString = 'dev.fluttercommunity.plus/share/unavailable'; - await tapShareButton(tester); - await tester.pump(Duration.zero); - check(mockSharePlus.sharedString).equals('Hello world'); - await tester.pump(); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Sharing failed'))); - }); + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + mockSharePlus.resultString = 'dev.fluttercommunity.plus/share/unavailable'; + await tapShareButton(tester); + await tester.pump(Duration.zero); + check(mockSharePlus.sharedString).equals('Hello world'); + await tester.pump(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Sharing failed'))); + }); - testWidgets('request has an error', (tester) async { - final mockSharePlus = setupMockSharePlus(); - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + testWidgets('request has an error', (tester) async { + final mockSharePlus = setupMockSharePlus(); + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - prepareRawContentResponseError(); - await tapShareButton(tester); - await tester.pump(Duration.zero); // error arrives; error dialog shows + prepareRawContentResponseError(); + await tapShareButton(tester); + await tester.pump(Duration.zero); // error arrives; error dialog shows - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Sharing failed', - expectedMessage: 'That message does not seem to exist.', - ))); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Sharing failed', + expectedMessage: 'That message does not seem to exist.', + ))); - check(mockSharePlus.sharedString).isNull(); + check(mockSharePlus.sharedString).isNull(); + }); }); - }); - group('MessageActionSheetCancelButton', () { - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + group('MessageActionSheetCancelButton', () { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - void checkActionSheet(WidgetTester tester, {required bool isShown}) { - check(find.text(zulipLocalizations.actionSheetOptionStarMessage) - .evaluate().length).equals(isShown ? 1 : 0); + void checkActionSheet(WidgetTester tester, {required bool isShown}) { + check(find.text(zulipLocalizations.actionSheetOptionStarMessage) + .evaluate().length).equals(isShown ? 1 : 0); - final findCancelButton = find.text(zulipLocalizations.dialogCancel); - check(findCancelButton.evaluate().length).equals(isShown ? 1 : 0); - } + final findCancelButton = find.text(zulipLocalizations.dialogCancel); + check(findCancelButton.evaluate().length).equals(isShown ? 1 : 0); + } - testWidgets('pressing the button dismisses the action sheet', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - checkActionSheet(tester, isShown: true); + testWidgets('pressing the button dismisses the action sheet', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + checkActionSheet(tester, isShown: true); - final findCancelButton = find.text(zulipLocalizations.dialogCancel); - await tester.tap(findCancelButton); - await tester.pumpAndSettle(); - checkActionSheet(tester, isShown: false); + final findCancelButton = find.text(zulipLocalizations.dialogCancel); + await tester.tap(findCancelButton); + await tester.pumpAndSettle(); + checkActionSheet(tester, isShown: false); + }); }); }); } diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart index 4a4698a175..5a957c44e0 100644 --- a/test/widgets/actions_test.dart +++ b/test/widgets/actions_test.dart @@ -42,6 +42,7 @@ void main() { Future prepare(WidgetTester tester, { UnreadMessagesSnapshot? unreadMsgs, String? ackedPushToken = '123', + bool skipAssertAccountExists = false, }) async { addTearDown(testBinding.reset); final selfAccount = eg.selfAccount.copyWith(ackedPushToken: Value(ackedPushToken)); @@ -50,7 +51,9 @@ void main() { store = await testBinding.globalStore.perAccount(selfAccount.id); connection = store.connection as FakeApiConnection; - await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, + await tester.pumpWidget(TestZulipApp( + accountId: selfAccount.id, + skipAssertAccountExists: skipAssertAccountExists, child: const Scaffold(body: Placeholder()))); await tester.pump(); context = tester.element(find.byType(Placeholder)); @@ -99,7 +102,7 @@ void main() { group('logOutAccount', () { testWidgets('smoke', (tester) async { - await prepare(tester); + await prepare(tester, skipAssertAccountExists: true); check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id); const unregisterDelay = Duration(seconds: 5); assert(unregisterDelay > TestGlobalStore.removeAccountDuration); @@ -124,7 +127,7 @@ void main() { }); testWidgets('unregister request has an error', (tester) async { - await prepare(tester); + await prepare(tester, skipAssertAccountExists: true); check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id); const unregisterDelay = Duration(seconds: 5); assert(unregisterDelay > TestGlobalStore.removeAccountDuration); diff --git a/test/widgets/app_bar_test.dart b/test/widgets/app_bar_test.dart index 3685eb4ae0..099471d4f7 100644 --- a/test/widgets/app_bar_test.dart +++ b/test/widgets/app_bar_test.dart @@ -1,5 +1,7 @@ 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/widgets/app_bar.dart'; import 'package:zulip/widgets/profile.dart'; @@ -35,4 +37,124 @@ void main() { check(tester.getRect(find.byType(ZulipAppBar))).equals(rectBefore); check(finder.evaluate()).single; }); + + group("buildTitle's willCenterTitle agrees with Material AppBar", () { + /// Build an [AppBar]; inspect and return whether it decided to center. + Future material(WidgetTester tester, { + required bool? paramValue, + required bool? themeValue, + required List? actions, + }) async { + testBinding.reset(); + + final themeData = ThemeData(appBarTheme: AppBarTheme(centerTitle: themeValue)); + final widget = TestZulipApp( + child: Theme(data: themeData, + child: AppBar( + centerTitle: paramValue, + actions: actions, + title: const Text('a')))); + + await tester.pumpWidget(widget); + await tester.pump(); + + // test assumes LTR text direction + check(tester.platformDispatcher.locale).equals(const Locale('en', 'US')); + assert(actions == null || actions.isNotEmpty); + final titleAreaRightEdgeOffset = actions == null + ? (tester.view.physicalSize / tester.view.devicePixelRatio).width + : tester.getTopLeft(find.byWidget(actions.first)).dx; + final titlePosition = tester.getTopLeft(find.text('a')).dx; + final isCentered = titlePosition > ((1 / 3) * titleAreaRightEdgeOffset); + check(titlePosition).isLessThan((2 / 3) * titleAreaRightEdgeOffset); + + return isCentered; + } + + /// Build a [ZulipAppBar]; return willCenterTitle from the buildTitle call. + Future ours(WidgetTester tester, { + required bool? paramValue, + required bool? themeValue, + required List? actions, + }) async { + testBinding.reset(); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + + bool? result; + + final widget = TestZulipApp( + // ZulipAppBar expects a per-account context (for the loading indicator) + accountId: eg.selfAccount.id, + child: Builder(builder: (context) => Theme( + data: Theme.of(context).copyWith(appBarTheme: AppBarTheme(centerTitle: themeValue)), + child: ZulipAppBar( + centerTitle: paramValue, + actions: actions, + buildTitle: (willCenterTitle) { + result = willCenterTitle; + return const Text('a'); + })))); + + await tester.pumpWidget(widget); + await tester.pump(); // global store + await tester.pump(); // per-account store + check(find.widgetWithText(ZulipAppBar, 'a')).findsOne(); + + check(result).isNotNull(); + return result!; + } + + void doTest(String description, bool expectedWillCenter, { + bool? paramValue, + bool? themeValue, + TargetPlatform? platform, + List? actions, + }) { + testWidgets(description, (tester) async { + addTearDown(testBinding.reset); + debugDefaultTargetPlatformOverride = platform; + + check( + await ours(tester, paramValue: paramValue, themeValue: themeValue, actions: actions) + )..equals( + await material(tester, paramValue: paramValue, themeValue: themeValue, actions: actions) + )..equals(expectedWillCenter); + + // TODO(upstream) Do this in an addTearDown, once we can: + // https://github.com/flutter/flutter/issues/123189 + debugDefaultTargetPlatformOverride = null; + }); + } + + const iOS = TargetPlatform.iOS; + const android = TargetPlatform.android; + + Widget button() => IconButton(icon: const Icon(Icons.add), onPressed: () {}); + final oneButton = [button()]; + final twoButtons = [button(), button()]; + final threeButtons = [button(), button(), button()]; + + doTest('ios', true, platform: iOS); + doTest('android', false, platform: android); + + doTest('ios, theme false', false, platform: iOS, themeValue: false); + doTest('android, theme true', true, platform: android, themeValue: true); + + doTest('ios, param false', false, platform: iOS, paramValue: false); + doTest('android, param true', true, platform: android, paramValue: true); + + doTest('ios, theme true, param false', false, platform: iOS, themeValue: true, paramValue: false); + doTest('ios, theme false, param true', true, platform: iOS, themeValue: false, paramValue: true); + + doTest('android, theme true, param false', false, platform: android, themeValue: true, paramValue: false); + doTest('android, theme false, param true', true, platform: android, themeValue: false, paramValue: true); + + doTest('ios, no actions', true, platform: iOS, actions: null); + doTest('ios, one action', true, platform: iOS, actions: oneButton); + doTest('ios, two actions' , false, platform: iOS, actions: twoButtons); + doTest('ios, three actions', false, platform: iOS, actions: threeButtons); + + doTest('ios, two actions but param true', true, platform: iOS, paramValue: true, actions: twoButtons); + doTest('ios, two actions but theme true', true, platform: iOS, themeValue: true, actions: twoButtons); + }); } diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart index 8c992533ee..af386bfdf1 100644 --- a/test/widgets/app_test.dart +++ b/test/widgets/app_test.dart @@ -280,14 +280,14 @@ void main() { check(ZulipApp.ready).value.isFalse(); await tester.pump(); check(findSnackBarByText(message).evaluate()).isEmpty(); - check(find.byType(AlertDialog).evaluate()).isEmpty(); + checkNoErrorDialog(tester); check(ZulipApp.ready).value.isTrue(); // After app startup, reportErrorToUserBriefly displays a SnackBar. reportErrorToUserBriefly(message, details: details); await tester.pumpAndSettle(); check(findSnackBarByText(message).evaluate()).single; - check(find.byType(AlertDialog).evaluate()).isEmpty(); + checkNoErrorDialog(tester); // Open the error details dialog. await tester.tap(find.text('Details')); diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 24ae2dba83..3f3c32bd59 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -13,10 +13,13 @@ 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/compose_box.dart'; +import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.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_images.dart'; @@ -33,7 +36,9 @@ import 'test_app.dart'; /// before the end of the test. Future setupToComposeInput(WidgetTester tester, { List users = const [], + Narrow? narrow, }) async { + assert(narrow is ChannelNarrow? || narrow is SendableNarrow?); TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); @@ -44,8 +49,24 @@ Future setupToComposeInput(WidgetTester tester, { await store.addUsers(users); final connection = store.connection as FakeApiConnection; + narrow ??= DmNarrow( + allRecipientIds: [eg.selfUser.userId, eg.otherUser.userId], + selfUserId: eg.selfUser.userId); // prepare message list data - final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + final Message message; + switch(narrow) { + case DmNarrow(): + message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + case ChannelNarrow(:final streamId): + final stream = eg.stream(streamId: streamId); + message = eg.streamMessage(stream: stream); + await store.addStream(stream); + case TopicNarrow(:final streamId, :final topic): + final stream = eg.stream(streamId: streamId); + message = eg.streamMessage(stream: stream, topic: topic.apiName); + await store.addStream(stream); + default: throw StateError('unexpected narrow type'); + } connection.prepare(json: GetMessagesResult( anchor: message.id, foundNewest: true, @@ -58,15 +79,13 @@ Future setupToComposeInput(WidgetTester tester, { prepareBoringImageHttpClient(); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, - child: MessageListPage(initNarrow: DmNarrow( - allRecipientIds: [eg.selfUser.userId, eg.otherUser.userId], - selfUserId: eg.selfUser.userId)))); + child: MessageListPage(initNarrow: narrow))); // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); - // (hint text of compose input in a 1:1 DM) - final finder = find.widgetWithText(TextField, 'Message @${eg.otherUser.fullName}'); + final finder = find.byWidgetPredicate((widget) => widget is TextField + && widget.controller is ComposeContentController); check(finder.evaluate()).isNotEmpty(); return finder; } @@ -133,7 +152,7 @@ void main() { check(avatarFinder.evaluate().length).equals(expected ? 1 : 0); } - testWidgets('options appear, disappear, and change correctly', (tester) async { + testWidgets('user options appear, disappear, and change correctly', (tester) async { final user1 = eg.user(userId: 1, fullName: 'User One', avatarUrl: 'user1.png'); final user2 = eg.user(userId: 2, fullName: 'User Two', avatarUrl: 'user2.png'); final user3 = eg.user(userId: 3, fullName: 'User Three', avatarUrl: 'user3.png'); @@ -155,7 +174,7 @@ void main() { await tester.tap(find.text('User Three')); await tester.pump(); check(tester.widget(composeInputFinder).controller!.text) - .contains(mention(user3, users: store.users)); + .contains(userMention(user3, users: store.users)); checkUserShown(user1, store, expected: false); checkUserShown(user2, store, expected: false); checkUserShown(user3, store, expected: false); @@ -177,6 +196,46 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + + void checkWildcardShown(WildcardMentionOption wildcard, {required bool expected}) { + final richTextFinder = find.textContaining(wildcard.canonicalString, findRichText: true); + final iconFinder = find.byIcon(ZulipIcons.three_person); + final wildcardItemFinder = find.ancestor(of: richTextFinder, + matching: find.ancestor(of: iconFinder, matching: find.byType(Row))); + check(wildcardItemFinder.evaluate().length).equals(expected ? 1 : 0); + } + + testWidgets('wildcard options appear, disappear, and change correctly', (tester) async { + final composeInputFinder = await setupToComposeInput(tester, + narrow: const ChannelNarrow(1)); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + // Options are filtered correctly for query + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @'); + await tester.enterText(composeInputFinder, 'hello @c'); + await tester.pumpAndSettle(); // async computation; options appear + + checkWildcardShown(WildcardMentionOption.channel, expected: true); + checkWildcardShown(WildcardMentionOption.topic, expected: true); + checkWildcardShown(WildcardMentionOption.all, expected: false); + checkWildcardShown(WildcardMentionOption.everyone, expected: false); + checkWildcardShown(WildcardMentionOption.stream, expected: false); + + // Finishing autocomplete updates compose box; causes options to disappear + await tester.tap(find.textContaining(WildcardMentionOption.channel.canonicalString, + findRichText: true)); + await tester.pump(); + check(tester.widget(composeInputFinder).controller!.text) + .contains(wildcardMention(WildcardMentionOption.channel, store: store)); + checkWildcardShown(WildcardMentionOption.channel, expected: false); + checkWildcardShown(WildcardMentionOption.topic, expected: false); + checkWildcardShown(WildcardMentionOption.all, expected: false); + checkWildcardShown(WildcardMentionOption.everyone, expected: false); + checkWildcardShown(WildcardMentionOption.stream, expected: false); + + debugNetworkImageHttpClientProvider = null; + }); }); group('emoji', () { @@ -224,6 +283,9 @@ void main() { await tester.enterText(composeInputFinder, 'hi :'); await tester.enterText(composeInputFinder, 'hi :z'); await tester.pump(); + // Add an extra pump to account for any potential frame delays introduced + // by the post frame callback in RawAutocomplete's implementation. + await tester.pump(); checkEmojiShown(expected: true, zzzOption); checkEmojiShown(expected: true, buzzingOption); checkEmojiShown(expected: true, zulipOption); @@ -273,7 +335,7 @@ void main() { group('TopicAutocomplete', () { void checkTopicShown(GetStreamTopicsEntry topic, PerAccountStore store, {required bool expected}) { - check(find.text(topic.name).evaluate().length).equals(expected ? 1 : 0); + check(find.text(topic.name.displayName).evaluate().length).equals(expected ? 1 : 0); } testWidgets('options appear, disappear, and change correctly', (WidgetTester tester) async { @@ -298,7 +360,7 @@ void main() { await tester.tap(find.text('Topic three')); await tester.pumpAndSettle(); check(tester.widget(topicInputFinder).controller!.text) - .equals(topic3.name); + .equals(topic3.name.displayName); checkTopicShown(topic1, store, expected: false); checkTopicShown(topic2, store, expected: false); checkTopicShown(topic3, store, expected: true); // shown in `_TopicInput` once @@ -309,5 +371,40 @@ void main() { await tester.pumpAndSettle(); checkTopicShown(topic2, store, expected: true); }); + + testWidgets('text selection is reset on choosing an option', (tester) async { + // TODO test also that composing region gets reset. + // (Just adding it to the updateEditingValue call below doesn't seem + // to suffice to set it up; the controller value after the pump still + // has empty composing region, so there's nothing to check after tap.) + + final topic = eg.getStreamTopicsEntry(name: 'some topic'); + final topicInputFinder = await setupToTopicInput(tester, topics: [topic]); + final controller = tester.widget(topicInputFinder).controller!; + + await tester.enterText(topicInputFinder, 'so'); + await tester.enterText(topicInputFinder, 'some'); + tester.testTextInput.updateEditingValue(const TextEditingValue( + text: 'some', + selection: TextSelection(baseOffset: 1, extentOffset: 3))); + await tester.pump(); + // Add an extra pump to account for any potential frame delays introduced + // by the post frame callback in RawAutocomplete's implementation. + await tester.pump(); + + check(controller.value) + ..text.equals('some') + ..selection.equals( + const TextSelection(baseOffset: 1, extentOffset: 3)); + + await tester.tap(find.text('some topic')); + await tester.pump(); + check(controller.value) + ..text.equals('some topic') + ..selection.equals( + const TextSelection.collapsed(offset: 'some topic'.length)); + + await tester.pump(Duration.zero); + }); }); } diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 077134e7d1..f1eb9bb3ba 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -41,15 +41,12 @@ void main() { late FakeApiConnection connection; late ComposeBoxController? controller; - final contentInputFinder = find.byWidgetPredicate( - (widget) => widget is TextField && widget.controller is ComposeContentController); - Future prepareComposeBox(WidgetTester tester, { required Narrow narrow, User? selfUser, - int? realmWaitingPeriodThreshold, - List users = const [], + List otherUsers = const [], List streams = const [], + bool? mandatoryTopics, }) async { if (narrow case ChannelNarrow(:var streamId) || TopicNarrow(: var streamId)) { assert(streams.any((stream) => stream.streamId == streamId), @@ -59,11 +56,12 @@ void main() { selfUser ??= eg.selfUser; final selfAccount = eg.account(user: selfUser); await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( - realmWaitingPeriodThreshold: realmWaitingPeriodThreshold)); + realmMandatoryTopics: mandatoryTopics, + )); store = await testBinding.globalStore.perAccount(selfAccount.id); - await store.addUsers([selfUser, ...users]); + await store.addUsers([selfUser, ...otherUsers]); await store.addStreams(streams); connection = store.connection as FakeApiConnection; @@ -80,6 +78,7 @@ void main() { controller = tester.state(find.byType(ComposeBox)).controller; } + /// Set the topic input's text to [topic], using [WidgetTester.enterText]. Future enterTopic(WidgetTester tester, { required ChannelNarrow narrow, required String topic, @@ -95,6 +94,32 @@ void main() { ..url.path.equals('/api/v1/users/me/${narrow.streamId}/topics'); } + /// A [Finder] for the content input. + /// + /// To enter some text, use [enterContent]. + final contentInputFinder = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller is ComposeContentController); + + /// Set the content input's text to [content], using [WidgetTester.enterText]. + Future enterContent(WidgetTester tester, String content) async { + await tester.enterText(contentInputFinder, content); + } + + Future tapSendButton(WidgetTester tester) async { + connection.prepare(json: SendMessageResult(id: 123).toJson()); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + } + + group('ComposeBoxTheme', () { + test('lerp light to dark, no crash', () { + final a = ComposeBoxTheme.light; + final b = ComposeBoxTheme.dark; + + check(() => a.lerp(b, 0.5)).returnsNormally(); + }); + }); + group('ComposeContentController', () { group('insertPadded', () { // Like `parseMarkedText` in test/model/autocomplete_test.dart, @@ -197,6 +222,102 @@ void main() { }); }); + group('length validation', () { + final channel = eg.stream(); + + /// String where there are [n] Unicode code points, + /// >[n] UTF-16 code units, and <[n] "characters" a.k.a. grapheme clusters. + String makeStringWithCodePoints(int n) { + assert(n >= 5); + const graphemeCluster = '👨‍👩‍👦'; + assert(graphemeCluster.runes.length == 5); + assert(graphemeCluster.length == 8); + assert(graphemeCluster.characters.length == 1); + + final result = + graphemeCluster * (n ~/ 5) + + 'a' * (n % 5); + assert(result.runes.length == n); + + return result; + } + + group('content', () { + Future prepareWithContent(WidgetTester tester, String content) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final narrow = ChannelNarrow(channel.streamId); + await prepareComposeBox(tester, narrow: narrow, streams: [channel]); + await enterTopic(tester, narrow: narrow, topic: 'some topic'); + await enterContent(tester, content); + } + + Future checkErrorResponse(WidgetTester tester) async { + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Message not sent', + expectedMessage: 'Message length shouldn\'t be greater than 10000 characters.'))); + } + + testWidgets('too-long content is rejected', (tester) async { + await prepareWithContent(tester, + makeStringWithCodePoints(kMaxMessageLengthCodePoints + 1)); + await tapSendButton(tester); + await checkErrorResponse(tester); + }); + + testWidgets('max-length content not rejected', (tester) async { + await prepareWithContent(tester, + makeStringWithCodePoints(kMaxMessageLengthCodePoints)); + await tapSendButton(tester); + checkNoErrorDialog(tester); + }); + + testWidgets('code points not counted unnecessarily', (tester) async { + await prepareWithContent(tester, 'a' * kMaxMessageLengthCodePoints); + check(controller!.content.debugLengthUnicodeCodePointsIfLong).isNull(); + }); + }); + + group('topic', () { + Future prepareWithTopic(WidgetTester tester, String topic) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final narrow = ChannelNarrow(channel.streamId); + await prepareComposeBox(tester, narrow: narrow, streams: [channel]); + await enterTopic(tester, narrow: narrow, topic: topic); + await enterContent(tester, 'some content'); + } + + Future checkErrorResponse(WidgetTester tester) async { + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Message not sent', + expectedMessage: 'Topic length shouldn\'t be greater than 60 characters.'))); + } + + testWidgets('too-long topic is rejected', (tester) async { + await prepareWithTopic(tester, + makeStringWithCodePoints(kMaxTopicLengthCodePoints + 1)); + await tapSendButton(tester); + await checkErrorResponse(tester); + }); + + testWidgets('max-length topic not rejected', (tester) async { + await prepareWithTopic(tester, + makeStringWithCodePoints(kMaxTopicLengthCodePoints)); + await tapSendButton(tester); + checkNoErrorDialog(tester); + }); + + testWidgets('code points not counted unnecessarily', (tester) async { + await prepareWithTopic(tester, 'a' * kMaxTopicLengthCodePoints); + check((controller as StreamComposeBoxController) + .topic.debugLengthUnicodeCodePointsIfLong).isNull(); + }); + }); + }); + group('ComposeBox textCapitalization', () { void checkComposeBoxTextFields(WidgetTester tester, { required bool expectTopicTextField, @@ -230,21 +351,21 @@ void main() { testWidgets('_FixedDestinationComposeBox', (tester) async { final channel = eg.stream(); await prepareComposeBox(tester, - narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]); + narrow: eg.topicNarrow(channel.streamId, 'topic'), streams: [channel]); checkComposeBoxTextFields(tester, expectTopicTextField: false); }); }); group('ComposeBox typing notices', () { final channel = eg.stream(); - final narrow = TopicNarrow(channel.streamId, 'some topic'); + final narrow = eg.topicNarrow(channel.streamId, 'some topic'); void checkTypingRequest(TypingOp op, SendableNarrow narrow) => checkSetTypingStatusRequests(connection.takeRequests(), [(op, narrow)]); Future checkStartTyping(WidgetTester tester, SendableNarrow narrow) async { connection.prepare(json: {}); - await tester.enterText(contentInputFinder, 'hello world'); + await enterContent(tester, 'hello world'); checkTypingRequest(TypingOp.start, narrow); } @@ -272,9 +393,9 @@ void main() { testWidgets('smoke ChannelNarrow', (tester) async { final narrow = ChannelNarrow(channel.streamId); - final destinationNarrow = TopicNarrow(narrow.streamId, 'test topic'); + final destinationNarrow = eg.topicNarrow(narrow.streamId, 'test topic'); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); - await enterTopic(tester, narrow: narrow, topic: destinationNarrow.topic); + await enterTopic(tester, narrow: narrow, topic: 'test topic'); await checkStartTyping(tester, destinationNarrow); @@ -289,7 +410,7 @@ void main() { await checkStartTyping(tester, narrow); connection.prepare(json: {}); - await tester.enterText(contentInputFinder, ''); + await enterContent(tester, ''); checkTypingRequest(TypingOp.stop, narrow); }); @@ -339,9 +460,9 @@ void main() { testWidgets('for content input, unfocusing sends a "typing stopped" notice', (tester) async { final narrow = ChannelNarrow(channel.streamId); - final destinationNarrow = TopicNarrow(narrow.streamId, 'test topic'); + final destinationNarrow = eg.topicNarrow(narrow.streamId, 'test topic'); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); - await enterTopic(tester, narrow: narrow, topic: destinationNarrow.topic); + await enterTopic(tester, narrow: narrow, topic: 'test topic'); await checkStartTyping(tester, destinationNarrow); @@ -402,10 +523,10 @@ void main() { addTearDown(TypingNotifier.debugReset); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - await prepareComposeBox(tester, narrow: const TopicNarrow(123, 'some topic'), + await prepareComposeBox(tester, narrow: eg.topicNarrow(123, 'some topic'), streams: [eg.stream(streamId: 123)]); - await tester.enterText(contentInputFinder, 'hello world'); + await enterContent(tester, 'hello world'); prepareResponse(456); await tester.tap(find.byTooltip(zulipLocalizations.composeBoxSendTooltip)); @@ -427,8 +548,7 @@ void main() { await setupAndTapSend(tester, prepareResponse: (int messageId) { connection.prepare(json: SendMessageResult(id: messageId).toJson()); }); - final errorDialogs = tester.widgetList(find.byType(AlertDialog)); - check(errorDialogs).isEmpty(); + checkNoErrorDialog(tester); }); testWidgets('ZulipApiException', (tester) async { @@ -450,6 +570,60 @@ void main() { }); }); + group('sending to empty topic', () { + late ZulipStream channel; + + Future setupAndTapSend(WidgetTester tester, { + required String topicInputText, + required bool mandatoryTopics, + }) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + channel = eg.stream(); + final narrow = ChannelNarrow(channel.streamId); + await prepareComposeBox(tester, + narrow: narrow, streams: [channel], + mandatoryTopics: mandatoryTopics); + + await enterTopic(tester, narrow: narrow, topic: topicInputText); + await tester.enterText(contentInputFinder, 'test content'); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(); + } + + void checkMessageNotSent(WidgetTester tester) { + check(connection.takeRequests()).isEmpty(); + checkErrorDialog(tester, + expectedTitle: 'Message not sent', + expectedMessage: 'Topics are required in this organization.'); + } + + testWidgets('empty topic -> "(no topic)"', (tester) async { + await setupAndTapSend(tester, + topicInputText: '', + mandatoryTopics: false); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages') + ..bodyFields['topic'].equals('(no topic)'); + }); + + testWidgets('if topics are mandatory, reject empty topic', (tester) async { + await setupAndTapSend(tester, + topicInputText: '', + mandatoryTopics: true); + checkMessageNotSent(tester); + }); + + testWidgets('if topics are mandatory, reject "(no topic)"', (tester) async { + await setupAndTapSend(tester, + topicInputText: '(no topic)', + mandatoryTopics: true); + checkMessageNotSent(tester); + }); + }); + group('uploads', () { void checkAppearsLoading(WidgetTester tester, bool expected) { final sendButtonElement = tester.element(find.ancestor( @@ -497,8 +671,7 @@ void main() { check(call.allowMultiple).equals(true); check(call.type).equals(FileType.media); - final errorDialogs = tester.widgetList(find.byType(AlertDialog)); - check(errorDialogs).isEmpty(); + checkNoErrorDialog(tester); check(controller!.content.text) .equals('see image: [Uploading image.jpg…]()\n\n'); @@ -557,8 +730,7 @@ void main() { check(call.source).equals(ImageSource.camera); check(call.requestFullMetadata).equals(false); - final errorDialogs = tester.widgetList(find.byType(AlertDialog)); - check(errorDialogs).isEmpty(); + checkNoErrorDialog(tester); check(controller!.content.text) .equals('see image: [Uploading image.jpg…]()\n\n'); @@ -640,7 +812,7 @@ void main() { testWidgets('compose box replaced with a banner', (tester) async { final deactivatedUser = eg.user(isActive: false); await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser), - users: [deactivatedUser]); + otherUsers: [deactivatedUser]); checkComposeBox(isShown: false); }); @@ -648,7 +820,7 @@ void main() { 'compose box is replaced with a banner', (tester) async { final activeUser = eg.user(isActive: true); await prepareComposeBox(tester, narrow: dmNarrowWith(activeUser), - users: [activeUser]); + otherUsers: [activeUser]); checkComposeBox(isShown: true); await changeUserStatus(tester, user: activeUser, isActive: false); @@ -659,7 +831,7 @@ void main() { 'banner is replaced with the compose box', (tester) async { final deactivatedUser = eg.user(isActive: false); await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser), - users: [deactivatedUser]); + otherUsers: [deactivatedUser]); checkComposeBox(isShown: false); await changeUserStatus(tester, user: deactivatedUser, isActive: true); @@ -671,7 +843,7 @@ void main() { testWidgets('compose box replaced with a banner', (tester) async { final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)]; await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers), - users: deactivatedUsers); + otherUsers: deactivatedUsers); checkComposeBox(isShown: false); }); @@ -679,7 +851,7 @@ void main() { 'compose box is replaced with a banner', (tester) async { final activeUsers = [eg.user(isActive: true), eg.user(isActive: true)]; await prepareComposeBox(tester, narrow: groupDmNarrowWith(activeUsers), - users: activeUsers); + otherUsers: activeUsers); checkComposeBox(isShown: true); await changeUserStatus(tester, user: activeUsers[0], isActive: false); @@ -690,7 +862,7 @@ void main() { 'banner is replaced with the compose box', (tester) async { final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)]; await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers), - users: deactivatedUsers); + otherUsers: deactivatedUsers); checkComposeBox(isShown: false); await changeUserStatus(tester, user: deactivatedUsers[0], isActive: true); @@ -708,7 +880,7 @@ void main() { final narrowTestCases = [ ('channel', const ChannelNarrow(1)), - ('topic', const TopicNarrow(1, 'topic')), + ('topic', eg.topicNarrow(1, 'topic')), ]; for (final (String narrowType, Narrow narrow) in narrowTestCases) { @@ -802,7 +974,7 @@ void main() { group('ComposeBox content input scaling', () { const verticalPadding = 8; final stream = eg.stream(); - final narrow = TopicNarrow(stream.streamId, 'foo'); + final narrow = eg.topicNarrow(stream.streamId, 'foo'); Future checkContentInputMaxHeight(WidgetTester tester, { required double maxHeight, @@ -816,7 +988,7 @@ void main() { double? height; for (numLines = 2; numLines <= 1000; numLines++) { final content = List.generate(numLines, (_) => 'foo').join('\n'); - await tester.enterText(contentInputFinder, content); + await enterContent(tester, content); await tester.pump(); final newHeight = tester.getRect(contentInputFinder).height; if (newHeight == height) { diff --git a/test/widgets/dialog_checks.dart b/test/widgets/dialog_checks.dart index 504042d07e..af0c6e2963 100644 --- a/test/widgets/dialog_checks.dart +++ b/test/widgets/dialog_checks.dart @@ -1,4 +1,6 @@ +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/widgets/dialog.dart'; @@ -26,6 +28,11 @@ Widget checkErrorDialog(WidgetTester tester, { matching: find.widgetWithText(TextButton, 'OK'))); } +// TODO(#996) update this to check for per-platform flavors of alert dialog +void checkNoErrorDialog(WidgetTester tester) { + check(find.byType(AlertDialog)).findsNothing(); +} + /// In a widget test, check that [showSuggestedActionDialog] was called /// with the right text. /// diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index 58b4f64a77..89333ee1af 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -251,15 +251,15 @@ void main() { } check(backgroundColor('smile')).isNotNull() - .isSameColorAs(EmojiReactionTheme.light().bgSelected); + .isSameColorAs(EmojiReactionTheme.light.bgSelected); check(backgroundColor('tada')).isNotNull() - .isSameColorAs(EmojiReactionTheme.light().bgUnselected); + .isSameColorAs(EmojiReactionTheme.light.bgUnselected); tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; await tester.pump(); await tester.pump(kThemeAnimationDuration * 0.4); - final expectedLerped = EmojiReactionTheme.light().lerp(EmojiReactionTheme.dark(), 0.4); + final expectedLerped = EmojiReactionTheme.light.lerp(EmojiReactionTheme.dark, 0.4); check(backgroundColor('smile')).isNotNull() .isSameColorAs(expectedLerped.bgSelected); check(backgroundColor('tada')).isNotNull() @@ -267,9 +267,9 @@ void main() { await tester.pump(kThemeAnimationDuration * 0.6); check(backgroundColor('smile')).isNotNull() - .isSameColorAs(EmojiReactionTheme.dark().bgSelected); + .isSameColorAs(EmojiReactionTheme.dark.bgSelected); check(backgroundColor('tada')).isNotNull() - .isSameColorAs(EmojiReactionTheme.dark().bgUnselected); + .isSameColorAs(EmojiReactionTheme.dark.bgUnselected); }); testWidgets('use emoji font', (tester) async { diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 2939358bb8..5bb789727f 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -6,6 +6,7 @@ import 'package:zulip/api/model/events.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/about_zulip.dart'; +import 'package:zulip/widgets/actions.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/home.dart'; @@ -21,6 +22,7 @@ import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; +import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; import 'message_list_checks.dart'; @@ -49,6 +51,23 @@ void main () { await tester.pump(); } + void checkOnLoadingPage() { + check(find.byType(CircularProgressIndicator).hitTestable()).findsOne(); + check(find.byType(ChooseAccountPage)).findsNothing(); + check(find.byType(HomePage)).findsNothing(); + } + + ModalRoute? getRouteOf(WidgetTester tester, Finder finder) => + ModalRoute.of(tester.element(finder)); + + void checkOnHomePage(WidgetTester tester, {required Account expectedAccount}) { + check(find.byType(CircularProgressIndicator)).findsNothing(); + check(find.byType(ChooseAccountPage)).findsNothing(); + check(find.byType(HomePage).hitTestable()).findsOne(); + check(getRouteOf(tester, find.byType(HomePage))) + .isA().accountId.equals(expectedAccount.id); + } + group('bottom nav navigation', () { testWidgets('preserve states when switching between views', (tester) async { await prepare(tester); @@ -255,12 +274,6 @@ void main () { const loadPerAccountDuration = Duration(seconds: 30); assert(loadPerAccountDuration > kTryAnotherAccountWaitPeriod); - void checkOnLoadingPage() { - check(find.byType(CircularProgressIndicator).hitTestable()).findsOne(); - check(find.byType(ChooseAccountPage)).findsNothing(); - check(find.byType(HomePage)).findsNothing(); - } - void checkOnChooseAccountPage() { // Ignore the possible loading page in the background. check(find.byType(CircularProgressIndicator).hitTestable()).findsNothing(); @@ -268,17 +281,6 @@ void main () { check(find.byType(HomePage)).findsNothing(); } - ModalRoute? getRouteOf(WidgetTester tester, Finder finder) => - ModalRoute.of(tester.element(finder)); - - void checkOnHomePage(WidgetTester tester, {required Account expectedAccount}) { - check(find.byType(CircularProgressIndicator)).findsNothing(); - check(find.byType(ChooseAccountPage)).findsNothing(); - check(find.byType(HomePage).hitTestable()).findsOne(); - check(getRouteOf(tester, find.byType(HomePage))) - .isA().accountId.equals(expectedAccount.id); - } - Future prepare(WidgetTester tester) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); @@ -442,4 +444,39 @@ void main () { checkOnHomePage(tester, expectedAccount: eg.selfAccount); }); }); + + testWidgets('logging out while still loading', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1219 + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await tester.pumpWidget(const ZulipApp()); + await tester.pump(); // wait for the loading page + checkOnLoadingPage(); + + final element = tester.element(find.byType(MaterialApp)); + final future = logOutAccount(element, eg.selfAccount.id); + await tester.pump(TestGlobalStore.removeAccountDuration); + await future; + // No error expected from briefly not having + // access to the account being logged out. + check(testBinding.globalStore).accountIds.isEmpty(); + }); + + testWidgets('logging out after fully loaded', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1219 + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await tester.pumpWidget(const ZulipApp()); + await tester.pump(); // wait for the loading page + await tester.pump(); // wait for store + checkOnHomePage(tester, expectedAccount: eg.selfAccount); + + final element = tester.element(find.byType(HomePage)); + final future = logOutAccount(element, eg.selfAccount.id); + await tester.pump(TestGlobalStore.removeAccountDuration); + await future; + // No error expected from briefly not having + // access to the account being logged out. + check(testBinding.globalStore).accountIds.isEmpty(); + }); } diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index b7af250d03..3fa3713d5d 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -206,6 +206,8 @@ void main() { // TODO test that tapping a conversation row opens the message list // for the conversation + // Tests for the topic action sheet are in test/widgets/action_sheet_test.dart. + group('muting', () { // aka topic visibility testWidgets('baseline', (tester) async { final stream = eg.stream(); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index b1f443c8b5..b3cc208463 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -133,12 +133,14 @@ void main() { }); group('app bar', () { + // Tests for the topic action sheet are in test/widgets/action_sheet_test.dart. + testWidgets('has channel-feed action for topic narrows', (tester) async { final pushedRoutes = >[]; final navObserver = TestNavigatorObserver() ..onPushed = (route, prevRoute) => pushedRoutes.add(route); final channel = eg.stream(); - await setupMessageListPage(tester, narrow: TopicNarrow(channel.streamId, 'hi'), + await setupMessageListPage(tester, narrow: eg.topicNarrow(channel.streamId, 'hi'), navObservers: [navObserver], streams: [channel], messageCount: 1); @@ -157,7 +159,7 @@ void main() { final channel = eg.stream(); const topic = 'topic'; await setupMessageListPage(tester, - narrow: TopicNarrow(channel.streamId, topic), + narrow: eg.topicNarrow(channel.streamId, topic), streams: [channel], subscriptions: [eg.subscription(channel)], messageCount: 1); await store.handleEvent(eg.userTopicEvent( @@ -207,17 +209,17 @@ void main() { return widget.color; } - check(backgroundColor()).isSameColorAs(MessageListTheme.light().streamMessageBgDefault); + check(backgroundColor()).isSameColorAs(MessageListTheme.light.streamMessageBgDefault); 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 = MessageListTheme.light.lerp(MessageListTheme.dark, 0.4); check(backgroundColor()).isSameColorAs(expectedLerped.streamMessageBgDefault); await tester.pump(kThemeAnimationDuration * 0.6); - check(backgroundColor()).isSameColorAs(MessageListTheme.dark().streamMessageBgDefault); + check(backgroundColor()).isSameColorAs(MessageListTheme.dark.streamMessageBgDefault); }); group('fetch older messages on scroll', () { @@ -661,7 +663,7 @@ void main() { const topic = 'foo'; final channel = eg.stream(); final otherChannel = eg.stream(); - final narrow = TopicNarrow(channel.streamId, topic); + final narrow = eg.topicNarrow(channel.streamId, topic); void prepareGetMessageResponse(List messages) { connection.prepare(json: eg.newestGetMessagesResult( @@ -671,7 +673,7 @@ void main() { void handleMessageMoveEvent(List messages, String newTopic, {int? newChannelId}) { store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: messages, - newTopic: newTopic, + newTopicStr: newTopic, newStreamId: newChannelId, propagateMode: PropagateMode.changeAll)); } @@ -748,8 +750,11 @@ void main() { group('recipient headers', () { group('StreamMessageRecipientHeader', () { + // Tests for the topic action sheet are in test/widgets/action_sheet_test.dart. + final stream = eg.stream(name: 'stream name'); - final message = eg.streamMessage(stream: stream, topic: 'topic name'); + const topic = 'topic name'; + final message = eg.streamMessage(stream: stream, topic: topic); FinderResult findInMessageList(String text) { // Stream name shows up in [AppBar] so need to avoid matching that @@ -808,7 +813,7 @@ void main() { narrow: const CombinedFeedNarrow(), messages: [message], subscriptions: [eg.subscription(stream)]); await store.handleEvent(eg.userTopicEvent( - stream.streamId, message.topic, UserTopicVisibilityPolicy.followed)); + stream.streamId, topic, UserTopicVisibilityPolicy.followed)); await tester.pump(); check(find.descendant( of: find.byType(MessageList), @@ -820,7 +825,7 @@ void main() { narrow: TopicNarrow.ofMessage(message), messages: [message], subscriptions: [eg.subscription(stream, isMuted: true)]); await store.handleEvent(eg.userTopicEvent( - stream.streamId, message.topic, UserTopicVisibilityPolicy.unmuted)); + stream.streamId, topic, UserTopicVisibilityPolicy.unmuted)); await tester.pump(); check(find.descendant( of: find.byType(MessageList), @@ -922,6 +927,54 @@ void main() { await tester.pump(); tester.widget(find.text('new stream name')); }); + + testWidgets('navigates to TopicNarrow on tapping topic in ChannelNarrow', (tester) async { + final pushedRoutes = >[]; + final navObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final channel = eg.stream(); + final message = eg.streamMessage(stream: channel, topic: 'topic name'); + await setupMessageListPage(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + 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('topic name'))); + await tester.pump(); + check(pushedRoutes).single.isA().page.isA() + .initNarrow.equals(TopicNarrow.ofMessage(message)); + await tester.pumpAndSettle(); + }); + + testWidgets('does not navigate on tapping topic in TopicNarrow', (tester) async { + final pushedRoutes = >[]; + final navObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final channel = eg.stream(); + final message = eg.streamMessage(stream: channel, topic: 'topic name'); + await setupMessageListPage(tester, + narrow: TopicNarrow.ofMessage(message), + streams: [channel], + messages: [message], + navObservers: [navObserver]); + + assert(pushedRoutes.length == 1); + pushedRoutes.clear(); + + await tester.tap(find.descendant( + of: find.byType(StreamMessageRecipientHeader), + matching: find.text('topic name'))); + await tester.pump(); + check(pushedRoutes).isEmpty(); + }); }); group('DmRecipientHeader', () { @@ -987,6 +1040,46 @@ void main() { tester.widget(find.textContaining(RegExp("Dec 1[89], 2022"))); tester.widget(find.textContaining(RegExp("Aug 2[23], 2022"))); }); + + testWidgets('navigates to DmNarrow on tapping recipient header in CombinedFeedNarrow', (tester) async { + final pushedRoutes = >[]; + final navObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final dmMessage = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + await setupMessageListPage(tester, + narrow: const CombinedFeedNarrow(), + messages: [dmMessage], + navObservers: [navObserver]); + + assert(pushedRoutes.length == 1); + pushedRoutes.clear(); + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [dmMessage]).toJson()); + await tester.tap(find.byType(DmRecipientHeader)); + await tester.pump(); + check(pushedRoutes).single.isA().page.isA() + .initNarrow.equals(DmNarrow.withUser(eg.otherUser.userId, selfUserId: eg.selfUser.userId)); + await tester.pumpAndSettle(); + }); + + testWidgets('does not navigate on tapping recipient header in DmNarrow', (tester) async { + final pushedRoutes = >[]; + final navObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final dmMessage = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + await setupMessageListPage(tester, + narrow: DmNarrow.withUser(eg.otherUser.userId, selfUserId: eg.selfUser.userId), + messages: [dmMessage], + navObservers: [navObserver]); + + assert(pushedRoutes.length == 1); + pushedRoutes.clear(); + + await tester.tap(find.byType(DmRecipientHeader)); + await tester.pump(); + check(pushedRoutes).isEmpty(); + }); }); group('formatHeaderDate', () { @@ -1134,7 +1227,7 @@ void main() { checkMarkersCount(edited: 1, moved: 0); await store.handleEvent(eg.updateMessageEventMoveFrom( - origMessages: [message, message2], newTopic: 'new')); + origMessages: [message, message2], newTopicStr: 'new')); await tester.pump(); checkMarkersCount(edited: 1, moved: 1); diff --git a/test/widgets/page_checks.dart b/test/widgets/page_checks.dart index 412a59fc49..a3692273bf 100644 --- a/test/widgets/page_checks.dart +++ b/test/widgets/page_checks.dart @@ -6,6 +6,6 @@ extension WidgetRouteChecks on Subject> { Subject get page => has((x) => x.page, 'page'); } -extension AccountPageRouteMixinChecks on Subject> { +extension AccountRouteChecks on Subject> { Subject get accountId => has((x) => x.accountId, 'accountId'); } diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index fc363c71e8..c283652ae1 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -103,6 +103,116 @@ void main() { } } } + + testWidgets('sticky headers: propagate scrollOffsetCorrection properly', (tester) async { + Widget page(Widget Function(BuildContext, int) itemBuilder) { + return Directionality(textDirection: TextDirection.ltr, + child: StickyHeaderListView.builder( + cacheExtent: 0, + itemCount: 10, itemBuilder: itemBuilder)); + } + + await tester.pumpWidget(page((context, i) => + StickyHeaderItem( + allowOverflow: true, + header: _Header(i, height: 40), + child: _Item(i, height: 200)))); + check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 400)); + + // Scroll down (dragging up) to get item 0 off screen. + await tester.drag(find.text("Item 2"), Offset(0, -300)); + await tester.pump(); + check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 100)); + + // Make the off-screen item 0 taller, so scrolling back up will underflow. + await tester.pumpWidget(page((context, i) => + StickyHeaderItem( + allowOverflow: true, + header: _Header(i, height: 40), + child: _Item(i, height: i == 0 ? 400 : 200)))); + // Confirm the change in item 0's height hasn't already been applied, + // as it would if the item were within the viewport or its cache area. + check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 100)); + + // Scroll back up (dragging down). This will cause a correction as the list + // discovers that moving 300px up doesn't reach the start anymore. + await tester.drag(find.text("Item 2"), Offset(0, 300)); + + // As a bonus, mark one of the already-visible items as needing layout. + // (In a real app, this would typically happen because some state changed.) + tester.firstElement(find.widgetWithText(SizedBox, "Item 2")) + .renderObject!.markNeedsLayout(); + + // If scrollOffsetCorrection doesn't get propagated to the viewport, this + // pump will record an exception (causing the test to fail at the end) + // because the marked item won't get laid out. + await tester.pump(); + check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 400)); + + // Moreover if scrollOffsetCorrection doesn't get propagated, this item + // will get placed at zero rather than properly extend up off screen. + check(tester.getTopLeft(find.text("Item 0"))).equals(Offset(0, -200)); + }); + + testWidgets('sliver only part of viewport, header at end', (tester) async { + const centerKey = ValueKey('center'); + final controller = ScrollController(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: CustomScrollView( + controller: controller, + anchor: 0.5, + center: centerKey, + slivers: [ + SliverStickyHeaderList( + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildListDelegate( + List.generate(100, (i) => StickyHeaderItem( + header: _Header(99 - i, height: 20), + child: _Item(99 - i, height: 100))))), + SliverStickyHeaderList( + key: centerKey, + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildListDelegate( + List.generate(100, (i) => StickyHeaderItem( + header: _Header(100 + i, height: 20), + child: _Item(100 + i, height: 100))))), + ]))); + + final overallSize = tester.getSize(find.byType(CustomScrollView)); + final extent = overallSize.onAxis(Axis.vertical); + assert(extent == 600); + + void checkState(int index, {required double item, required double header}) { + final itemElement = tester.firstElement(find.byElementPredicate((element) { + if (element.widget is! _Item) return false; + final renderObject = element.renderObject as RenderBox; + return (renderObject.size.contains(renderObject.globalToLocal( + Offset(overallSize.width / 2, 1) + ))); + })); + final itemWidget = itemElement.widget as _Item; + check(itemWidget.index).equals(index); + check(_headerIndex(tester)).equals(index); + check((itemElement.renderObject as RenderBox).localToGlobal(Offset(0, 0))) + .equals(Offset(0, item)); + check(tester.getTopLeft(find.byType(_Header))).equals(Offset(0, header)); + } + + check(controller.offset).equals(0); + checkState( 97, item: 0, header: 0); + + controller.jumpTo(-5); + await tester.pump(); + checkState( 96, item: -95, header: -15); + + controller.jumpTo(-600); + await tester.pump(); + checkState( 91, item: 0, header: 0); + + controller.jumpTo(600); + await tester.pump(); + checkState(103, item: 0, header: 0); + }); } Future _checkSequence( @@ -174,7 +284,6 @@ Future _checkSequence( final expectedHeaderIndex = first ? (scrollOffset / 100).floor() : (extent ~/ 100 - 1) + (scrollOffset / 100).ceil(); - // print("$scrollOffset, $extent, $expectedHeaderIndex"); check(tester.widget<_Item>(itemFinder).index).equals(expectedHeaderIndex); check(_headerIndex(tester)).equals(expectedHeaderIndex); diff --git a/test/widgets/subscription_list_test.dart b/test/widgets/subscription_list_test.dart index 9439bac865..a3fc13dac9 100644 --- a/test/widgets/subscription_list_test.dart +++ b/test/widgets/subscription_list_test.dart @@ -150,12 +150,35 @@ void main() { ]); check(listedStreamIds(tester)).deepEquals([2, 1, 3, 4, 6, 5]); }); + + testWidgets('channels with names starting with an emoji are above channel names that do not start with an emoji', (tester) async { + await setupStreamListPage(tester, subscriptions: [ + eg.subscription(eg.stream(streamId: 1, name: 'Happy 😊 Stream')), + eg.subscription(eg.stream(streamId: 2, name: 'Alpha Stream')), + eg.subscription(eg.stream(streamId: 3, name: '🚀 Rocket Stream')), + ]); + check(listedStreamIds(tester)).deepEquals([3, 2, 1]); + }); + + testWidgets('channels with names starting with an emoji, pinned, unpinned, muted, and unmuted are sorted correctly', (tester) async { + await setupStreamListPage(tester, subscriptions: [ + eg.subscription(eg.stream(streamId: 1, name: '😊 Happy Stream'), pinToTop: true, isMuted: false), + eg.subscription(eg.stream(streamId: 2, name: '🚀 Rocket Stream'), pinToTop: true, isMuted: true), + eg.subscription(eg.stream(streamId: 3, name: 'Alpha Stream'), pinToTop: true, isMuted: false), + eg.subscription(eg.stream(streamId: 4, name: 'Beta Stream'), pinToTop: true, isMuted: true), + eg.subscription(eg.stream(streamId: 5, name: '🌟 Star Stream'), pinToTop: false, isMuted: false), + eg.subscription(eg.stream(streamId: 6, name: '🔥 Fire Stream'), pinToTop: false, isMuted: true), + eg.subscription(eg.stream(streamId: 7, name: 'Gamma Stream'), pinToTop: false, isMuted: false), + eg.subscription(eg.stream(streamId: 8, name: 'Delta Stream'), pinToTop: false, isMuted: true), + ]); + check(listedStreamIds(tester)).deepEquals([1, 3, 2, 4, 5, 7, 6, 8]); + }); }); testWidgets('unread badge shows with unreads', (tester) async { final stream = eg.stream(); final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), ]); await setupStreamListPage(tester, subscriptions: [ eg.subscription(stream), @@ -167,14 +190,14 @@ void main() { testWidgets('unread badge counts unmuted only', (tester) async { final stream = eg.stream(); final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), ]); await setupStreamListPage(tester, subscriptions: [eg.subscription(stream, isMuted: true)], userTopics: [UserTopicItem( streamId: stream.streamId, - topicName: 'b', + topicName: eg.t('b'), lastUpdated: 1234567890, visibilityPolicy: UserTopicVisibilityPolicy.unmuted, )], @@ -198,7 +221,7 @@ void main() { testWidgets('muted unread badge shows when unreads are visible in channel but not inbox', (tester) async { final stream = eg.stream(); final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), ]); await setupStreamListPage(tester, subscriptions: [eg.subscription(stream, isMuted: true)], @@ -211,7 +234,7 @@ void main() { testWidgets('muted unread badge does not show when unreads are visible in both channel & inbox', (tester) async { final stream = eg.stream(); final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), ]); await setupStreamListPage(tester, subscriptions: [eg.subscription(stream, isMuted: false)], @@ -224,7 +247,7 @@ void main() { testWidgets('muted unread badge does not show when unreads are not visible in channel nor inbox', (tester) async { final stream = eg.stream(); final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), ]); await setupStreamListPage(tester, subscriptions: [eg.subscription(stream, isMuted: true)], @@ -237,7 +260,7 @@ void main() { testWidgets('color propagates to icon and badge', (tester) async { final stream = eg.stream(); final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), ]); final subscription = eg.subscription(stream, color: Colors.red.argbInt); final swatch = ChannelColorSwatch.light(subscription.color); @@ -275,8 +298,8 @@ void main() { eg.userTopicItem(stream2, 'b', UserTopicVisibilityPolicy.unmuted), ], unreadMsgs: eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream1.streamId, topic: 'a', unreadMessageIds: [1, 2]), - UnreadChannelSnapshot(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [3]), + eg.unreadChannelMsgs(streamId: stream1.streamId, topic: 'a', unreadMessageIds: [1, 2]), + eg.unreadChannelMsgs(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [3]), ]), ); @@ -310,10 +333,10 @@ void main() { eg.userTopicItem(mutedStreamWithNoUnmutedUnreads, 'd', UserTopicVisibilityPolicy.muted), ], unreadMsgs: eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: unmutedStreamWithUnmutedUnreads.streamId, topic: 'a', unreadMessageIds: [1]), - UnreadChannelSnapshot(streamId: unmutedStreamWithNoUnmutedUnreads.streamId, topic: 'b', unreadMessageIds: [2]), - UnreadChannelSnapshot(streamId: mutedStreamWithUnmutedUnreads.streamId, topic: 'c', unreadMessageIds: [3]), - UnreadChannelSnapshot(streamId: mutedStreamWithNoUnmutedUnreads.streamId, topic: 'd', unreadMessageIds: [4]), + eg.unreadChannelMsgs(streamId: unmutedStreamWithUnmutedUnreads.streamId, topic: 'a', unreadMessageIds: [1]), + eg.unreadChannelMsgs(streamId: unmutedStreamWithNoUnmutedUnreads.streamId, topic: 'b', unreadMessageIds: [2]), + eg.unreadChannelMsgs(streamId: mutedStreamWithUnmutedUnreads.streamId, topic: 'c', unreadMessageIds: [3]), + eg.unreadChannelMsgs(streamId: mutedStreamWithNoUnmutedUnreads.streamId, topic: 'd', unreadMessageIds: [4]), ]), ); diff --git a/test/widgets/test_app.dart b/test/widgets/test_app.dart index 0a358484c4..e431aeaf23 100644 --- a/test/widgets/test_app.dart +++ b/test/widgets/test_app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:zulip/generated/l10n/zulip_localizations.dart'; +import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/theme.dart'; @@ -9,12 +10,29 @@ class TestZulipApp extends StatelessWidget { const TestZulipApp({ super.key, this.accountId, + this.skipAssertAccountExists = false, this.navigatorObservers, this.child = const Placeholder(), - }); + }) : assert(!skipAssertAccountExists || accountId != null); final int? accountId; + /// Whether to proceed if [accountId] doesn't have an [Account] in the store. + /// + /// If this widget's [GlobalStoreWidget] loads + /// with no [Account] for [accountId], + /// [build] will error unless this param is true. + /// + /// Usually, this case is just a mistake; + /// the caller either forgot to add the account to the store + /// or they didn't want to simulate a per-account context in the first place. + /// + /// Sometimes we want to simulate an account's UI + /// just after the account is logged out (so is absent in the store) + /// but before we tear down that UI. + /// Pass true to silence the assertion in that case. + final bool skipAssertAccountExists; + /// A list to pass through to [MaterialApp.navigatorObservers]. final List? navigatorObservers; @@ -27,8 +45,31 @@ class TestZulipApp extends StatelessWidget { @override Widget build(BuildContext context) { - return GlobalStoreWidget( - child: MaterialApp( + return GlobalStoreWidget(child: Builder(builder: (context) { + assert(() { + if (accountId != null && !skipAssertAccountExists) { + final account = GlobalStoreWidget.of(context).getAccount(accountId!); + if (account == null) { + throw FlutterError.fromParts([ + ErrorSummary( + 'TestZulipApp() was called with [accountId] but a corresponding ' + 'Account was not found in the GlobalStore.'), + ErrorHint( + 'If [child] needs per-account data, consider calling ' + '`testBinding.globalStore.add` before pumping `TestZulipApp`.'), + ErrorHint( + 'If [child] is not specific to an account, omit [accountId].'), + ErrorHint( + 'If you are testing behavior when an account is logged out, ' + 'consider building ZulipApp instead of TestZulipApp, ' + 'or pass `skipAssertAccountExists: true`.'), + ]); + } + } + return true; + }()); + + return MaterialApp( title: 'Zulip', localizationsDelegates: ZulipLocalizations.localizationsDelegates, supportedLocales: ZulipLocalizations.supportedLocales, @@ -37,8 +78,9 @@ class TestZulipApp extends StatelessWidget { navigatorObservers: navigatorObservers ?? const [], home: accountId != null - ? PerAccountStoreWidget(accountId: accountId!, child: child) - : child, - )); + ? PerAccountStoreWidget(accountId: accountId!, + child: PageRoot(child: child)) + : PageRoot(child: child)); + })); } } diff --git a/tools/customer-testing/setup.sh b/tools/customer-testing/setup.sh new file mode 100755 index 0000000000..4e7659c7c2 --- /dev/null +++ b/tools/customer-testing/setup.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Setup script for running Zulip's tests as part of the Flutter +# "customer testing" suite: +# https://github.com/flutter/tests + +set -euo pipefail + +# Flutter's "customer testing" suite runs in two environments: +# * GitHub Actions for changes to the flutter/tests tree itself +# (which is just a registry of downstream test suites to run); +# * LUCI, at ci.chromium.org, for changes in Flutter. +# +# For background, see: +# https://github.com/flutter/flutter/issues/162041#issuecomment-2611129958 + +if ! sudo -v; then + # In the LUCI environment sudo isn't available, + # but also the setup below isn't needed. Skip it. + exit 0 +fi + +# Otherwise, assume we're in the GitHub Actions environment. + + +# Install libsqlite3-dev. +# +# A few Zulip tests use SQLite, and so need libsqlite3.so. +# (The actual databases involved are tiny and in-memory.) +# +# Both older and newer GitHub Actions images have the SQLite shared +# library, from the libsqlite3-0 package. But newer images +# (ubuntu-24.04, which the ubuntu-latest alias switched to around +# 2025-01) no longer have a symlink "libsqlite3.so" pointing to it, +# which is part of the libsqlite3-dev package. Install that. +sudo apt install -y libsqlite3-dev