diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ae781c17d..4ff2bfbe7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,12 @@ jobs: # so that Flutter knows its version and sees the constraint in our # pubspec is satisfied. It's uncommon for flutter/flutter to go # more than 100 commits between tags. Fetch 1000 for good measure. + # TODO(upstream): Around 2025-05, Flutter upstream stopped making + # tags within the main/master branch. Get that fixed: + # https://github.com/zulip/zulip-flutter/issues/1710 + # Pending that, fetch more than 1000 commits. run: | - git clone --depth=1000 -b main https://github.com/flutter/flutter ~/flutter + git clone --depth=3000 -b main https://github.com/flutter/flutter ~/flutter TZ=UTC git --git-dir ~/flutter/.git log -1 --format='%h | %ci | %s' --date=iso8601-local echo ~/flutter/bin >> "$GITHUB_PATH" diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index b0e934e287..7703fd6ec1 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -1,19 +1,54 @@ name: Update translations from Weblate +permissions: + contents: write + pull-requests: write on: schedule: - cron: "0 10 * * 1" workflow_dispatch: + jobs: update-translations: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Fetch and merge from Weblate # The commit message is generated in Weblate; see https://hosted.weblate.org/addon/17163/ run: | git remote add weblate https://hosted.weblate.org/git/zulip/zulip-flutter/ git fetch weblate - git merge --ff-only weblate/main + # This may lag behind `main` if weblate is backlogged; this can + # theoretically cause the PR to not be able to auto-merged, though + # re-running the action once weblate has caught up should be + # sufficient to fix that. + git reset --hard weblate/main + + - name: Clone Flutter SDK + # 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. + # TODO(upstream): See ci.yml for why we fetch more than 1000. + run: | + git clone --depth=3000 -b main https://github.com/flutter/flutter ~/flutter + TZ=UTC git --git-dir ~/flutter/.git log -1 --format='%h | %ci | %s' --date=iso8601-local + echo ~/flutter/bin >> "$GITHUB_PATH" + + # 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: diff --git a/.mailmap b/.mailmap index 84989ea84a..ce5fadf069 100644 --- a/.mailmap +++ b/.mailmap @@ -12,11 +12,16 @@ # # shows raw names/emails, filtered by mapped name: # $ git log --format='%an %ae' --author=$NAME | uniq -c +Brynly Mitchell +Chinmay Ajith Chris Bobbe Greg Price +K Akhil +Lakshya Goel Lalit Kumar Singh -Rajesh Malviya +Rajesh Malviya Shu Chen +Tomlin7 # The goal when editing this file is to group all of a given person's # contributions together, and under their preferred name and email diff --git a/README.md b/README.md index 1bcc1b00d2..4af1b6f42b 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,25 @@ -# Zulip Flutter (beta) +# Zulip Flutter -A Zulip client for Android and iOS, using Flutter. +The official Zulip app for Android and iOS, built with Flutter. -This app is currently [in beta][beta]. -When it's ready, it [will become the new][] official mobile Zulip client. -To see what work is planned before that launch, -see the [milestones][] and the [project board][]. - -[beta]: https://chat.zulip.org/#narrow/stream/2-general/topic/Flutter/near/1708728 -[will become the new]: https://chat.zulip.org/#narrow/stream/2-general/topic/Flutter/near/1582367 -[milestones]: https://github.com/zulip/zulip-flutter/milestones?direction=asc&sort=title -[project board]: https://github.com/orgs/zulip/projects/5/views/4 +This app [was launched][] as the main Zulip mobile app +in June 2025. +It replaced the [previous Zulip mobile app][] built with React Native. +[was launched]: https://blog.zulip.com/flutter-mobile-app-launch +[previous Zulip mobile app]: https://github.com/zulip/zulip-mobile#readme -## Using Zulip -To use Zulip on iOS or Android, install the [official mobile Zulip client][]. +## Get the app -You can also [try out this beta app][beta]. - -[official mobile Zulip client]: https://github.com/zulip/zulip-mobile#readme +Release versions of the app are available here: +* [Zulip for iOS](https://apps.apple.com/app/zulip/id1203036395) + on Apple's App Store +* [Zulip for Android](https://play.google.com/store/apps/details?id=com.zulipmobile) + on the Google Play Store + * Or if you don't use Google Play, you can + [download an APK](https://github.com/zulip/zulip-flutter/releases/latest) + from the official build we post on GitHub. ## Contributing @@ -27,10 +27,10 @@ You can also [try out this beta app][beta]. Contributions to this app are welcome. If you're looking to participate in Google Summer of Code with Zulip, -this is one of the projects we're [accepting GSoC 2024 applications][] -for. +this was among the projects we accepted [GSoC applications][gsoc] for +in 2024 and 2025. -[accepting GSoC 2024 applications]: https://zulip.readthedocs.io/en/latest/outreach/gsoc.html#mobile-app +[gsoc]: https://zulip.readthedocs.io/en/latest/outreach/gsoc.html#mobile-app ### Picking an issue to work on @@ -42,7 +42,7 @@ browsing through recent commits and the codebase, and the Zulip guide to Git. To find possible issues to work on, see our [project board][]. -Look for issues up through the "Launch" milestone, +Look for issues in the earliest milestone, and that aren't already assigned. Follow the Zulip guide to [picking an issue to work on][], @@ -55,6 +55,7 @@ and describing your progress. [your first codebase contribution]: https://zulip.readthedocs.io/en/latest/contributing/contributing.html#your-first-codebase-contribution [what makes a great Zulip contributor]: https://zulip.readthedocs.io/en/latest/contributing/contributing.html#what-makes-a-great-zulip-contributor +[project board]: https://github.com/orgs/zulip/projects/5/views/4 [picking an issue to work on]: https://zulip.readthedocs.io/en/latest/contributing/contributing.html#picking-an-issue-to-work-on @@ -96,6 +97,8 @@ Two specific points to expand on: * Your changes will need to be organized into [clear and coherent commits][commit-style], following [Zulip's commit style guide][commit-style]. + (Use Greg's ["secret" to using `git log -p`][git-log-p-secret] + and/or a graphical Git client to see examples of mergeable commits.) This is always required before we can merge your PR. Depending on your changes' complexity, it may also be required before we can @@ -106,9 +109,10 @@ Two specific points to expand on: [working on an issue]: https://zulip.readthedocs.io/en/latest/contributing/contributing.html#working-on-an-issue [submitting a pull request]: https://zulip.readthedocs.io/en/latest/contributing/review-process.html [commit-style]: https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html +[git-log-p-secret]: https://github.com/zulip/zulip-mobile/blob/main/docs/howto/git.md#git-log-secret -## Getting started in developing this beta app +## Getting started in developing ### Setting up @@ -289,7 +293,7 @@ good time to [report them as issues][dart-test-tracker]. #### Server compatibility -We support Zulip Server 4.0 and later. +We support Zulip Server 7.0 and later. For API features added in newer versions, use `TODO(server-N)` comments (like those you see in the existing code.) diff --git a/android/app/build.gradle b/android/app/build.gradle index f360f667e5..a71f04a296 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -29,16 +29,12 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = '1.8' - } - sourceSets { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { - applicationId "com.zulip.flutter" + applicationId "com.zulipmobile" minSdkVersion 28 targetSdkVersion flutter.targetSdkVersion // These are synced to local.properties from pubspec.yaml by the flutter tool. @@ -77,17 +73,25 @@ android { // https://developer.android.com/reference/tools/gradle-api/8.5/com/android/build/api/dsl/Lint checkAllWarnings = true warningsAsErrors = true + baseline = file("lint-baseline.xml") + disable += ['AndroidGradlePluginVersion'] + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } } -tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile).configureEach { // Compile Kotlin with `-Werror`... but only in release builds, so that it // doesn't get in the way of quick local experiments for debugging. // // The string-searching makes this a bit of a mess, but it works. // Better would be if we can add this to android.buildTypes.release above; // but on a first attempt that didn't work (it affected debug builds too). - kotlinOptions.allWarningsAsErrors = name.contains("Release") + compilerOptions.allWarningsAsErrors = name.contains("Release") } flutter { diff --git a/android/app/lint-baseline.xml b/android/app/lint-baseline.xml new file mode 100644 index 0000000000..be85415f4e --- /dev/null +++ b/android/app/lint-baseline.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a0f602e899..2624d97b6a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,7 +5,7 @@ + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntentEventListener.kt b/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntentEventListener.kt new file mode 100644 index 0000000000..a5f66343a5 --- /dev/null +++ b/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntentEventListener.kt @@ -0,0 +1,112 @@ +package com.zulip.flutter + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.OpenableColumns + +class AndroidIntentEventListener : AndroidIntentEventsStreamHandler() { + private var eventSink: PigeonEventSink? = null + private val buffer = mutableListOf() + + override fun onListen(p0: Any?, sink: PigeonEventSink) { + eventSink = sink + buffer.forEach { eventSink!!.success(it) } + } + + private fun onEvent(event: AndroidIntentEvent) { + if (eventSink != null) { + eventSink?.success(event) + } else { + buffer.add(event) + } + } + + fun handleSend(context: Context, intent: Intent) { + val intentAction = intent.action + assert( + intentAction == Intent.ACTION_SEND + || intentAction == Intent.ACTION_SEND_MULTIPLE + ) + + // EXTRA_TEXT and EXTRA_STREAM are the text and file components of the + // content, respectively. The ACTION_SEND{,_MULTIPLE} docs say + // "either" / "or" will be present: + // https://developer.android.com/reference/android/content/Intent#ACTION_SEND + // But empirically both can be present, commonly, so we accept that form, + // interpreting it as an intent to share both kinds of data. + // + // Empirically, sometimes EXTRA_TEXT isn't something we think needs to be + // shared, like the URL of a file that's present in EXTRA_STREAM… but we + // shrug and include it anyway because we don't want to second-guess other + // apps' decisions about what to include; it's their responsibility. + + val extraText = intent.getStringExtra(Intent.EXTRA_TEXT) + val extraStream = when (intentAction) { + Intent.ACTION_SEND -> { + var extraStream: List? = null + // TODO(android-sdk-33) Remove the use of deprecated API. + @Suppress("DEPRECATION") val url = intent.getParcelableExtra(Intent.EXTRA_STREAM) + if (url != null) { + extraStream = listOf(getIntentSharedFile(context, url)) + } + extraStream + } + + Intent.ACTION_SEND_MULTIPLE -> { + var extraStream: MutableList? = null + // TODO(android-sdk-33) Remove the use of deprecated API. + @Suppress("DEPRECATION") val urls = + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + if (urls != null) { + extraStream = mutableListOf() + for (url in urls) { + val sharedFile = getIntentSharedFile(context, url) + extraStream.add(sharedFile) + } + } + extraStream + } + + else -> throw IllegalArgumentException("Unexpected value for intent.action: $intentAction") + } + + if (extraText == null && extraStream == null) { + throw Exception("Got unexpected ACTION_SEND* intent, with neither EXTRA_TEXT nor EXTRA_STREAM") + } + + onEvent( + AndroidIntentSendEvent( + action = intentAction, + extraText = extraText, + extraStream = extraStream, + ) + ) + } +} + +// A helper function to retrieve the shared file from the `content://` URL +// from the ACTION_SEND{_MULTIPLE} intent. +fun getIntentSharedFile(context: Context, url: Uri): IntentSharedFile { + val contentResolver = context.contentResolver + val mimeType = contentResolver.getType(url) + val name = contentResolver.query(url, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.getString(nameIndex) + } + + class ResolverFailedException(msg: String) : RuntimeException(msg) + + val bytes = (contentResolver.openInputStream(url) + ?: throw ResolverFailedException("resolver.open… failed")) + .use { inputStream -> + inputStream.readBytes() + } + + return IntentSharedFile( + name = name, + mimeType = mimeType, + bytes = bytes + ) +} diff --git a/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntents.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntents.g.kt new file mode 100644 index 0000000000..f7b3ff4287 --- /dev/null +++ b/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntents.g.kt @@ -0,0 +1,203 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +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 +private object AndroidIntentsPigeonUtils { + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class IntentSharedFile ( + val name: String? = null, + val mimeType: String? = null, + val bytes: ByteArray +) + { + companion object { + fun fromList(pigeonVar_list: List): IntentSharedFile { + val name = pigeonVar_list[0] as String? + val mimeType = pigeonVar_list[1] as String? + val bytes = pigeonVar_list[2] as ByteArray + return IntentSharedFile(name, mimeType, bytes) + } + } + fun toList(): List { + return listOf( + name, + mimeType, + bytes, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is IntentSharedFile) { + return false + } + if (this === other) { + return true + } + return AndroidIntentsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Generated class from Pigeon that represents data sent in messages. + * This class should not be extended by any user class outside of the generated file. + */ +sealed class AndroidIntentEvent +/** Generated class from Pigeon that represents data sent in messages. */ +data class AndroidIntentSendEvent ( + val action: String, + val extraText: String? = null, + val extraStream: List? = null +) : AndroidIntentEvent() + { + companion object { + fun fromList(pigeonVar_list: List): AndroidIntentSendEvent { + val action = pigeonVar_list[0] as String + val extraText = pigeonVar_list[1] as String? + val extraStream = pigeonVar_list[2] as List? + return AndroidIntentSendEvent(action, extraText, extraStream) + } + } + fun toList(): List { + return listOf( + action, + extraText, + extraStream, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is AndroidIntentSendEvent) { + return false + } + if (this === other) { + return true + } + return AndroidIntentsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} +private open class AndroidIntentsPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + IntentSharedFile.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + AndroidIntentSendEvent.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is IntentSharedFile -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is AndroidIntentSendEvent -> { + stream.write(130) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + +val AndroidIntentsPigeonMethodCodec = StandardMethodCodec(AndroidIntentsPigeonCodec()) + + +private class AndroidIntentsPigeonStreamHandler( + val wrapper: AndroidIntentsPigeonEventChannelWrapper +) : EventChannel.StreamHandler { + var pigeonSink: PigeonEventSink? = null + + override fun onListen(p0: Any?, sink: EventChannel.EventSink) { + pigeonSink = PigeonEventSink(sink) + wrapper.onListen(p0, pigeonSink!!) + } + + override fun onCancel(p0: Any?) { + pigeonSink = null + wrapper.onCancel(p0) + } +} + +interface AndroidIntentsPigeonEventChannelWrapper { + open fun onListen(p0: Any?, sink: PigeonEventSink) {} + + open fun onCancel(p0: Any?) {} +} + +class PigeonEventSink(private val sink: EventChannel.EventSink) { + fun success(value: T) { + sink.success(value) + } + + fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + sink.error(errorCode, errorMessage, errorDetails) + } + + fun endOfStream() { + sink.endOfStream() + } +} + +abstract class AndroidIntentEventsStreamHandler : AndroidIntentsPigeonEventChannelWrapper { + companion object { + fun register(messenger: BinaryMessenger, streamHandler: AndroidIntentEventsStreamHandler, instanceName: String = "") { + var channelName: String = "dev.flutter.pigeon.zulip.AndroidIntentsEventChannelApi.androidIntentEvents" + if (instanceName.isNotEmpty()) { + channelName += ".$instanceName" + } + val internalStreamHandler = AndroidIntentsPigeonStreamHandler(streamHandler) + EventChannel(messenger, channelName, AndroidIntentsPigeonMethodCodec).setStreamHandler(internalStreamHandler) + } + } +} + diff --git a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt similarity index 83% rename from android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt rename to android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt index 2070473b96..7422c2ebdd 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v22.7.2), do not edit directly. +// Autogenerated from Pigeon (v26.0.0), do not edit directly. // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -13,25 +13,57 @@ import io.flutter.plugin.common.StandardMethodCodec import io.flutter.plugin.common.StandardMessageCodec import java.io.ByteArrayOutputStream import java.nio.ByteBuffer +private object AndroidNotificationsPigeonUtils { -private fun wrapResult(result: Any?): List { - return listOf(result) -} + fun wrapResult(result: Any?): List { + return listOf(result) + } -private fun wrapError(exception: Throwable): List { - return if (exception is FlutterError) { - listOf( - exception.code, - exception.message, - exception.details - ) - } else { - listOf( - exception.javaClass.simpleName, - exception.toString(), - "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) - ) + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b } + } /** @@ -89,6 +121,16 @@ data class NotificationChannel ( vibrationPattern, ) } + override fun equals(other: Any?): Boolean { + if (other !is NotificationChannel) { + return false + } + if (this === other) { + return true + } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() } /** @@ -122,6 +164,16 @@ data class AndroidIntent ( flags, ) } + override fun equals(other: Any?): Boolean { + if (other !is AndroidIntent) { + return false + } + if (this === other) { + return true + } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() } /** @@ -156,6 +208,16 @@ data class PendingIntent ( flags, ) } + override fun equals(other: Any?): Boolean { + if (other !is PendingIntent) { + return false + } + if (this === other) { + return true + } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() } /** @@ -180,6 +242,16 @@ data class InboxStyle ( summaryText, ) } + override fun equals(other: Any?): Boolean { + if (other !is InboxStyle) { + return false + } + if (this === other) { + return true + } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() } /** @@ -220,6 +292,16 @@ data class Person ( name, ) } + override fun equals(other: Any?): Boolean { + if (other !is Person) { + return false + } + if (this === other) { + return true + } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() } /** @@ -250,6 +332,16 @@ data class MessagingStyleMessage ( person, ) } + override fun equals(other: Any?): Boolean { + if (other !is MessagingStyleMessage) { + return false + } + if (this === other) { + return true + } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() } /** @@ -283,6 +375,16 @@ data class MessagingStyle ( isGroupConversation, ) } + override fun equals(other: Any?): Boolean { + if (other !is MessagingStyle) { + return false + } + if (this === other) { + return true + } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() } /** @@ -310,6 +412,16 @@ data class Notification ( extras, ) } + override fun equals(other: Any?): Boolean { + if (other !is Notification) { + return false + } + if (this === other) { + return true + } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() } /** @@ -340,6 +452,16 @@ data class StatusBarNotification ( notification, ) } + override fun equals(other: Any?): Boolean { + if (other !is StatusBarNotification) { + return false + } + if (this === other) { + return true + } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() } /** @@ -380,8 +502,18 @@ data class StoredNotificationSound ( contentUrl, ) } + override fun equals(other: Any?): Boolean { + if (other !is StoredNotificationSound) { + return false + } + if (this === other) { + return true + } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() } -private open class NotificationsPigeonCodec : StandardMessageCodec() { +private open class AndroidNotificationsPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { 129.toByte() -> { @@ -589,7 +721,7 @@ interface AndroidNotificationHostApi { companion object { /** The codec used by AndroidNotificationHostApi. */ val codec: MessageCodec by lazy { - NotificationsPigeonCodec() + AndroidNotificationsPigeonCodec() } /** Sets up an instance of `AndroidNotificationHostApi` to handle messages through the `binaryMessenger`. */ @JvmOverloads @@ -605,7 +737,7 @@ interface AndroidNotificationHostApi { api.createNotificationChannel(channelArg) listOf(null) } catch (exception: Throwable) { - wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -620,7 +752,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.getNotificationChannels()) } catch (exception: Throwable) { - wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -638,7 +770,7 @@ interface AndroidNotificationHostApi { api.deleteNotificationChannel(channelIdArg) listOf(null) } catch (exception: Throwable) { - wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -653,7 +785,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.listStoredSoundsInNotificationsDirectory()) } catch (exception: Throwable) { - wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -671,7 +803,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.copySoundResourceToMediaStore(targetFileDisplayNameArg, sourceResourceNameArg)) } catch (exception: Throwable) { - wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -703,7 +835,7 @@ interface AndroidNotificationHostApi { api.notify(tagArg, idArg, autoCancelArg, channelIdArg, colorArg, contentIntentArg, contentTextArg, contentTitleArg, extrasArg, groupKeyArg, inboxStyleArg, isGroupSummaryArg, messagingStyleArg, numberArg, smallIconResourceNameArg) listOf(null) } catch (exception: Throwable) { - wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -720,7 +852,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.getActiveNotificationMessagingStyleByTag(tagArg)) } catch (exception: Throwable) { - wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -737,7 +869,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.getActiveNotifications(desiredExtrasArg)) } catch (exception: Throwable) { - wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -756,7 +888,7 @@ interface AndroidNotificationHostApi { api.cancel(tagArg, idArg) listOf(null) } catch (exception: Throwable) { - wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } diff --git a/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt b/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt index 1829456362..cad696eecf 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt @@ -1,6 +1,42 @@ package com.zulip.flutter +import android.content.Intent import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine -class MainActivity: FlutterActivity() { +class MainActivity : FlutterActivity() { + private var androidIntentEventListener: AndroidIntentEventListener? = null + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + androidIntentEventListener = AndroidIntentEventListener() + AndroidIntentEventsStreamHandler.register( + flutterEngine.dartExecutor.binaryMessenger, + androidIntentEventListener!! + ) + maybeHandleIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + if (maybeHandleIntent(intent)) { + return + } + super.onNewIntent(intent) + } + + /** Returns true just if we did handle the intent. */ + private fun maybeHandleIntent(intent: Intent?): Boolean { + intent ?: return false + when (intent.action) { + // Share-to-Zulip + Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE -> { + androidIntentEventListener!!.handleSend(this, intent) + return true + } + + // For other intents, let Flutter handle it. + else -> return false + } + } } 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 eb332d786f..e91b7deafd 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt @@ -19,6 +19,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.graphics.drawable.IconCompat import io.flutter.embedding.engine.plugins.FlutterPlugin +import androidx.core.net.toUri private const val TAG = "ZulipPlugin" @@ -64,7 +65,7 @@ private class AndroidNotificationHost(val context: Context) channel.name?.let { setName(it) } channel.lightsEnabled?.let { setLightsEnabled(it) } channel.soundUrl?.let { - setSound(Uri.parse(it), + setSound(it.toUri(), AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION).build()) } channel.vibrationPattern?.let { setVibrationPattern(it) } @@ -199,7 +200,7 @@ private class AndroidNotificationHost(val context: Context) it.requestCode.toInt(), it.intent.let { intent -> Intent( intent.action, - Uri.parse(intent.dataUrl), + intent.dataUrl.toUri(), context, MainActivity::class.java ).apply { diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000000..847adc77a5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index 6dcde4fddf..0000000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp new file mode 100644 index 0000000000..29ac2c64df Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000000..491a79190f Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 7237bfcbcd..0000000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp new file mode 100644 index 0000000000..509773e658 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000000..a1460987f2 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 2e48d57495..0000000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp new file mode 100644 index 0000000000..baa177b2e0 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000000..b484b79c87 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index c4ddec91c9..0000000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000000..a5d6517a86 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000000..25f3ead329 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index e93b486dd0..0000000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000000..f6e4c671db Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000000..cbd20f3a9d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/android/gradle.properties b/android/gradle.properties index 2974fbcb00..416beeeb38 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx3072M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true @@ -6,11 +6,11 @@ android.enableJetifier=true # Defining them here makes them available both in # settings.gradle and in the build.gradle files. -agpVersion=8.5.2 +agpVersion=8.12.0 # Generally update this to the version found in recent releases # of Android Studio, as listed in this table: # https://kotlinlang.org/docs/releases.html#release-details # A helpful discussion is at: # https://stackoverflow.com/a/74425347 -kotlinVersion=2.0.10 +kotlinVersion=2.2.0 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 545935f33c..c8fe05f5c7 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -6,7 +6,7 @@ # the wrapper is the one from the new Gradle too.) distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/assets/KaTeX/KaTeX_AMS-Regular.ttf b/assets/KaTeX/KaTeX_AMS-Regular.ttf new file mode 100644 index 0000000000..c6f9a5e7c0 Binary files /dev/null and b/assets/KaTeX/KaTeX_AMS-Regular.ttf differ diff --git a/assets/KaTeX/KaTeX_Caligraphic-Bold.ttf b/assets/KaTeX/KaTeX_Caligraphic-Bold.ttf new file mode 100644 index 0000000000..9ff4a5e044 Binary files /dev/null and b/assets/KaTeX/KaTeX_Caligraphic-Bold.ttf differ diff --git a/assets/KaTeX/KaTeX_Caligraphic-Regular.ttf b/assets/KaTeX/KaTeX_Caligraphic-Regular.ttf new file mode 100644 index 0000000000..f522294ff0 Binary files /dev/null and b/assets/KaTeX/KaTeX_Caligraphic-Regular.ttf differ diff --git a/assets/KaTeX/KaTeX_Fraktur-Bold.ttf b/assets/KaTeX/KaTeX_Fraktur-Bold.ttf new file mode 100644 index 0000000000..4e98259c3b Binary files /dev/null and b/assets/KaTeX/KaTeX_Fraktur-Bold.ttf differ diff --git a/assets/KaTeX/KaTeX_Fraktur-Regular.ttf b/assets/KaTeX/KaTeX_Fraktur-Regular.ttf new file mode 100644 index 0000000000..b8461b275f Binary files /dev/null and b/assets/KaTeX/KaTeX_Fraktur-Regular.ttf differ diff --git a/assets/KaTeX/KaTeX_Main-Bold.ttf b/assets/KaTeX/KaTeX_Main-Bold.ttf new file mode 100644 index 0000000000..4060e627dc Binary files /dev/null and b/assets/KaTeX/KaTeX_Main-Bold.ttf differ diff --git a/assets/KaTeX/KaTeX_Main-BoldItalic.ttf b/assets/KaTeX/KaTeX_Main-BoldItalic.ttf new file mode 100644 index 0000000000..dc007977ee Binary files /dev/null and b/assets/KaTeX/KaTeX_Main-BoldItalic.ttf differ diff --git a/assets/KaTeX/KaTeX_Main-Italic.ttf b/assets/KaTeX/KaTeX_Main-Italic.ttf new file mode 100644 index 0000000000..0e9b0f354a Binary files /dev/null and b/assets/KaTeX/KaTeX_Main-Italic.ttf differ diff --git a/assets/KaTeX/KaTeX_Main-Regular.ttf b/assets/KaTeX/KaTeX_Main-Regular.ttf new file mode 100644 index 0000000000..dd45e1ed2e Binary files /dev/null and b/assets/KaTeX/KaTeX_Main-Regular.ttf differ diff --git a/assets/KaTeX/KaTeX_Math-BoldItalic.ttf b/assets/KaTeX/KaTeX_Math-BoldItalic.ttf new file mode 100644 index 0000000000..728ce7a1e2 Binary files /dev/null and b/assets/KaTeX/KaTeX_Math-BoldItalic.ttf differ diff --git a/assets/KaTeX/KaTeX_Math-Italic.ttf b/assets/KaTeX/KaTeX_Math-Italic.ttf new file mode 100644 index 0000000000..70d559b4e9 Binary files /dev/null and b/assets/KaTeX/KaTeX_Math-Italic.ttf differ diff --git a/assets/KaTeX/KaTeX_SansSerif-Bold.ttf b/assets/KaTeX/KaTeX_SansSerif-Bold.ttf new file mode 100644 index 0000000000..2f65a8a3a6 Binary files /dev/null and b/assets/KaTeX/KaTeX_SansSerif-Bold.ttf differ diff --git a/assets/KaTeX/KaTeX_SansSerif-Italic.ttf b/assets/KaTeX/KaTeX_SansSerif-Italic.ttf new file mode 100644 index 0000000000..d5850df98e Binary files /dev/null and b/assets/KaTeX/KaTeX_SansSerif-Italic.ttf differ diff --git a/assets/KaTeX/KaTeX_SansSerif-Regular.ttf b/assets/KaTeX/KaTeX_SansSerif-Regular.ttf new file mode 100644 index 0000000000..537279f6bd Binary files /dev/null and b/assets/KaTeX/KaTeX_SansSerif-Regular.ttf differ diff --git a/assets/KaTeX/KaTeX_Script-Regular.ttf b/assets/KaTeX/KaTeX_Script-Regular.ttf new file mode 100644 index 0000000000..fd679bf374 Binary files /dev/null and b/assets/KaTeX/KaTeX_Script-Regular.ttf differ diff --git a/assets/KaTeX/KaTeX_Size1-Regular.ttf b/assets/KaTeX/KaTeX_Size1-Regular.ttf new file mode 100644 index 0000000000..871fd7d19d Binary files /dev/null and b/assets/KaTeX/KaTeX_Size1-Regular.ttf differ diff --git a/assets/KaTeX/KaTeX_Size2-Regular.ttf b/assets/KaTeX/KaTeX_Size2-Regular.ttf new file mode 100644 index 0000000000..7a212caf91 Binary files /dev/null and b/assets/KaTeX/KaTeX_Size2-Regular.ttf differ diff --git a/assets/KaTeX/KaTeX_Size3-Regular.ttf b/assets/KaTeX/KaTeX_Size3-Regular.ttf new file mode 100644 index 0000000000..00bff3495f Binary files /dev/null and b/assets/KaTeX/KaTeX_Size3-Regular.ttf differ diff --git a/assets/KaTeX/KaTeX_Size4-Regular.ttf b/assets/KaTeX/KaTeX_Size4-Regular.ttf new file mode 100644 index 0000000000..74f08921f0 Binary files /dev/null and b/assets/KaTeX/KaTeX_Size4-Regular.ttf differ diff --git a/assets/KaTeX/KaTeX_Typewriter-Regular.ttf b/assets/KaTeX/KaTeX_Typewriter-Regular.ttf new file mode 100644 index 0000000000..c83252c571 Binary files /dev/null and b/assets/KaTeX/KaTeX_Typewriter-Regular.ttf differ diff --git a/assets/KaTeX/LICENSE b/assets/KaTeX/LICENSE new file mode 100644 index 0000000000..37c6433e3b --- /dev/null +++ b/assets/KaTeX/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013-2020 Khan Academy and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/Noto_Color_Emoji/Noto-COLRv1.ttf b/assets/Noto_Color_Emoji/Noto-COLRv1.ttf index 1b34705704..29d500e467 100644 Binary files a/assets/Noto_Color_Emoji/Noto-COLRv1.ttf and b/assets/Noto_Color_Emoji/Noto-COLRv1.ttf differ diff --git a/assets/app-icons/zulip-combined.svg b/assets/app-icons/zulip-combined.svg new file mode 100644 index 0000000000..aab26af6d8 --- /dev/null +++ b/assets/app-icons/zulip-combined.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/app-icons/zulip-gradient.svg b/assets/app-icons/zulip-gradient.svg new file mode 100644 index 0000000000..11da1f6eb4 --- /dev/null +++ b/assets/app-icons/zulip-gradient.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/app-icons/zulip-white-z-on-transparent.svg b/assets/app-icons/zulip-white-z-on-transparent.svg new file mode 100644 index 0000000000..0e681076a9 --- /dev/null +++ b/assets/app-icons/zulip-white-z-on-transparent.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 15fce5b25a..c753a15308 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/arrow_left_right.svg b/assets/icons/arrow_left_right.svg new file mode 100644 index 0000000000..72a9bb99d5 --- /dev/null +++ b/assets/icons/arrow_left_right.svg @@ -0,0 +1,4 @@ + + + + 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_check.svg b/assets/icons/check_check.svg new file mode 100644 index 0000000000..3d7b4a59d6 --- /dev/null +++ b/assets/icons/check_check.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/assets/icons/check_circle_checked.svg b/assets/icons/check_circle_checked.svg new file mode 100644 index 0000000000..df4b5694a0 --- /dev/null +++ b/assets/icons/check_circle_checked.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/check_circle_unchecked.svg b/assets/icons/check_circle_unchecked.svg new file mode 100644 index 0000000000..f60d58ca9f --- /dev/null +++ b/assets/icons/check_circle_unchecked.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/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/chevron_down.svg b/assets/icons/chevron_down.svg new file mode 100644 index 0000000000..43d3f6b84f --- /dev/null +++ b/assets/icons/chevron_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/circle_x.svg b/assets/icons/circle_x.svg new file mode 100644 index 0000000000..a364d54a81 --- /dev/null +++ b/assets/icons/circle_x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/edit.svg b/assets/icons/edit.svg new file mode 100644 index 0000000000..0c220a4240 --- /dev/null +++ b/assets/icons/edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/eye.svg b/assets/icons/eye.svg new file mode 100644 index 0000000000..c5cc095bbe --- /dev/null +++ b/assets/icons/eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/eye_off.svg b/assets/icons/eye_off.svg new file mode 100644 index 0000000000..cc2c3587d7 --- /dev/null +++ b/assets/icons/eye_off.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/link.svg b/assets/icons/link.svg new file mode 100644 index 0000000000..0d560f15ed --- /dev/null +++ b/assets/icons/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/message_checked.svg b/assets/icons/message_checked.svg new file mode 100644 index 0000000000..5c598ae87e --- /dev/null +++ b/assets/icons/message_checked.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/person.svg b/assets/icons/person.svg new file mode 100644 index 0000000000..6a35686e46 --- /dev/null +++ b/assets/icons/person.svg @@ -0,0 +1,10 @@ + + + diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg new file mode 100644 index 0000000000..a5b1b7e078 --- /dev/null +++ b/assets/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/remove.svg b/assets/icons/remove.svg new file mode 100644 index 0000000000..dcb1763c46 --- /dev/null +++ b/assets/icons/remove.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/search.svg b/assets/icons/search.svg new file mode 100644 index 0000000000..171e4109ec --- /dev/null +++ b/assets/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/see_who_reacted.svg b/assets/icons/see_who_reacted.svg new file mode 100644 index 0000000000..78c2a48063 --- /dev/null +++ b/assets/icons/see_who_reacted.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg new file mode 100644 index 0000000000..202eb2deaf --- /dev/null +++ b/assets/icons/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/topics.svg b/assets/icons/topics.svg new file mode 100644 index 0000000000..c07afa80b3 --- /dev/null +++ b/assets/icons/topics.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/user.svg b/assets/icons/two_person.svg similarity index 100% rename from assets/icons/user.svg rename to assets/icons/two_person.svg diff --git a/assets/l10n/app_ar.arb b/assets/l10n/app_ar.arb index 5ca1208723..b9c2bd611b 100644 --- a/assets/l10n/app_ar.arb +++ b/assets/l10n/app_ar.arb @@ -1,11 +1,56 @@ { + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "@wildcardMentionAll": {}, + "@wildcardMentionAllDmDescription": {}, + "@wildcardMentionChannel": {}, + "@wildcardMentionChannelDescription": {}, + "@wildcardMentionEveryone": {}, + "@wildcardMentionStream": {}, + "@wildcardMentionStreamDescription": {}, + "@wildcardMentionTopic": {}, + "@wildcardMentionTopicDescription": {}, + "aboutPageAppVersion": "نسخة التطبيق", + "aboutPageOpenSourceLicenses": "10.0.151.1", + "aboutPageTapToView": "اضغط للعرض", + "aboutPageTitle": "عن زوليب", + "chooseAccountPageTitle": "اختر حساب", + "settingsPageTitle": "الإعدادات", + "switchAccountButton": "تبديل الحساب", + "upgradeWelcomeDialogDismiss": "هيا بنا", + "upgradeWelcomeDialogTitle": "أهلا بك في تطبيق زوليب الجديد !", "wildcardMentionAll": "الجميع", - "wildcardMentionEveryone": "الكل", + "wildcardMentionAllDmDescription": "إخطار المستلمين", "wildcardMentionChannel": "القناة", - "wildcardMentionStream": "الدفق", - "wildcardMentionTopic": "الموضوع", "wildcardMentionChannelDescription": "إخطار القناة", + "wildcardMentionEveryone": "الكل", + "wildcardMentionStream": "الدفق", "wildcardMentionStreamDescription": "إخطار الدفق", - "wildcardMentionAllDmDescription": "إخطار المستلمين", + "wildcardMentionTopic": "الموضوع", "wildcardMentionTopicDescription": "إخطار الموضوع" } diff --git a/assets/l10n/app_de.arb b/assets/l10n/app_de.arb new file mode 100644 index 0000000000..f2d6f55960 --- /dev/null +++ b/assets/l10n/app_de.arb @@ -0,0 +1,1518 @@ +{ + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "@actionSheetOptionChannelFeed": { + "description": "Label for navigating to a channel's channel-feed page." + }, + "@actionSheetOptionCopyChannelLink": { + "description": "Label for copy channel link button on action sheet." + }, + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "@actionSheetOptionCopyTopicLink": { + "description": "Label for copy topic link button in action sheet." + }, + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "@actionSheetOptionSeeWhoReacted": { + "description": "Label for the 'See who reacted' button in the message action sheet." + }, + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "@actionSheetOptionSubscribe": { + "description": "Label in the channel action sheet for subscribing to the channel." + }, + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "@actionSheetOptionUnsubscribe": { + "description": "Label in the channel action sheet for unsubscribing from the channel." + }, + "@actionSheetOptionViewReadReceipts": { + "description": "Label for the 'View read receipts' button in the message action sheet." + }, + "@actionSheetReadReceipts": { + "description": "Title for the \"Read receipts\" bottom sheet." + }, + "@actionSheetReadReceiptsErrorReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when loading read receipts failed." + }, + "@actionSheetReadReceiptsReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when one or more people have read the message.", + "placeholders": { + "count": { + "example": "1", + "type": "int" + } + } + }, + "@actionSheetReadReceiptsZeroReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when no one has read the message." + }, + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "example": "#channel name > topic name", + "type": "String" + } + } + }, + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "example": "channel name", + "type": "String" + } + } + }, + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "example": "general chat", + "type": "String" + } + } + }, + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "example": "1234", + "type": "int" + } + } + }, + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "example": "user@example.com", + "type": "String" + }, + "server": { + "example": "https://example.com", + "type": "String" + } + } + }, + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "error": { + "example": "Invalid format", + "type": "String" + }, + "serverUrl": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "@errorContentNotInsertedTitle": { + "description": "Title for error dialog when an attempt to insert rich content failed." + }, + "@errorContentToInsertIsEmpty": { + "description": "Error message when the rich content to be inserted is empty or cannot be accessed." + }, + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "listMessage": { + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB", + "type": "String" + }, + "maxFileUploadSizeMib": { + "example": "15", + "type": "int" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "error": { + "example": "Unexpected null value", + "type": "String" + }, + "event": { + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')", + "type": "String" + }, + "serverUrl": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + } + }, + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "details": { + "example": "type 'Null' is not a subtype of type 'String' in type cast", + "type": "String" + }, + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "example": "500", + "type": "int" + } + } + }, + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "example": "Invalid format", + "type": "String" + } + } + }, + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "minSupportedZulipVersion": { + "example": "4.0", + "type": "String" + }, + "url": { + "example": "http://chat.example.com/", + "type": "String" + }, + "zulipVersion": { + "example": "3.2", + "type": "String" + } + } + }, + "@errorSharingAccountNotLoggedIn": { + "description": "Error title when sharing content received from other apps fails, when there is no account logged in" + }, + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "@errorSharingTitle": { + "description": "Error title when sharing content received from other apps fails" + }, + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "example": "foo.txt", + "type": "String" + }, + "size": { + "example": "20.2", + "type": "String" + } + } + }, + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "@loginPageTitle": { + "description": "Title for login page." + }, + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "@noStatusText": { + "description": "The text part of the status button sub-label in self-user profile page when status text is not set." + }, + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "numOthers": { + "example": "4", + "type": "int" + }, + "senderFullName": { + "example": "Alice", + "type": "String" + } + } + }, + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "@pollVoterNames": { + "description": "The list of people who voted for a poll option, wrapped in parentheses.", + "placeholders": { + "voterNames": { + "example": "Alice, Bob, Chad", + "type": "String" + } + } + }, + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "@reactionChipLabel": { + "description": "Text describing a reaction chip, with the emoji name and a list or number of votes. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "votes": { + "example": "You, Chris, Greg", + "type": "String" + } + } + }, + "@reactionChipVotesYouAndOthers": { + "description": "The number of votes on a reaction chip, where the self-user and at least one other user has voted. (An accessibility label for assistive technology.)", + "placeholders": { + "otherUsersCount": { + "example": "4", + "type": "int" + } + } + }, + "@reactionChipsLabel": { + "description": "Text identifying the container of reaction chips on a message. (An accessibility label for assistive technology.)" + }, + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." + }, + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "@seeWhoReactedSheetEmojiNameWithVoteCount": { + "description": "In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetHeaderLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.)", + "placeholders": { + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetNoReactions": { + "description": "Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened)." + }, + "@seeWhoReactedSheetUserListLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "@setStatusPageTitle": { + "description": "Title for the 'Set status' page." + }, + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "@sharePageTitle": { + "description": "Title for the page about sharing content received from other apps." + }, + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "example": "Google", + "type": "String" + } + } + }, + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "@statusButtonLabelStatusSet": { + "description": "The status button label in self-user profile page when status is set." + }, + "@statusButtonLabelStatusUnset": { + "description": "The status button label in self-user profile page when status is not set." + }, + "@statusClearButtonLabel": { + "description": "Label for the button that clears the user status, in 'Set status' page." + }, + "@statusSaveButtonLabel": { + "description": "Label for the button that saves the user status, in 'Set status' page." + }, + "@statusTextHint": { + "description": "Hint text for the status text input field in 'Set status' page." + }, + "@subscribeFailedTitle": { + "description": "Error title when subscribing to a channel failed." + }, + "@successChannelLinkCopied": { + "description": "Message when link of a channel was copied to the user's system clipboard." + }, + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "@successTopicLinkCopied": { + "description": "Message when link of a topic was copied to the user's system clipboard." + }, + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@today": { + "description": "Term to use to reference the current day." + }, + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + }, + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "otherTypist": { + "example": "Bob", + "type": "String" + }, + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "@unsubscribeConfirmationDialogConfirmButton": { + "description": "Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel." + }, + "@unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": { + "description": "Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe." + }, + "@unsubscribeConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for unsubscribing from a channel.", + "placeholders": { + "channelName": { + "example": "mobile", + "type": "String" + } + } + }, + "@unsubscribeFailedTitle": { + "description": "Error title when unsubscribing from a channel failed." + }, + "@updateStatusErrorTitle": { + "description": "Error title when updating user status failed." + }, + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "@userActiveDate": { + "description": "Indicates the date when a user was last active on Zulip (who is currently offline).\n\nThe date might be day and month if recent, or day, month, and year if less recent.", + "placeholders": { + "date": { + "example": "Aug 1, 2024", + "type": "String" + } + } + }, + "@userActiveDaysAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "days": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveHoursAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "hours": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveMinutesAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "minutes": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveNow": { + "description": "Indicates a user is currently active on Zulip (not idle or offline)" + }, + "@userActiveYesterday": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userIdle": { + "description": "Indicates a user is currently idle on Zulip (not active, but not offline)" + }, + "@userNotActiveInYear": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "@userStatusAtTheOffice": { + "description": "A suggested user status text, 'At the office'." + }, + "@userStatusBusy": { + "description": "A suggested user status text, 'Busy'." + }, + "@userStatusCommuting": { + "description": "A suggested user status text, 'Commuting'." + }, + "@userStatusInAMeeting": { + "description": "A suggested user status text, 'In a meeting'." + }, + "@userStatusOutSick": { + "description": "A suggested user status text, 'Out sick'." + }, + "@userStatusVacationing": { + "description": "A suggested user status text, 'Vacationing'." + }, + "@userStatusWorkingRemotely": { + "description": "A suggested user status text, 'Working remotely'." + }, + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "aboutPageAppVersion": "App-Version", + "aboutPageOpenSourceLicenses": "Open-Source-Lizenzen", + "aboutPageTapToView": "Antippen zum Ansehen", + "aboutPageTitle": "Über Zulip", + "actionSheetOptionChannelFeed": "Kanal-Feed", + "actionSheetOptionCopyChannelLink": "Link zum Kanal kopieren", + "actionSheetOptionCopyMessageLink": "Link zur Nachricht kopieren", + "actionSheetOptionCopyMessageText": "Nachrichtentext kopieren", + "actionSheetOptionCopyTopicLink": "Link zum Thema kopieren", + "actionSheetOptionEditMessage": "Nachricht bearbeiten", + "actionSheetOptionFollowTopic": "Thema folgen", + "actionSheetOptionHideMutedMessage": "Stummgeschaltete Nachricht wieder ausblenden", + "actionSheetOptionListOfTopics": "Themenliste", + "actionSheetOptionMarkAsUnread": "Ab hier als ungelesen markieren", + "actionSheetOptionMarkChannelAsRead": "Kanal als gelesen markieren", + "actionSheetOptionMarkTopicAsRead": "Thema als gelesen markieren", + "actionSheetOptionMuteTopic": "Thema stummschalten", + "actionSheetOptionQuoteMessage": "Nachricht zitieren", + "actionSheetOptionResolveTopic": "Als gelöst markieren", + "actionSheetOptionSeeWhoReacted": "Wer hat reagiert", + "actionSheetOptionShare": "Teilen", + "actionSheetOptionStarMessage": "Nachricht markieren", + "actionSheetOptionSubscribe": "Abonnieren", + "actionSheetOptionUnfollowTopic": "Thema entfolgen", + "actionSheetOptionUnmuteTopic": "Thema lautschalten", + "actionSheetOptionUnresolveTopic": "Als ungelöst markieren", + "actionSheetOptionUnstarMessage": "Markierung aufheben", + "actionSheetOptionUnsubscribe": "Deabonnieren", + "actionSheetOptionViewReadReceipts": "Empfangsbestätigungen ansehen", + "actionSheetReadReceipts": "Empfangsbestätigungen", + "actionSheetReadReceiptsErrorReadCount": "Laden von Empfangsbestätigungen fehlgeschlagen.", + "actionSheetReadReceiptsReadCount": "{count, plural, =1{Diese Nachricht wurde von einer Person gelesen:} other{Diese Nachricht wurde von {count} Personen gelesen:}}", + "actionSheetReadReceiptsZeroReadCount": "Niemand hat diese Nachricht bisher gelesen.", + "appVersionUnknownPlaceholder": "(…)", + "channelFeedButtonTooltip": "Kanal-Feed", + "channelsEmptyPlaceholder": "Du hast noch keine Kanäle abonniert.", + "channelsPageTitle": "Kanäle", + "chooseAccountButtonAddAnAccount": "Account hinzufügen", + "chooseAccountPageLogOutButton": "Abmelden", + "chooseAccountPageTitle": "Konto auswählen", + "combinedFeedPageTitle": "Kombinierter Feed", + "composeBoxAttachFilesTooltip": "Dateien anhängen", + "composeBoxAttachFromCameraTooltip": "Ein Foto aufnehmen", + "composeBoxAttachMediaTooltip": "Bilder oder Videos anhängen", + "composeBoxBannerButtonCancel": "Abbrechen", + "composeBoxBannerButtonSave": "Speichern", + "composeBoxBannerLabelEditMessage": "Nachricht bearbeiten", + "composeBoxChannelContentHint": "Nachricht an {destination}", + "composeBoxDmContentHint": "Nachricht an @{user}", + "composeBoxEnterTopicOrSkipHintText": "Gib ein Thema ein (leer lassen für “{defaultTopicName}”)", + "composeBoxGenericContentHint": "Eine Nachricht eingeben", + "composeBoxGroupDmContentHint": "Nachricht an Gruppe", + "composeBoxLoadingMessage": "(lade Nachricht {messageId})", + "composeBoxSelfDmContentHint": "Schreibe etwas", + "composeBoxSendTooltip": "Senden", + "composeBoxTopicHintText": "Thema", + "composeBoxUploadingFilename": "Lade {filename} hoch…", + "contentValidationErrorEmpty": "Du hast nichts zum Senden!", + "contentValidationErrorQuoteAndReplyInProgress": "Bitte warte bis das Zitat abgeschlossen ist.", + "contentValidationErrorTooLong": "Nachrichtenlänge sollte nicht größer als 10000 Zeichen sein.", + "contentValidationErrorUploadInProgress": "Bitte warte bis das Hochladen abgeschlossen ist.", + "dialogCancel": "Abbrechen", + "dialogClose": "Schließen", + "dialogContinue": "Fortsetzen", + "discardDraftConfirmationDialogConfirmButton": "Verwerfen", + "discardDraftConfirmationDialogTitle": "Die Nachricht, die du schreibst, verwerfen?", + "discardDraftForEditConfirmationDialogMessage": "Wenn du eine Nachricht bearbeitest, wird der vorherige Inhalt der Nachrichteneingabe verworfen.", + "discardDraftForOutboxConfirmationDialogMessage": "Wenn du eine nicht gesendete Nachricht wiederherstellst, wird der vorherige Inhalt der Nachrichteneingabe verworfen.", + "dmsWithOthersPageTitle": "DNs mit {others}", + "dmsWithYourselfPageTitle": "DNs mit dir selbst", + "editAlreadyInProgressMessage": "Eine Bearbeitung läuft gerade. Bitte warte bis sie abgeschlossen ist.", + "editAlreadyInProgressTitle": "Kann Nachricht nicht bearbeiten", + "emojiPickerSearchEmoji": "Emoji suchen", + "emojiReactionsMore": "mehr", + "emptyMessageList": "Hier gibt es keine Nachrichten.", + "emptyMessageListSearch": "Keine Suchergebnisse.", + "errorAccountLoggedIn": "Der Account {email} auf {server} ist bereits in deiner Account-Liste.", + "errorAccountLoggedInTitle": "Account bereits angemeldet", + "errorBannerCannotPostInChannelLabel": "Du hast keine Berechtigung in diesen Kanal zu schreiben.", + "errorBannerDeactivatedDmLabel": "Du kannst keine Nachrichten an deaktivierte Nutzer:innen senden.", + "errorConnectingToServerDetails": "Fehler beim Verbinden mit Zulip auf {serverUrl}. Wird wiederholt:\n\n{error}", + "errorConnectingToServerShort": "Fehler beim Verbinden mit Zulip. Wiederhole…", + "errorContentNotInsertedTitle": "Inhalt nicht eingefügt", + "errorContentToInsertIsEmpty": "Die einzufügende Datei ist leer oder kann nicht geöffnet werden.", + "errorCopyingFailed": "Kopieren fehlgeschlagen", + "errorCouldNotConnectTitle": "Konnte nicht verbinden", + "errorCouldNotEditMessageTitle": "Konnte Nachricht nicht bearbeiten", + "errorCouldNotFetchMessageSource": "Konnte Nachrichtenquelle nicht abrufen.", + "errorCouldNotOpenLink": "Link konnte nicht geöffnet werden: {url}", + "errorCouldNotOpenLinkTitle": "Link kann nicht geöffnet werden", + "errorCouldNotShowUserProfile": "Nutzerprofil kann nicht angezeigt werden.", + "errorDialogContinue": "OK", + "errorDialogLearnMore": "Mehr erfahren", + "errorDialogTitle": "Fehler", + "errorFailedToUploadFileTitle": "Fehler beim Upload der Datei: {filename}", + "errorFilesTooLarge": "{num, plural, =1{Datei ist} other{{num} Dateien sind}} größer als das Serverlimit von {maxFileUploadSizeMib} MiB und {num, plural, =1{wird} other{{num} werden}} nicht hochgeladen:\n\n{listMessage}", + "errorFilesTooLargeTitle": "{num, plural, =1{Datei} other{Dateien}} zu groß", + "errorFollowTopicFailed": "Konnte Thema nicht folgen", + "errorHandlingEventDetails": "Fehler beim Verarbeiten eines Zulip-Ereignisses von {serverUrl}; Wird wiederholt.\n\nFehler: {error}\n\nEreignis: {event}", + "errorHandlingEventTitle": "Fehler beim Verarbeiten eines Zulip-Ereignisses. Wiederhole Verbindung…", + "errorInvalidApiKeyMessage": "Dein Account bei {url} konnte nicht authentifiziert werden. Bitte wiederhole die Anmeldung oder verwende einen anderen Account.", + "errorInvalidResponse": "Der Server hat eine ungültige Antwort gesendet.", + "errorLoginCouldNotConnect": "Verbindung zu Server fehlgeschlagen:\n{url}", + "errorLoginFailedTitle": "Anmeldung fehlgeschlagen", + "errorLoginInvalidInputTitle": "Ungültige Eingabe", + "errorMalformedResponse": "Server lieferte fehlerhafte Antwort; HTTP Status {httpStatus}", + "errorMalformedResponseWithCause": "Server lieferte fehlerhafte Antwort; HTTP Status {httpStatus}; {details}", + "errorMarkAsReadFailedTitle": "Als gelesen markieren fehlgeschlagen", + "errorMarkAsUnreadFailedTitle": "Als ungelesen markieren fehlgeschlagen", + "errorMessageDoesNotSeemToExist": "Diese Nachricht scheint nicht zu existieren.", + "errorMessageEditNotSaved": "Nachricht nicht gespeichert", + "errorMessageNotSent": "Nachricht nicht versendet", + "errorMuteTopicFailed": "Konnte Thema nicht stummschalten", + "errorNetworkRequestFailed": "Netzwerkanfrage fehlgeschlagen", + "errorNotificationOpenAccountNotFound": "Der Account, der mit dieser Benachrichtigung verknüpft ist, konnte nicht gefunden werden.", + "errorNotificationOpenTitle": "Fehler beim Öffnen der Benachrichtigung", + "errorQuotationFailed": "Zitat fehlgeschlagen", + "errorReactionAddingFailedTitle": "Hinzufügen der Reaktion fehlgeschlagen", + "errorReactionRemovingFailedTitle": "Entfernen der Reaktion fehlgeschlagen", + "errorRequestFailed": "Netzwerkanfrage fehlgeschlagen: HTTP Status {httpStatus}", + "errorResolveTopicFailedTitle": "Thema konnte nicht als gelöst markiert werden", + "errorServerMessage": "Der Server sagte:\n\n{message}", + "errorServerVersionUnsupportedMessage": "{url} nutzt Zulip Server {zulipVersion}, welche nicht unterstützt wird. Die unterstützte Mindestversion ist Zulip Server {minSupportedZulipVersion}.", + "errorSharingAccountNotLoggedIn": "Es ist kein Konto angemeldet. Bitte logge dich in ein Konto ein und versuche es erneut.", + "errorSharingFailed": "Teilen fehlgeschlagen", + "errorSharingTitle": "Teilen des Inhalts fehlgeschlagen", + "errorStarMessageFailedTitle": "Konnte Nachricht nicht markieren", + "errorUnfollowTopicFailed": "Konnte Thema nicht entfolgen", + "errorUnmuteTopicFailed": "Konnte Thema nicht lautschalten", + "errorUnresolveTopicFailedTitle": "Thema konnte nicht als ungelöst markiert werden", + "errorUnstarMessageFailedTitle": "Konnte Markierung nicht von der Nachricht entfernen", + "errorVideoPlayerFailed": "Video konnte nicht wiedergegeben werden.", + "errorWebAuthOperationalError": "Ein unerwarteter Fehler ist aufgetreten.", + "errorWebAuthOperationalErrorTitle": "Etwas ist schiefgelaufen", + "experimentalFeatureSettingsPageTitle": "Experimentelle Funktionen", + "experimentalFeatureSettingsWarning": "Diese Optionen aktivieren Funktionen, die noch in Entwicklung und nicht bereit sind. Sie funktionieren möglicherweise nicht und können Problem in anderen Bereichen der App verursachen.\n\nDer Zweck dieser Einstellungen ist das Experimentieren der Leute, die an der Entwicklung von Zulip arbeiten.", + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "inboxEmptyPlaceholder": "Es sind keine ungelesenen Nachrichten in deinem Eingang. Verwende die Buttons unten, um den kombinierten Feed oder die Kanalliste anzusehen.", + "inboxPageTitle": "Eingang", + "initialAnchorSettingDescription": "Du kannst auswählen ob Nachrichten-Feeds bei deiner ersten ungelesenen oder bei den neuesten Nachrichten geöffnet werden.", + "initialAnchorSettingFirstUnreadAlways": "Erste ungelesene Nachricht", + "initialAnchorSettingFirstUnreadConversations": "Erste ungelesene Nachricht in Unterhaltungsansicht, sonst neueste Nachricht", + "initialAnchorSettingNewestAlways": "Neueste Nachricht", + "initialAnchorSettingTitle": "Nachrichten-Feed öffnen bei", + "invisibleMode": "Unsichtbarer Modus", + "lightboxCopyLinkTooltip": "Link kopieren", + "lightboxVideoCurrentPosition": "Aktuelle Position", + "lightboxVideoDuration": "Videolänge", + "logOutConfirmationDialogConfirmButton": "Abmelden", + "logOutConfirmationDialogMessage": "Um diesen Account in Zukunft zu verwenden, musst du die URL deiner Organisation und deine Account-Informationen erneut eingeben.", + "logOutConfirmationDialogTitle": "Abmelden?", + "loginAddAnAccountPageTitle": "Account hinzufügen", + "loginEmailLabel": "E-Mail-Adresse", + "loginErrorMissingEmail": "Bitte gib deine E-Mail ein.", + "loginErrorMissingPassword": "Bitte gib dein Passwort ein.", + "loginErrorMissingUsername": "Bitte gib deinen Benutzernamen ein.", + "loginFormSubmitLabel": "Anmelden", + "loginHidePassword": "Passwort verstecken", + "loginMethodDivider": "ODER", + "loginPageTitle": "Anmelden", + "loginPasswordLabel": "Passwort", + "loginServerUrlLabel": "Deine Zulip Server URL", + "loginUsernameLabel": "Benutzername", + "mainMenuMyProfile": "Mein Profil", + "manyPeopleTyping": "Mehrere Leute tippen…", + "markAllAsReadLabel": "Alle Nachrichten als gelesen markieren", + "markAsReadComplete": "{num, plural, =1{Eine Nachricht} other{{num} Nachrichten}} als gelesen markiert.", + "markAsReadInProgress": "Nachrichten werden als gelesen markiert…", + "markAsUnreadComplete": "{num, plural, =1{Eine Nachricht} other{{num} Nachrichten}} als ungelesen markiert.", + "markAsUnreadInProgress": "Nachrichten werden als ungelesen markiert…", + "markReadOnScrollSettingAlways": "Immer", + "markReadOnScrollSettingConversations": "Nur in Unterhaltungsansichten", + "markReadOnScrollSettingConversationsDescription": "Nachrichten werden nur beim Ansehen einzelner Themen oder Direktnachrichten automatisch als gelesen markiert.", + "markReadOnScrollSettingDescription": "Sollen Nachrichten automatisch als gelesen markiert werden, wenn du sie durchscrollst?", + "markReadOnScrollSettingNever": "Nie", + "markReadOnScrollSettingTitle": "Nachrichten beim Scrollen als gelesen markieren", + "mentionsPageTitle": "Erwähnungen", + "messageIsEditedLabel": "BEARBEITET", + "messageIsMovedLabel": "VERSCHOBEN", + "messageListGroupYouAndOthers": "Du und {others}", + "messageListGroupYouWithYourself": "Nachrichten mit dir selbst", + "messageNotSentLabel": "NACHRICHT NICHT GESENDET", + "mutedUser": "Stummgeschaltete:r Nutzer:in", + "newDmFabButtonLabel": "Neue DN", + "newDmSheetComposeButtonLabel": "Verfassen", + "newDmSheetNoUsersFound": "Keine Nutzer:innen gefunden", + "newDmSheetScreenTitle": "Neue DN", + "newDmSheetSearchHintEmpty": "Füge ein oder mehrere Nutzer:innen hinzu", + "newDmSheetSearchHintSomeSelected": "Füge weitere Nutzer:in hinzu…", + "noEarlierMessages": "Keine früheren Nachrichten", + "noStatusText": "Kein Statustext", + "notifGroupDmConversationLabel": "{senderFullName} an dich und {numOthers, plural, =1{1 weitere:n} other{{numOthers} weitere}}", + "notifSelfUser": "Du", + "onePersonTyping": "{typist} tippt…", + "openLinksWithInAppBrowser": "Links mit In-App-Browser öffnen", + "permissionsDeniedCameraAccess": "Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um ein Bild hochzuladen.", + "permissionsDeniedReadExternalStorage": "Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um Dateien hochzuladen.", + "permissionsNeededOpenSettings": "Einstellungen öffnen", + "permissionsNeededTitle": "Berechtigungen erforderlich", + "pinnedSubscriptionsLabel": "Angeheftet", + "pollVoterNames": "{voterNames}", + "pollWidgetOptionsMissing": "Diese Umfrage hat noch keine Optionen.", + "pollWidgetQuestionMissing": "Keine Frage.", + "preparingEditMessageContentInput": "Bereite vor…", + "profileButtonSendDirectMessage": "Direktnachricht senden", + "reactedEmojiSelfUser": "Du", + "reactionChipLabel": "{emojiName}: {votes}", + "reactionChipVotesYouAndOthers": "{otherUsersCount, plural, =1{Du und ein weiterer} other{Du und {otherUsersCount} weitere}}", + "reactionChipsLabel": "Reaktionen", + "recentDmConversationsEmptyPlaceholder": "Du hast noch keine Direktnachrichten! Warum nicht die Unterhaltung beginnen?", + "recentDmConversationsPageTitle": "Direktnachrichten", + "recentDmConversationsSectionHeader": "Direktnachrichten", + "revealButtonLabel": "Nachricht anzeigen", + "savingMessageEditFailedLabel": "BEARBEITUNG NICHT GESPEICHERT", + "savingMessageEditLabel": "SPEICHERE BEARBEITUNG…", + "scrollToBottomTooltip": "Nach unten Scrollen", + "searchMessagesClearButtonTooltip": "Leeren", + "searchMessagesHintText": "Suche", + "searchMessagesPageTitle": "Suche", + "seeWhoReactedSheetEmojiNameWithVoteCount": "{emojiName}: {num, plural, =1{1 Stimme} other{{num} Stimmen}}", + "seeWhoReactedSheetHeaderLabel": "Emoji-Reaktionen (insgesamt {num})", + "seeWhoReactedSheetNoReactions": "Diese Nachricht hat keine Reaktionen.", + "seeWhoReactedSheetUserListLabel": "Stimmen für {emojiName} ({num})", + "serverUrlValidationErrorEmpty": "Bitte gib eine URL ein.", + "serverUrlValidationErrorInvalidUrl": "Bitte gib eine gültige URL ein.", + "serverUrlValidationErrorNoUseEmail": "Bitte gib die Server-URL ein, nicht deine E-Mail-Adresse.", + "serverUrlValidationErrorUnsupportedScheme": "Die Server-URL muss mit http:// oder https:// beginnen.", + "setStatusPageTitle": "Status setzen", + "settingsPageTitle": "Einstellungen", + "sharePageTitle": "Teilen", + "signInWithFoo": "Anmelden mit {method}", + "snackBarDetails": "Details", + "spoilerDefaultHeaderText": "Spoiler", + "starredMessagesPageTitle": "Markierte Nachrichten", + "statusButtonLabelStatusSet": "Status", + "statusButtonLabelStatusUnset": "Status setzen", + "statusClearButtonLabel": "Leeren", + "statusSaveButtonLabel": "Speichern", + "statusTextHint": "Dein Status", + "subscribeFailedTitle": "Konnte nicht abonnieren", + "successChannelLinkCopied": "Kanallink kopiert", + "successLinkCopied": "Link kopiert", + "successMessageLinkCopied": "Nachrichtenlink kopiert", + "successMessageTextCopied": "Nachrichtentext kopiert", + "successTopicLinkCopied": "Link zum Thema kopiert", + "switchAccountButton": "Konto wechseln", + "themeSettingDark": "Dunkel", + "themeSettingLight": "Hell", + "themeSettingSystem": "System", + "themeSettingTitle": "THEMA", + "today": "Heute", + "topicValidationErrorMandatoryButEmpty": "Themen sind in dieser Organisation erforderlich.", + "topicValidationErrorTooLong": "Länge des Themas sollte 60 Zeichen nicht überschreiten.", + "topicsButtonTooltip": "Themen", + "tryAnotherAccountButton": "Anderen Account ausprobieren", + "tryAnotherAccountMessage": "Dein Account bei {url} benötigt einige Zeit zum Laden.", + "turnOffInvisibleModeErrorTitle": "Fehler beim Ausschalten des unsichtbaren Modus. Bitte versuche es erneut.", + "turnOnInvisibleModeErrorTitle": "Fehler beim Einschalten des unsichtbaren Modus. Bitte versuche es erneut.", + "twoPeopleTyping": "{typist} und {otherTypist} tippen…", + "unknownChannelName": "(unbekannter Kanal)", + "unknownUserName": "(Nutzer:in unbekannt)", + "unpinnedSubscriptionsLabel": "Nicht angeheftet", + "unsubscribeConfirmationDialogConfirmButton": "Deabonnieren", + "unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": "Wenn du diesen Kanal verlässt, kannst du sich vielleicht nicht wieder beitreten.", + "unsubscribeConfirmationDialogTitle": "{channelName} deabonnieren?", + "unsubscribeFailedTitle": "Konnte nicht deabonnieren", + "updateStatusErrorTitle": "Fehler beim Update des Benutzerstatus. Bitte versuche es nochmal.", + "upgradeWelcomeDialogDismiss": "Los gehts", + "upgradeWelcomeDialogLinkText": "Sieh dir den Ankündigungs-Blogpost an!", + "upgradeWelcomeDialogMessage": "Du wirst ein vertrautes Erlebnis in einer schnelleren, schlankeren App erleben.", + "upgradeWelcomeDialogTitle": "Willkommen in der neuen Zulip-App!", + "userActiveDate": "Aktiv {date}", + "userActiveDaysAgo": "Aktiv vor {days, plural, =1{einem Tag} other{{days} Tagen}}", + "userActiveHoursAgo": "Aktiv vor {hours, plural, =1{einer Stunde} other{{hours} Stunden}}", + "userActiveMinutesAgo": "Aktiv vor {minutes, plural, =1{einer Minute} other{{minutes} Minuten}}", + "userActiveNow": "Gerade aktiv", + "userActiveYesterday": "Gestern aktiv", + "userIdle": "Untätig", + "userNotActiveInYear": "Im letzten Jahr nicht aktiv", + "userRoleAdministrator": "Administrator", + "userRoleGuest": "Gast", + "userRoleMember": "Mitglied", + "userRoleModerator": "Moderator", + "userRoleOwner": "Besitzer", + "userRoleUnknown": "Unbekannt", + "userStatusAtTheOffice": "Im Büro", + "userStatusBusy": "Beschäftigt", + "userStatusCommuting": "Unterwegs", + "userStatusInAMeeting": "In einem Meeting", + "userStatusOutSick": "Krankgemeldet", + "userStatusVacationing": "Im Urlaub", + "userStatusWorkingRemotely": "Arbeitet von zu Hause", + "wildcardMentionAll": "alle", + "wildcardMentionAllDmDescription": "Empfänger benachrichtigen", + "wildcardMentionChannel": "Kanal", + "wildcardMentionChannelDescription": "Kanal benachrichtigen", + "wildcardMentionEveryone": "jeder", + "wildcardMentionStream": "Stream", + "wildcardMentionStreamDescription": "Stream benachrichtigen", + "wildcardMentionTopic": "Thema", + "wildcardMentionTopicDescription": "Thema benachrichtigen", + "yesterday": "Gestern", + "zulipAppTitle": "Zulip" +} diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ee7e96c35f..776a866d3b 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -15,10 +15,30 @@ "@aboutPageTapToView": { "description": "Item subtitle in About Zulip page to navigate to Licenses page" }, + "upgradeWelcomeDialogTitle": "Welcome to the new Zulip app!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogMessage": "You’ll find a familiar experience in a faster, sleeker package.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogLinkText": "Check out the announcement blog post!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "Let's go", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, "chooseAccountPageTitle": "Choose account", "@chooseAccountPageTitle": { "description": "Title for the page to choose between Zulip accounts." }, + "settingsPageTitle": "Settings", + "@settingsPageTitle": { + "description": "Title for the settings page." + }, "switchAccountButton": "Switch account", "@switchAccountButton": { "description": "Label for main-menu button leading to the choose-account page." @@ -76,6 +96,53 @@ "@permissionsDeniedReadExternalStorage": { "description": "Message for dialog asking the user to grant permissions for external storage read access." }, + "actionSheetOptionSubscribe": "Subscribe", + "@actionSheetOptionSubscribe": { + "description": "Label in the channel action sheet for subscribing to the channel." + }, + "subscribeFailedTitle": "Failed to subscribe", + "@subscribeFailedTitle": { + "description": "Error title when subscribing to a channel failed." + }, + "actionSheetOptionMarkChannelAsRead": "Mark channel as read", + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "actionSheetOptionCopyChannelLink": "Copy link to channel", + "@actionSheetOptionCopyChannelLink": { + "description": "Label for copy channel link button on action sheet." + }, + "actionSheetOptionListOfTopics": "List of topics", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "actionSheetOptionChannelFeed": "Channel feed", + "@actionSheetOptionChannelFeed": { + "description": "Label for navigating to a channel's channel-feed page." + }, + "actionSheetOptionUnsubscribe": "Unsubscribe", + "@actionSheetOptionUnsubscribe": { + "description": "Label in the channel action sheet for unsubscribing from the channel." + }, + "unsubscribeConfirmationDialogTitle": "Unsubscribe from {channelName}?", + "@unsubscribeConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for unsubscribing from a channel.", + "placeholders": { + "channelName": {"type": "String", "example": "mobile"} + } + }, + "unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": "Once you leave this channel, you might not be able to rejoin.", + "@unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": { + "description": "Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe." + }, + "unsubscribeConfirmationDialogConfirmButton": "Unsubscribe", + "@unsubscribeConfirmationDialogConfirmButton": { + "description": "Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel." + }, + "unsubscribeFailedTitle": "Failed to unsubscribe", + "@unsubscribeFailedTitle": { + "description": "Error title when unsubscribing from a channel failed." + }, "actionSheetOptionMuteTopic": "Mute topic", "@actionSheetOptionMuteTopic": { "description": "Label for muting a topic on action sheet." @@ -92,6 +159,76 @@ "@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." + }, + "actionSheetOptionSeeWhoReacted": "See who reacted", + "@actionSheetOptionSeeWhoReacted": { + "description": "Label for the 'See who reacted' button in the message action sheet." + }, + "seeWhoReactedSheetNoReactions": "This message has no reactions.", + "@seeWhoReactedSheetNoReactions": { + "description": "Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened)." + }, + "seeWhoReactedSheetHeaderLabel": "Emoji reactions ({num} total)", + "@seeWhoReactedSheetHeaderLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.)", + "placeholders": { + "num": {"type": "int", "example": "2"} + } + }, + "seeWhoReactedSheetEmojiNameWithVoteCount": "{emojiName}: {num, plural, =1{1 vote} other{{num} votes}}", + "@seeWhoReactedSheetEmojiNameWithVoteCount": { + "description": "In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": {"type": "String", "example": "working_on_it"}, + "num": {"type": "int", "example": "2"} + } + }, + "seeWhoReactedSheetUserListLabel": "Votes for {emojiName} ({num})", + "@seeWhoReactedSheetUserListLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": {"type": "String", "example": "working_on_it"}, + "num": {"type": "int", "example": "2"} + } + }, + "actionSheetOptionViewReadReceipts": "View read receipts", + "@actionSheetOptionViewReadReceipts": { + "description": "Label for the 'View read receipts' button in the message action sheet." + }, + "actionSheetReadReceipts": "Read receipts", + "@actionSheetReadReceipts": { + "description": "Title for the \"Read receipts\" bottom sheet." + }, + "actionSheetReadReceiptsReadCount": "{count, plural, =1{This message has been read by {count} person:} other{This message has been read by {count} people:}}", + "@actionSheetReadReceiptsReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when one or more people have read the message.", + "placeholders": { + "count": {"type": "int", "example": "1"} + } + }, + "actionSheetReadReceiptsZeroReadCount": "No one has read this message yet.", + "@actionSheetReadReceiptsZeroReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when no one has read the message." + }, + "actionSheetReadReceiptsErrorReadCount": "Failed to load read receipts.", + "@actionSheetReadReceiptsErrorReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when loading read receipts failed." + }, "actionSheetOptionCopyMessageText": "Copy message text", "@actionSheetOptionCopyMessageText": { "description": "Label for copy message text button on action sheet." @@ -104,13 +241,17 @@ "@actionSheetOptionMarkAsUnread": { "description": "Label for mark as unread button on action sheet." }, + "actionSheetOptionHideMutedMessage": "Hide muted message again", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, "actionSheetOptionShare": "Share", "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionQuoteAndReply": "Quote and reply", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." + "actionSheetOptionQuoteMessage": "Quote message", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." }, "actionSheetOptionStarMessage": "Star message", "@actionSheetOptionStarMessage": { @@ -120,6 +261,18 @@ "@actionSheetOptionUnstarMessage": { "description": "Label for unstar button on action sheet." }, + "actionSheetOptionEditMessage": "Edit message", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "actionSheetOptionMarkTopicAsRead": "Mark topic as read", + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "actionSheetOptionCopyTopicLink": "Copy link to topic", + "@actionSheetOptionCopyTopicLink": { + "description": "Label for copy topic link button in action sheet." + }, "errorWebAuthOperationalErrorTitle": "Something went wrong", "@errorWebAuthOperationalErrorTitle": { "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." @@ -140,7 +293,7 @@ "server": {"type": "String", "example": "https://example.com"} } }, - "errorCouldNotFetchMessageSource": "Could not fetch message source", + "errorCouldNotFetchMessageSource": "Could not fetch message source.", "@errorCouldNotFetchMessageSource": { "description": "Error message when the source of a message could not be fetched." }, @@ -155,13 +308,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", @@ -183,6 +344,10 @@ "@errorMessageNotSent": { "description": "Error message for compose box when a message could not be sent." }, + "errorMessageEditNotSaved": "Message not saved", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, "errorLoginCouldNotConnect": "Failed to connect to server:\n{url}", "@errorLoginCouldNotConnect": { "description": "Error message when the app could not connect to the server.", @@ -190,8 +355,8 @@ "url": {"type": "String", "example": "http://example.com/"} } }, - "errorLoginCouldNotConnectTitle": "Could not connect", - "@errorLoginCouldNotConnectTitle": { + "errorCouldNotConnectTitle": "Could not connect", + "@errorCouldNotConnectTitle": { "description": "Error title when the app could not connect to the server." }, "errorMessageDoesNotSeemToExist": "That message does not seem to exist.", @@ -273,6 +438,10 @@ "@errorUnstarMessageFailedTitle": { "description": "Error title when unstarring a message failed." }, + "errorCouldNotEditMessageTitle": "Could not edit message", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, "successLinkCopied": "Link copied", "@successLinkCopied": { "description": "Success message after copy link action completed." @@ -285,6 +454,14 @@ "@successMessageLinkCopied": { "description": "Message when link of a message was copied to the user's system clipboard." }, + "successTopicLinkCopied": "Topic link copied", + "@successTopicLinkCopied": { + "description": "Message when link of a topic was copied to the user's system clipboard." + }, + "successChannelLinkCopied": "Channel link copied", + "@successChannelLinkCopied": { + "description": "Message when link of a channel was copied to the user's system clipboard." + }, "errorBannerDeactivatedDmLabel": "You cannot send messages to deactivated users.", "@errorBannerDeactivatedDmLabel": { "description": "Label text for error banner when sending a message to one or multiple deactivated users." @@ -293,6 +470,50 @@ "@errorBannerCannotPostInChannelLabel": { "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." }, + "composeBoxBannerLabelEditMessage": "Edit message", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonCancel": "Cancel", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonSave": "Save", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressTitle": "Cannot edit message", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "editAlreadyInProgressMessage": "An edit is already in progress. Please wait for it to complete.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "savingMessageEditLabel": "SAVING EDIT…", + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "savingMessageEditFailedLabel": "EDIT NOT SAVED", + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "discardDraftConfirmationDialogTitle": "Discard the message you’re writing?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftForEditConfirmationDialogMessage": "When you edit a message, the content that was previously in the compose box is discarded.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "discardDraftForOutboxConfirmationDialogMessage": "When you restore an unsent message, the content that was previously in the compose box is discarded.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "discardDraftConfirmationDialogConfirmButton": "Discard", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, "composeBoxAttachFilesTooltip": "Attach files", "@composeBoxAttachFilesTooltip": { "description": "Tooltip for compose box icon to attach a file to the message." @@ -309,6 +530,30 @@ "@composeBoxGenericContentHint": { "description": "Hint text for content input when sending a message." }, + "newDmSheetComposeButtonLabel": "Compose", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "newDmSheetScreenTitle": "New DM", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "New DM", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "newDmSheetSearchHintEmpty": "Add one or more users", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "newDmSheetSearchHintSomeSelected": "Add another user…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "newDmSheetNoUsersFound": "No users found", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, "composeBoxDmContentHint": "Message @{user}", "@composeBoxDmContentHint": { "description": "Hint text for content input when sending a message to one other person.", @@ -324,14 +569,17 @@ "@composeBoxSelfDmContentHint": { "description": "Hint text for content input when sending a message to yourself." }, - "composeBoxChannelContentHint": "Message #{channel} > {topic}", + "composeBoxChannelContentHint": "Message {destination}", "@composeBoxChannelContentHint": { - "description": "Hint text for content input when sending a message to a channel", + "description": "Hint text for content input when sending a message to a channel.", "placeholders": { - "channel": {"type": "String", "example": "channel name"}, - "topic": {"type": "String", "example": "topic name"} + "destination": {"type": "String", "example": "#channel name > topic name"} } }, + "preparingEditMessageContentInput": "Preparing…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, "composeBoxSendTooltip": "Send", "@composeBoxSendTooltip": { "description": "Tooltip for send button in compose box." @@ -344,6 +592,13 @@ "@composeBoxTopicHintText": { "description": "Hint text for topic input widget in compose box." }, + "composeBoxEnterTopicOrSkipHintText": "Enter a topic (skip for “{defaultTopicName}”)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": {"type": "String", "example": "general chat"} + } + }, "composeBoxUploadingFilename": "Uploading {filename}…", "@composeBoxUploadingFilename": { "description": "Placeholder in compose box showing the specified file is currently uploading.", @@ -380,7 +635,15 @@ "others": {"type": "String", "example": "Alice, Bob"} } }, - "messageListGroupYouWithYourself": "You with yourself", + "emptyMessageList": "There are no messages here.", + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "emptyMessageListSearch": "No search results.", + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "messageListGroupYouWithYourself": "Messages with yourself", "@messageListGroupYouWithYourself": { "description": "Message list recipient header for a DM group that only includes yourself." }, @@ -412,6 +675,10 @@ "@dialogClose": { "description": "Button label in dialogs to close." }, + "errorDialogLearnMore": "Learn more", + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, "errorDialogContinue": "OK", "@errorDialogContinue": { "description": "Button label in error dialogs to acknowledge the error and close the dialog." @@ -459,9 +726,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": { @@ -499,7 +766,31 @@ "@topicValidationErrorMandatoryButEmpty": { "description": "Topic validation error when topic is required but was empty." }, - "errorInvalidResponse": "The server sent an invalid response", + "errorContentNotInsertedTitle": "Content not inserted", + "@errorContentNotInsertedTitle": { + "description": "Title for error dialog when an attempt to insert rich content failed." + }, + "errorContentToInsertIsEmpty": "The file to be inserted is empty or cannot be accessed.", + "@errorContentToInsertIsEmpty": { + "description": "Error message when the rich content to be inserted is empty or cannot be accessed." + }, + "errorServerVersionUnsupportedMessage": "{url} is running Zulip Server {zulipVersion}, which is unsupported. The minimum supported version is Zulip Server {minSupportedZulipVersion}.", + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "url": {"type": "String", "example": "http://chat.example.com/"}, + "zulipVersion": {"type": "String", "example": "3.2"}, + "minSupportedZulipVersion": {"type": "String", "example": "4.0"} + } + }, + "errorInvalidApiKeyMessage": "Your account at {url} could not be authenticated. Please try logging in again or use another account.", + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": {"type": "String", "example": "http://chat.example.com/"} + } + }, + "errorInvalidResponse": "The server sent an invalid response.", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." }, @@ -529,7 +820,7 @@ "httpStatus": {"type": "int", "example": "500"} } }, - "errorVideoPlayerFailed": "Unable to play the video", + "errorVideoPlayerFailed": "Unable to play the video.", "@errorVideoPlayerFailed": { "description": "Error message when a video fails to play." }, @@ -595,6 +886,62 @@ "@yesterday": { "description": "Term to use to reference the previous day." }, + "userActiveNow": "Active now", + "@userActiveNow": { + "description": "Indicates a user is currently active on Zulip (not idle or offline)" + }, + "userIdle": "Idle", + "@userIdle": { + "description": "Indicates a user is currently idle on Zulip (not active, but not offline)" + }, + "userActiveMinutesAgo": "Active {minutes, plural, =1{1 minute} other{{minutes} minutes}} ago", + "@userActiveMinutesAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "minutes": {"type": "int", "example": "5"} + } + }, + "userActiveHoursAgo": "Active {hours, plural, =1{1 hour} other{{hours} hours}} ago", + "@userActiveHoursAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "hours": {"type": "int", "example": "5"} + } + }, + "userActiveYesterday": "Active yesterday", + "@userActiveYesterday": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "userActiveDaysAgo": "Active {days, plural, =1{1 day} other{{days} days}} ago", + "@userActiveDaysAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "days": {"type": "int", "example": "5"} + } + }, + "userActiveDate": "Active {date}", + "@userActiveDate": { + "description": "Indicates the date when a user was last active on Zulip (who is currently offline).\n\nThe date might be day and month if recent, or day, month, and year if less recent.", + "placeholders": { + "date": {"type": "String", "example": "Aug 1, 2024"} + } + }, + "userNotActiveInYear": "Not active in the last year", + "@userNotActiveInYear": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "invisibleMode": "Invisible mode", + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "turnOnInvisibleModeErrorTitle": "Error turning on invisible mode. Please try again.", + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "turnOffInvisibleModeErrorTitle": "Error turning off invisible mode. Please try again.", + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, "userRoleOwner": "Owner", "@userRoleOwner": { "description": "Label for UserRole.owner" @@ -619,10 +966,86 @@ "@userRoleUnknown": { "description": "Label for UserRole.unknown" }, + "statusButtonLabelStatusSet": "Status", + "@statusButtonLabelStatusSet": { + "description": "The status button label in self-user profile page when status is set." + }, + "statusButtonLabelStatusUnset": "Set status", + "@statusButtonLabelStatusUnset": { + "description": "The status button label in self-user profile page when status is not set." + }, + "noStatusText": "No status text", + "@noStatusText": { + "description": "The text part of the status button sub-label in self-user profile page when status text is not set." + }, + "setStatusPageTitle": "Set status", + "@setStatusPageTitle": { + "description": "Title for the 'Set status' page." + }, + "statusClearButtonLabel": "Clear", + "@statusClearButtonLabel": { + "description": "Label for the button that clears the user status, in 'Set status' page." + }, + "statusSaveButtonLabel": "Save", + "@statusSaveButtonLabel": { + "description": "Label for the button that saves the user status, in 'Set status' page." + }, + "statusTextHint": "Your status", + "@statusTextHint": { + "description": "Hint text for the status text input field in 'Set status' page." + }, + "userStatusBusy": "Busy", + "@userStatusBusy": { + "description": "A suggested user status text, 'Busy'." + }, + "userStatusInAMeeting": "In a meeting", + "@userStatusInAMeeting": { + "description": "A suggested user status text, 'In a meeting'." + }, + "userStatusCommuting": "Commuting", + "@userStatusCommuting": { + "description": "A suggested user status text, 'Commuting'." + }, + "userStatusOutSick": "Out sick", + "@userStatusOutSick": { + "description": "A suggested user status text, 'Out sick'." + }, + "userStatusVacationing": "Vacationing", + "@userStatusVacationing": { + "description": "A suggested user status text, 'Vacationing'." + }, + "userStatusWorkingRemotely": "Working remotely", + "@userStatusWorkingRemotely": { + "description": "A suggested user status text, 'Working remotely'." + }, + "userStatusAtTheOffice": "At the office", + "@userStatusAtTheOffice": { + "description": "A suggested user status text, 'At the office'." + }, + "updateStatusErrorTitle": "Error updating user status. Please try again.", + "@updateStatusErrorTitle": { + "description": "Error title when updating user status failed." + }, + "searchMessagesPageTitle": "Search", + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "searchMessagesHintText": "Search", + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "searchMessagesClearButtonTooltip": "Clear", + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." + }, "inboxPageTitle": "Inbox", "@inboxPageTitle": { "description": "Title for the page with unreads." }, + "inboxEmptyPlaceholder": "There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, "recentDmConversationsPageTitle": "Direct messages", "@recentDmConversationsPageTitle": { "description": "Title for the page with a list of DM conversations." @@ -631,6 +1054,10 @@ "@recentDmConversationsSectionHeader": { "description": "Heading for direct messages section on the 'Inbox' message view." }, + "recentDmConversationsEmptyPlaceholder": "You have no direct messages yet! Why not start the conversation?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, "combinedFeedPageTitle": "Combined feed", "@combinedFeedPageTitle": { "description": "Page title for the 'Combined feed' message view." @@ -647,10 +1074,22 @@ "@channelsPageTitle": { "description": "Title for the page with a list of subscribed channels." }, + "channelsEmptyPlaceholder": "You are not subscribed to any channels yet.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "sharePageTitle": "Share", + "@sharePageTitle": { + "description": "Title for the page about sharing content received from other apps." + }, "mainMenuMyProfile": "My profile", "@mainMenuMyProfile": { "description": "Label for main-menu button leading to the user's own profile." }, + "topicsButtonTooltip": "Topics", + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, "channelFeedButtonTooltip": "Channel feed", "@channelFeedButtonTooltip": { "description": "Tooltip for button to navigate to a given channel's feed" @@ -671,10 +1110,6 @@ "@unpinnedSubscriptionsLabel": { "description": "Label for the list of unpinned subscribed channels." }, - "subscriptionListNoChannels": "No channels found", - "@subscriptionListNoChannels": { - "description": "Text to display on subscribed-channels page when there are no subscribed channels." - }, "notifSelfUser": "You", "@notifSelfUser": { "description": "Display name for the user themself, to show after replying in an Android notification" @@ -683,6 +1118,25 @@ "@reactedEmojiSelfUser": { "description": "Display name for the user themself, to show on an emoji reaction added by the user." }, + "reactionChipsLabel": "Reactions", + "@reactionChipsLabel": { + "description": "Text identifying the container of reaction chips on a message. (An accessibility label for assistive technology.)" + }, + "reactionChipLabel": "{emojiName}: {votes}", + "@reactionChipLabel": { + "description": "Text describing a reaction chip, with the emoji name and a list or number of votes. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": {"type": "String", "example": "working_on_it"}, + "votes": {"type": "String", "example": "You, Chris, Greg"} + } + }, + "reactionChipVotesYouAndOthers": "{otherUsersCount, plural, =1{You and 1 other} other{You and {otherUsersCount} others}}", + "@reactionChipVotesYouAndOthers": { + "description": "The number of votes on a reaction chip, where the self-user and at least one other user has voted. (An accessibility label for assistive technology.)", + "placeholders": { + "otherUsersCount": {"type": "int", "example": "4"} + } + }, "onePersonTyping": "{typist} is typing…", "@onePersonTyping": { "description": "Text to display when there is one user typing.", @@ -746,6 +1200,10 @@ "@messageIsMovedLabel": { "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" }, + "messageNotSentLabel": "MESSAGE NOT SENT", + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, "pollVoterNames": "({voterNames})", "@pollVoterNames": { "description": "The list of people who voted for a poll option, wrapped in parentheses.", @@ -753,6 +1211,26 @@ "voterNames": {"type": "String", "example": "Alice, Bob, Chad"} } }, + "themeSettingTitle": "THEME", + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingDark": "Dark", + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "themeSettingLight": "Light", + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "themeSettingSystem": "System", + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "openLinksWithInAppBrowser": "Open links with in-app browser", + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, "pollWidgetQuestionMissing": "No question.", "@pollWidgetQuestionMissing": { "description": "Text to display for a poll when the question is missing" @@ -761,13 +1239,65 @@ "@pollWidgetOptionsMissing": { "description": "Text to display for a poll when it has no options" }, + "initialAnchorSettingTitle": "Open message feeds at", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "initialAnchorSettingDescription": "You can choose whether message feeds open at your first unread message or at the newest messages.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadAlways": "First unread message", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadConversations": "First unread message in conversation views, newest message elsewhere", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingNewestAlways": "Newest message", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingTitle": "Mark messages as read on scroll", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingDescription": "When scrolling through messages, should they automatically be marked as read?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingAlways": "Always", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingNever": "Never", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "Only in conversation views", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversationsDescription": "Messages will be automatically marked as read only when viewing a single topic or direct message conversation.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "experimentalFeatureSettingsPageTitle": "Experimental features", + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "experimentalFeatureSettingsWarning": "These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.", + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, "errorNotificationOpenTitle": "Failed to open notification", "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "The account associated with this notification no longer exists.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" + "errorNotificationOpenAccountNotFound": "The account associated with this notification could not be found.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" }, "errorReactionAddingFailedTitle": "Adding reaction failed", "@errorReactionAddingFailedTitle": { @@ -777,6 +1307,14 @@ "@errorReactionRemovingFailedTitle": { "description": "Error title when removing a message reaction fails" }, + "errorSharingTitle": "Failed to share content", + "@errorSharingTitle": { + "description": "Error title when sharing content received from other apps fails" + }, + "errorSharingAccountNotLoggedIn": "There is no account logged in. Please log in to an account and try again.", + "@errorSharingAccountNotLoggedIn": { + "description": "Error title when sharing content received from other apps fails, when there is no account logged in" + }, "emojiReactionsMore": "more", "@emojiReactionsMore": { "description": "Label for a button opening the emoji picker." @@ -789,8 +1327,24 @@ "@noEarlierMessages": { "description": "Text to show at the start of a message list if there are no earlier messages." }, + "revealButtonLabel": "Reveal message", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "mutedUser": "Muted user", + "@mutedUser": { + "description": "Text to display in place of a muted user's name." + }, "scrollToBottomTooltip": "Scroll to bottom", "@scrollToBottomTooltip": { "description": "Tooltip for button to scroll to bottom." + }, + "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_en_GB.arb b/assets/l10n/app_en_GB.arb new file mode 100644 index 0000000000..3e9859203f --- /dev/null +++ b/assets/l10n/app_en_GB.arb @@ -0,0 +1,6 @@ +{ + "topicValidationErrorMandatoryButEmpty": "Topics are required in this organisation.", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + } +} diff --git a/assets/l10n/app_fr.arb b/assets/l10n/app_fr.arb new file mode 100644 index 0000000000..de1eb29f29 --- /dev/null +++ b/assets/l10n/app_fr.arb @@ -0,0 +1,472 @@ +{ + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "@actionSheetOptionCopyChannelLink": { + "description": "Label for copy channel link button on action sheet." + }, + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "@actionSheetOptionCopyTopicLink": { + "description": "Label for copy topic link button in action sheet." + }, + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "example": "user@example.com", + "type": "String" + }, + "server": { + "example": "https://example.com", + "type": "String" + } + } + }, + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "error": { + "example": "Invalid format", + "type": "String" + }, + "serverUrl": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "listMessage": { + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB", + "type": "String" + }, + "maxFileUploadSizeMib": { + "example": "15", + "type": "int" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "error": { + "example": "Unexpected null value", + "type": "String" + }, + "event": { + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')", + "type": "String" + }, + "serverUrl": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "example": "Invalid format", + "type": "String" + } + } + }, + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "example": "foo.txt", + "type": "String" + }, + "size": { + "example": "20.2", + "type": "String" + } + } + }, + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "@successChannelLinkCopied": { + "description": "Message when link of a channel was copied to the user's system clipboard." + }, + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "@successTopicLinkCopied": { + "description": "Message when link of a topic was copied to the user's system clipboard." + }, + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + }, + "@unsubscribeConfirmationDialogConfirmButton": { + "description": "Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel." + }, + "@unsubscribeConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for unsubscribing from a channel.", + "placeholders": { + "channelName": { + "example": "mobile", + "type": "String" + } + } + }, + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "aboutPageAppVersion": "Version de l'application", + "aboutPageOpenSourceLicenses": "Licences de logiciel libre", + "aboutPageTapToView": "Toucher pour voir", + "aboutPageTitle": "À propos de Zulip", + "actionSheetOptionCopyChannelLink": "Copier le lien du canal", + "actionSheetOptionCopyMessageLink": "Copier le lien au message", + "actionSheetOptionCopyMessageText": "Copier le contenu du message", + "actionSheetOptionCopyTopicLink": "Copier le lien sur le sujet", + "actionSheetOptionEditMessage": "Modifier le message", + "actionSheetOptionFollowTopic": "Suivre le sujet", + "actionSheetOptionHideMutedMessage": "Cacher à nouveau le message silencieux", + "actionSheetOptionListOfTopics": "Liste des sujets", + "actionSheetOptionMarkAsUnread": "Marquer non lu à partir d'ici", + "actionSheetOptionMarkChannelAsRead": "Marquer le canal comme lu", + "actionSheetOptionMarkTopicAsRead": "Marquer le sujet comme lu", + "actionSheetOptionMuteTopic": "Rendre le sujet silencieux", + "actionSheetOptionQuoteMessage": "Citer le message", + "actionSheetOptionResolveTopic": "Marquer comme résolu", + "actionSheetOptionShare": "Partager", + "actionSheetOptionStarMessage": "Mettre le message en favori", + "actionSheetOptionUnfollowTopic": "Ne plus suivre le sujet", + "actionSheetOptionUnmuteTopic": "Rendre le sujet non silencieux", + "actionSheetOptionUnresolveTopic": "Marquer comme non résolu", + "actionSheetOptionUnstarMessage": "Retirer ce message de la liste des favoris", + "chooseAccountButtonAddAnAccount": "Ajouter un compte", + "chooseAccountPageLogOutButton": "Déconnexion", + "chooseAccountPageTitle": "Choisir un compte", + "composeBoxBannerButtonCancel": "Annuler", + "composeBoxBannerButtonSave": "Sauvegarder", + "composeBoxBannerLabelEditMessage": "Editer le message", + "editAlreadyInProgressMessage": "Une modification est déjà en cours. Merci d'attendre qu'elle soit terminée.", + "editAlreadyInProgressTitle": "Impossible de modifier le message", + "errorAccountLoggedIn": "Le compte {email} at {server} figure déjà dans votre liste de comptes.", + "errorAccountLoggedInTitle": "Vous êtes déjà connecté à ce compte.", + "errorBannerCannotPostInChannelLabel": "Vous n'avez pas l'autorisation de poster sur ce canal.", + "errorBannerDeactivatedDmLabel": "Vous ne pouvez pas envoyer de messages aux utilisateurs désactivés.", + "errorConnectingToServerDetails": "Une erreur s'est produite lors de la connexion à Zulip sur {serverUrl}. Nouvelle tentative imminente :\n\n{error}", + "errorConnectingToServerShort": "Une erreur s'est produite lors de la connexion au serveur. Nouvelle tentative en cours…", + "errorCopyingFailed": "Échec de la copie", + "errorCouldNotConnectTitle": "Impossible de se connecter au serveur", + "errorCouldNotEditMessageTitle": "Le message n'a pas pu être modifié", + "errorCouldNotFetchMessageSource": "Impossible d'atteindre le message source.", + "errorCouldNotOpenLink": "Le lien suivant n'a pas pu être ouvert : {url}", + "errorCouldNotOpenLinkTitle": "Impossible d'ouvrir le lien", + "errorCouldNotShowUserProfile": "Impossible de montrer le profil de l'utilisateur.", + "errorFailedToUploadFileTitle": "Impossible de charger le fichier {filename}", + "errorFilesTooLarge": "{num, plural, =1{Fichier est} other{{num} fichiers sont}} plus gros que la limite de capacité du serveur ({maxFileUploadSizeMib} MO) et ne peu(ven)t pas être chargé(s) :\n\n{listMessage}", + "errorFilesTooLargeTitle": "{num, plural, =1{Le fichier est trop lourd} other{Les fichier sont trop lourds}}", + "errorFollowTopicFailed": "Échec du suivi du sujet", + "errorHandlingEventDetails": "Une erreur s'est produite sur le serveur {serverUrl} ; tentative de reconnexion imminente.\n\nErreur : {error}\n\nÉvénement : {event}", + "errorHandlingEventTitle": "Une erreur s'est produite sur le serveur. Reconnexion en cours…", + "errorLoginCouldNotConnect": "La connexion au serveur a échoué :\n{url}", + "errorLoginFailedTitle": "La connexion a échoué.", + "errorLoginInvalidInputTitle": "Identifiant incorrect", + "errorMessageDoesNotSeemToExist": "Ce message est introuvable.", + "errorMessageEditNotSaved": "Le message n'a pas pu être sauvegardé.", + "errorMessageNotSent": "Le message n'a pas pu être envoyé.", + "errorMuteTopicFailed": "Le sujet n'a pas pu être rendu silencieux", + "errorQuotationFailed": "Échec de la citation", + "errorResolveTopicFailedTitle": "Impossible de marquer le sujet comme résolu", + "errorServerMessage": "Message d'erreur du serveur :\n\n{message}", + "errorSharingFailed": "Échec du partage", + "errorStarMessageFailedTitle": "Échec de marquage du message en favori", + "errorUnfollowTopicFailed": "Échec de la tentative de ne plus suivre le sujet", + "errorUnmuteTopicFailed": "Impossible de ne plus mettre le sujet en sourdine", + "errorUnresolveTopicFailedTitle": "Impossible de marquer le sujet comme non résolu", + "errorUnstarMessageFailedTitle": "Échec de la tentative d'enlever le message des favoris", + "errorWebAuthOperationalError": "Oups, une erreur s'est produite.", + "errorWebAuthOperationalErrorTitle": "Une erreur s'est produite", + "filenameAndSizeInMiB": "{filename} : {size} MiB", + "logOutConfirmationDialogConfirmButton": "Déconnexion", + "logOutConfirmationDialogMessage": "Pour utiliser ce compte à l'avenir, vous devrez ré-entrer l'adresse pour votre organisation et les informations de votre compte.", + "logOutConfirmationDialogTitle": "Se déconnecter?", + "permissionsDeniedCameraAccess": "Pour charger une image, merci d'accorder des autorisations supplémentaires à Zulip, dans les préférences.", + "permissionsDeniedReadExternalStorage": "Pour charger des fichiers, merci d'accorder des autorisations supplémentaires à Zulip, dans les préférences.", + "permissionsNeededOpenSettings": "Ouvrir les préférences", + "permissionsNeededTitle": "Permissions requises", + "profileButtonSendDirectMessage": "Envoyer un message direct", + "settingsPageTitle": "Paramètres", + "successChannelLinkCopied": "Lien sur le canal copié", + "successLinkCopied": "Lien copié", + "successMessageLinkCopied": "Lien sur le message copié", + "successMessageTextCopied": "Texte du message copié", + "successTopicLinkCopied": "Lien sur le sujet copié", + "switchAccountButton": "Changer de compte", + "tryAnotherAccountButton": "Essayer un autre compte", + "tryAnotherAccountMessage": "Votre compte à {url} prend du temps à se charger.", + "unsubscribeConfirmationDialogConfirmButton": "Se désinscrire", + "unsubscribeConfirmationDialogTitle": "Se désinscrire de {channelName}?", + "upgradeWelcomeDialogDismiss": "Allons-y", + "upgradeWelcomeDialogLinkText": "Allez voir les articles sur le blog des annonces !", + "upgradeWelcomeDialogMessage": "Vous retrouverez une expérience familière dans un logiciel plus rapide et plus élégant.", + "upgradeWelcomeDialogTitle": "Bienvenue dans la nouvelle application Zulip !" +} diff --git a/assets/l10n/app_it.arb b/assets/l10n/app_it.arb new file mode 100644 index 0000000000..f4cc5e53d5 --- /dev/null +++ b/assets/l10n/app_it.arb @@ -0,0 +1,1204 @@ +{ + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "@actionSheetOptionSubscribe": { + "description": "Label in the channel action sheet for subscribing to the channel." + }, + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "example": "#channel name > topic name", + "type": "String" + } + } + }, + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "example": "channel name", + "type": "String" + } + } + }, + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "example": "general chat", + "type": "String" + } + } + }, + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "example": "1234", + "type": "int" + } + } + }, + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "example": "user@example.com", + "type": "String" + }, + "server": { + "example": "https://example.com", + "type": "String" + } + } + }, + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "error": { + "example": "Invalid format", + "type": "String" + }, + "serverUrl": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "listMessage": { + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB", + "type": "String" + }, + "maxFileUploadSizeMib": { + "example": "15", + "type": "int" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "error": { + "example": "Unexpected null value", + "type": "String" + }, + "event": { + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')", + "type": "String" + }, + "serverUrl": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + } + }, + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "details": { + "example": "type 'Null' is not a subtype of type 'String' in type cast", + "type": "String" + }, + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "example": "500", + "type": "int" + } + } + }, + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "example": "Invalid format", + "type": "String" + } + } + }, + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "minSupportedZulipVersion": { + "example": "4.0", + "type": "String" + }, + "url": { + "example": "http://chat.example.com/", + "type": "String" + }, + "zulipVersion": { + "example": "3.2", + "type": "String" + } + } + }, + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "example": "foo.txt", + "type": "String" + }, + "size": { + "example": "20.2", + "type": "String" + } + } + }, + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "@loginPageTitle": { + "description": "Title for login page." + }, + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "numOthers": { + "example": "4", + "type": "int" + }, + "senderFullName": { + "example": "Alice", + "type": "String" + } + } + }, + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "@pollVoterNames": { + "description": "The list of people who voted for a poll option, wrapped in parentheses.", + "placeholders": { + "voterNames": { + "example": "Alice, Bob, Chad", + "type": "String" + } + } + }, + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "example": "Google", + "type": "String" + } + } + }, + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "@subscribeFailedTitle": { + "description": "Error title when subscribing to a channel failed." + }, + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@today": { + "description": "Term to use to reference the current day." + }, + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + }, + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "otherTypist": { + "example": "Bob", + "type": "String" + }, + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "aboutPageAppVersion": "Versione app", + "aboutPageOpenSourceLicenses": "Licenze open-source", + "aboutPageTapToView": "Tap per visualizzare", + "aboutPageTitle": "Su Zulip", + "actionSheetOptionCopyMessageLink": "Copia il collegamento al messaggio", + "actionSheetOptionCopyMessageText": "Copia il testo del messaggio", + "actionSheetOptionEditMessage": "Modifica messaggio", + "actionSheetOptionFollowTopic": "Segui argomento", + "actionSheetOptionHideMutedMessage": "Nascondi nuovamente il messaggio disattivato", + "actionSheetOptionListOfTopics": "Elenco degli argomenti", + "actionSheetOptionMarkAsUnread": "Segna come non letto da qui", + "actionSheetOptionMarkChannelAsRead": "Segna il canale come letto", + "actionSheetOptionMarkTopicAsRead": "Segna l'argomento come letto", + "actionSheetOptionMuteTopic": "Silenzia argomento", + "actionSheetOptionQuoteMessage": "Cita messaggio", + "actionSheetOptionResolveTopic": "Segna come risolto", + "actionSheetOptionShare": "Condividi", + "actionSheetOptionStarMessage": "Messaggio speciale", + "actionSheetOptionSubscribe": "Iscriviti", + "actionSheetOptionUnfollowTopic": "Non seguire più l'argomento", + "actionSheetOptionUnmuteTopic": "Riattiva argomento", + "actionSheetOptionUnresolveTopic": "Segna come irrisolto", + "actionSheetOptionUnstarMessage": "Messaggio normale", + "appVersionUnknownPlaceholder": "(…)", + "channelFeedButtonTooltip": "Feed del canale", + "channelsEmptyPlaceholder": "Non sei ancora iscritto ad alcun canale.", + "channelsPageTitle": "Canali", + "chooseAccountButtonAddAnAccount": "Aggiungi un account", + "chooseAccountPageLogOutButton": "Esci", + "chooseAccountPageTitle": "Scegli account", + "combinedFeedPageTitle": "Feed combinato", + "composeBoxAttachFilesTooltip": "Allega file", + "composeBoxAttachFromCameraTooltip": "Fai una foto", + "composeBoxAttachMediaTooltip": "Allega immagini o video", + "composeBoxBannerButtonCancel": "Annulla", + "composeBoxBannerButtonSave": "Salva", + "composeBoxBannerLabelEditMessage": "Modifica messaggio", + "composeBoxChannelContentHint": "Messaggia {destination}", + "composeBoxDmContentHint": "Messaggia @{user}", + "composeBoxEnterTopicOrSkipHintText": "Inserisci un argomento (salta per \"{defaultTopicName}\")", + "composeBoxGenericContentHint": "Batti un messaggio", + "composeBoxGroupDmContentHint": "Gruppo di messaggi", + "composeBoxLoadingMessage": "(caricamento messaggio {messageId})", + "composeBoxSelfDmContentHint": "Annota qualcosa", + "composeBoxSendTooltip": "Invia", + "composeBoxTopicHintText": "Argomento", + "composeBoxUploadingFilename": "Caricamento {filename}…", + "contentValidationErrorEmpty": "Non devi inviare nulla!", + "contentValidationErrorQuoteAndReplyInProgress": "Attendere il completamento del commento.", + "contentValidationErrorTooLong": "La lunghezza del messaggio non deve essere superiore a 10.000 caratteri.", + "contentValidationErrorUploadInProgress": "Attendere il completamento del caricamento.", + "dialogCancel": "Annulla", + "dialogClose": "Chiudi", + "dialogContinue": "Continua", + "discardDraftConfirmationDialogConfirmButton": "Abbandona", + "discardDraftConfirmationDialogTitle": "Scartare il messaggio che si sta scrivendo?", + "discardDraftForEditConfirmationDialogMessage": "Quando si modifica un messaggio, il contenuto precedentemente presente nella casella di composizione viene ignorato.", + "discardDraftForOutboxConfirmationDialogMessage": "Quando si recupera un messaggio non inviato, il contenuto precedentemente presente nella casella di composizione viene ignorato.", + "dmsWithOthersPageTitle": "MD con {others}", + "dmsWithYourselfPageTitle": "MD con te stesso", + "editAlreadyInProgressMessage": "Una modifica è già in corso. Attendere il completamento.", + "editAlreadyInProgressTitle": "Impossibile modificare il messaggio", + "emojiPickerSearchEmoji": "Cerca emoji", + "emojiReactionsMore": "altro", + "errorAccountLoggedIn": "L'account {email} su {server} è già presente nell'elenco account.", + "errorAccountLoggedInTitle": "Account già registrato", + "errorBannerCannotPostInChannelLabel": "Non hai l'autorizzazione per postare su questo canale.", + "errorBannerDeactivatedDmLabel": "Non è possibile inviare messaggi agli utenti disattivati.", + "errorConnectingToServerDetails": "Errore durante la connessione a Zulip su {serverUrl}. Verrà effettuato un nuovo tentativo:\n\n{error}", + "errorConnectingToServerShort": "Errore di connessione a Zulip. Nuovo tentativo…", + "errorCopyingFailed": "Copia non riuscita", + "errorCouldNotConnectTitle": "Impossibile connettersi", + "errorCouldNotEditMessageTitle": "Impossibile modificare il messaggio", + "errorCouldNotFetchMessageSource": "Impossibile recuperare l'origine del messaggio.", + "errorCouldNotOpenLink": "Impossibile aprire il collegamento: {url}", + "errorCouldNotOpenLinkTitle": "Impossibile aprire il collegamento", + "errorCouldNotShowUserProfile": "Impossibile mostrare il profilo utente.", + "errorDialogContinue": "Ok", + "errorDialogLearnMore": "Scopri di più", + "errorDialogTitle": "Errore", + "errorFailedToUploadFileTitle": "Impossibile caricare il file: {filename}", + "errorFilesTooLarge": "{num, plural, =1{file è} other{{num} file sono}} più grande/i del limite del server di {maxFileUploadSizeMib} MiB e non verrà/anno caricato/i:\n\n{listMessage}", + "errorFilesTooLargeTitle": "{num, plural, =1{File} other{File}} troppo grande/i", + "errorFollowTopicFailed": "Impossibile seguire l'argomento", + "errorHandlingEventDetails": "Errore nella gestione di un evento Zulip da {serverUrl}; verrà effettuato un nuovo tentativo.\n\nErrore: {error}\n\nEvento: {event}", + "errorHandlingEventTitle": "Errore nella gestione di un evento Zulip. Nuovo tentativo di connessione…", + "errorInvalidApiKeyMessage": "L'account su {url} non è stato autenticato. Riprovare ad accedere o provare a usare un altro account.", + "errorInvalidResponse": "Il server ha inviato una risposta non valida.", + "errorLoginCouldNotConnect": "Impossibile connettersi al server:\n{url}", + "errorLoginFailedTitle": "Accesso non riuscito", + "errorLoginInvalidInputTitle": "Ingresso non valido", + "errorMalformedResponse": "Il server ha fornito una risposta non valida; stato HTTP {httpStatus}", + "errorMalformedResponseWithCause": "Il server ha fornito una risposta non valida; stato HTTP {httpStatus}; {details}", + "errorMarkAsReadFailedTitle": "Contrassegno come letto non riuscito", + "errorMarkAsUnreadFailedTitle": "Contrassegno come non letti non riuscito", + "errorMessageDoesNotSeemToExist": "Quel messaggio sembra non esistere.", + "errorMessageEditNotSaved": "Messaggio non salvato", + "errorMessageNotSent": "Messaggio non inviato", + "errorMuteTopicFailed": "Impossibile silenziare l'argomento", + "errorNetworkRequestFailed": "Richiesta di rete non riuscita", + "errorNotificationOpenAccountNotFound": "Impossibile trovare l'account associato a questa notifica.", + "errorNotificationOpenTitle": "Impossibile aprire la notifica", + "errorQuotationFailed": "Citazione non riuscita", + "errorReactionAddingFailedTitle": "Aggiunta della reazione non riuscita", + "errorReactionRemovingFailedTitle": "Rimozione della reazione non riuscita", + "errorRequestFailed": "Richiesta di rete non riuscita: stato HTTP {httpStatus}", + "errorResolveTopicFailedTitle": "Impossibile contrassegnare l'argomento come risolto", + "errorServerMessage": "Il server ha detto:\n\n{message}", + "errorServerVersionUnsupportedMessage": "{url} sta usando Zulip Server {zulipVersion}, che non è supportato. La versione minima supportata è Zulip Server {minSupportedZulipVersion}.", + "errorSharingFailed": "Condivisione fallita", + "errorStarMessageFailedTitle": "Impossibile contrassegnare il messaggio come speciale", + "errorUnfollowTopicFailed": "Impossibile smettere di seguire l'argomento", + "errorUnmuteTopicFailed": "Impossibile de-silenziare l'argomento", + "errorUnresolveTopicFailedTitle": "Impossibile contrassegnare l'argomento come irrisolto", + "errorUnstarMessageFailedTitle": "Impossibile contrassegnare il messaggio come normale", + "errorVideoPlayerFailed": "Impossibile riprodurre il video.", + "errorWebAuthOperationalError": "Si è verificato un errore imprevisto.", + "errorWebAuthOperationalErrorTitle": "Qualcosa è andato storto", + "experimentalFeatureSettingsPageTitle": "Caratteristiche sperimentali", + "experimentalFeatureSettingsWarning": "Queste opzioni abilitano funzionalità ancora in fase di sviluppo e non ancora pronte. Potrebbero non funzionare e causare problemi in altre aree dell'app.\n\nQueste impostazioni sono pensate per la sperimentazione da parte di chi lavora allo sviluppo di Zulip.", + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "inboxEmptyPlaceholder": "Non ci sono messaggi non letti nella posta in arrivo. Usare i pulsanti sotto per visualizzare il feed combinato o l'elenco dei canali.", + "inboxPageTitle": "Inbox", + "initialAnchorSettingDescription": "È possibile scegliere se i feed dei messaggi devono aprirsi al primo messaggio non letto oppure ai messaggi più recenti.", + "initialAnchorSettingFirstUnreadAlways": "Primo messaggio non letto", + "initialAnchorSettingFirstUnreadConversations": "Primo messaggio non letto nelle singole conversazioni, messaggio più recente altrove", + "initialAnchorSettingNewestAlways": "Messaggio più recente", + "initialAnchorSettingTitle": "Apri i feed dei messaggi su", + "lightboxCopyLinkTooltip": "Copia collegamento", + "lightboxVideoCurrentPosition": "Posizione corrente", + "lightboxVideoDuration": "Durata video", + "logOutConfirmationDialogConfirmButton": "Esci", + "logOutConfirmationDialogMessage": "Per utilizzare questo account in futuro, bisognerà reinserire l'URL della propria organizzazione e le informazioni del proprio account.", + "logOutConfirmationDialogTitle": "Disconnettersi?", + "loginAddAnAccountPageTitle": "Aggiungi account", + "loginEmailLabel": "Indirizzo email", + "loginErrorMissingEmail": "Inserire l'email.", + "loginErrorMissingPassword": "Inserire la propria password.", + "loginErrorMissingUsername": "Inserire il proprio nomeutente.", + "loginFormSubmitLabel": "Accesso", + "loginHidePassword": "Nascondi password", + "loginMethodDivider": "O", + "loginPageTitle": "Accesso", + "loginPasswordLabel": "Password", + "loginServerUrlLabel": "URL del server Zulip", + "loginUsernameLabel": "Nomeutente", + "mainMenuMyProfile": "Il mio profilo", + "manyPeopleTyping": "Molte persone stanno scrivendo…", + "markAllAsReadLabel": "Segna tutti i messaggi come letti", + "markAsReadComplete": "Segnato/i {num, plural, =1{1 messaggio} other{{num} messagei}} come letto/i.", + "markAsReadInProgress": "Contrassegno dei messaggi come letti…", + "markAsUnreadComplete": "Segnato/i {num, plural, =1{1 messaggio} other{{num} messagi}} come non letto/i.", + "markAsUnreadInProgress": "Contrassegno dei messaggi come non letti…", + "markReadOnScrollSettingAlways": "Sempre", + "markReadOnScrollSettingConversations": "Solo nelle visualizzazioni delle conversazioni", + "markReadOnScrollSettingConversationsDescription": "I messaggi verranno automaticamente contrassegnati come in sola lettura quando si visualizza un singolo argomento o una conversazione in un messaggio diretto.", + "markReadOnScrollSettingDescription": "Quando si scorrono i messaggi, questi devono essere contrassegnati automaticamente come letti?", + "markReadOnScrollSettingNever": "Mai", + "markReadOnScrollSettingTitle": "Segna i messaggi come letti durante lo scorrimento", + "mentionsPageTitle": "Menzioni", + "messageIsEditedLabel": "MODIFICATO", + "messageIsMovedLabel": "SPOSTATO", + "messageListGroupYouAndOthers": "Tu e {others}", + "messageListGroupYouWithYourself": "Messaggi con te stesso", + "messageNotSentLabel": "MESSAGGIO NON INVIATO", + "mutedUser": "Utente silenziato", + "newDmFabButtonLabel": "Nuovo MD", + "newDmSheetComposeButtonLabel": "Componi", + "newDmSheetNoUsersFound": "Nessun utente trovato", + "newDmSheetScreenTitle": "Nuovo MD", + "newDmSheetSearchHintEmpty": "Aggiungi uno o più utenti", + "newDmSheetSearchHintSomeSelected": "Aggiungi un altro utente…", + "noEarlierMessages": "Nessun messaggio precedente", + "notifGroupDmConversationLabel": "{senderFullName} a te e {numOthers, plural, =1{1 altro} other{{numOthers} altri}}", + "notifSelfUser": "Tu", + "onePersonTyping": "{typist} sta scrivendo…", + "openLinksWithInAppBrowser": "Apri i collegamenti con il browser in-app", + "permissionsDeniedCameraAccess": "Per caricare un'immagine, bisogna concedere a Zulip autorizzazioni aggiuntive nelle Impostazioni.", + "permissionsDeniedReadExternalStorage": "Per caricare file, bisogna concedere a Zulip autorizzazioni aggiuntive nelle Impostazioni.", + "permissionsNeededOpenSettings": "Apri le impostazioni", + "permissionsNeededTitle": "Permessi necessari", + "pinnedSubscriptionsLabel": "Bloccato", + "pollVoterNames": "({voterNames})", + "pollWidgetOptionsMissing": "Questo sondaggio non ha ancora opzioni.", + "pollWidgetQuestionMissing": "Nessuna domanda.", + "preparingEditMessageContentInput": "Preparazione…", + "profileButtonSendDirectMessage": "Invia un messaggio diretto", + "reactedEmojiSelfUser": "Tu", + "recentDmConversationsEmptyPlaceholder": "Non ci sono ancora messaggi diretti! Perché non iniziare la conversazione?", + "recentDmConversationsPageTitle": "Messaggi diretti", + "recentDmConversationsSectionHeader": "Messaggi diretti", + "revealButtonLabel": "Mostra messaggio per mittente silenziato", + "savingMessageEditFailedLabel": "MODIFICA NON SALVATA", + "savingMessageEditLabel": "SALVATAGGIO MODIFICA…", + "scrollToBottomTooltip": "Scorri fino in fondo", + "serverUrlValidationErrorEmpty": "Inserire un URL.", + "serverUrlValidationErrorInvalidUrl": "Inserire un URL valido.", + "serverUrlValidationErrorNoUseEmail": "Inserire l'URL del server, non il proprio indirizzo email.", + "serverUrlValidationErrorUnsupportedScheme": "L'URL del server deve iniziare con http:// o https://.", + "settingsPageTitle": "Impostazioni", + "signInWithFoo": "Accedi con {method}", + "snackBarDetails": "Dettagli", + "spoilerDefaultHeaderText": "Spoiler", + "starredMessagesPageTitle": "Messaggi speciali", + "subscribeFailedTitle": "Iscrizione non riuscita", + "successLinkCopied": "Collegamento copiato", + "successMessageLinkCopied": "Collegamento messaggio copiato", + "successMessageTextCopied": "Testo messaggio copiato", + "switchAccountButton": "Cambia account", + "themeSettingDark": "Scuro", + "themeSettingLight": "Chiaro", + "themeSettingSystem": "Sistema", + "themeSettingTitle": "TEMA", + "today": "Oggi", + "topicValidationErrorMandatoryButEmpty": "In questa organizzazione sono richiesti degli argomenti.", + "topicValidationErrorTooLong": "La lunghezza dell'argomento non deve superare i 60 caratteri.", + "topicsButtonTooltip": "Argomenti", + "tryAnotherAccountButton": "Prova un altro account", + "tryAnotherAccountMessage": "Il caricamento dell'account su {url} sta richiedendo un po' di tempo.", + "twoPeopleTyping": "{typist} e {otherTypist} stanno scrivendo…", + "unknownChannelName": "(canale sconosciuto)", + "unknownUserName": "(utente sconosciuto)", + "unpinnedSubscriptionsLabel": "Non bloccato", + "upgradeWelcomeDialogDismiss": "Andiamo", + "upgradeWelcomeDialogLinkText": "Date un'occhiata al post dell'annuncio sul blog!", + "upgradeWelcomeDialogMessage": "Troverai un'esperienza familiare in un pacchetto più veloce ed elegante.", + "upgradeWelcomeDialogTitle": "Benvenuti alla nuova app Zulip!", + "userRoleAdministrator": "Amministratore", + "userRoleGuest": "Ospite", + "userRoleMember": "Membro", + "userRoleModerator": "Moderatore", + "userRoleOwner": "Proprietario", + "userRoleUnknown": "Sconosciuto", + "wildcardMentionAll": "tutti", + "wildcardMentionAllDmDescription": "Notifica destinatari", + "wildcardMentionChannel": "canale", + "wildcardMentionChannelDescription": "Notifica canale", + "wildcardMentionEveryone": "ognuno", + "wildcardMentionStream": "flusso", + "wildcardMentionStreamDescription": "Notifica flusso", + "wildcardMentionTopic": "argomento", + "wildcardMentionTopicDescription": "Notifica argomento", + "yesterday": "Ieri", + "zulipAppTitle": "Zulip" +} diff --git a/assets/l10n/app_ja.arb b/assets/l10n/app_ja.arb index a66aede69e..c9687955c8 100644 --- a/assets/l10n/app_ja.arb +++ b/assets/l10n/app_ja.arb @@ -1,20 +1,1500 @@ { - "chooseAccountPageTitle": "アカウントを選択", - "@chooseAccountPageTitle": {}, - "chooseAccountButtonAddAnAccount": "新しいアカウントを追加", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "@actionSheetOptionChannelFeed": { + "description": "Label for navigating to a channel's channel-feed page." + }, + "@actionSheetOptionCopyChannelLink": { + "description": "Label for copy channel link button on action sheet." + }, + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "@actionSheetOptionCopyTopicLink": { + "description": "Label for copy topic link button in action sheet." + }, + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "@actionSheetOptionSeeWhoReacted": { + "description": "Label for the 'See who reacted' button in the message action sheet." + }, + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "@actionSheetOptionSubscribe": { + "description": "Label in the channel action sheet for subscribing to the channel." + }, + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "@actionSheetOptionUnsubscribe": { + "description": "Label in the channel action sheet for unsubscribing from the channel." + }, + "@actionSheetOptionViewReadReceipts": { + "description": "Label for the 'View read receipts' button in the message action sheet." + }, + "@actionSheetReadReceipts": { + "description": "Title for the \"Read receipts\" bottom sheet." + }, + "@actionSheetReadReceiptsErrorReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when loading read receipts failed." + }, + "@actionSheetReadReceiptsReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when one or more people have read the message.", + "placeholders": { + "count": { + "example": "1", + "type": "int" + } + } + }, + "@actionSheetReadReceiptsZeroReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when no one has read the message." + }, + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, "@chooseAccountButtonAddAnAccount": {}, - "profileButtonSendDirectMessage": "ダイレクトメッセージを送信", + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "@chooseAccountPageTitle": {}, + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "example": "#channel name > topic name", + "type": "String" + } + } + }, + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "example": "channel name", + "type": "String" + } + } + }, + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "example": "general chat", + "type": "String" + } + } + }, + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "example": "1234", + "type": "int" + } + } + }, + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "example": "user@example.com", + "type": "String" + }, + "server": { + "example": "https://example.com", + "type": "String" + } + } + }, + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "error": { + "example": "Invalid format", + "type": "String" + }, + "serverUrl": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "@errorContentNotInsertedTitle": { + "description": "Title for error dialog when an attempt to insert rich content failed." + }, + "@errorContentToInsertIsEmpty": { + "description": "Error message when the rich content to be inserted is empty or cannot be accessed." + }, + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "listMessage": { + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB", + "type": "String" + }, + "maxFileUploadSizeMib": { + "example": "15", + "type": "int" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "error": { + "example": "Unexpected null value", + "type": "String" + }, + "event": { + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')", + "type": "String" + }, + "serverUrl": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + } + }, + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "details": { + "example": "type 'Null' is not a subtype of type 'String' in type cast", + "type": "String" + }, + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "example": "500", + "type": "int" + } + } + }, + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "example": "Invalid format", + "type": "String" + } + } + }, + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "minSupportedZulipVersion": { + "example": "4.0", + "type": "String" + }, + "url": { + "example": "http://chat.example.com/", + "type": "String" + }, + "zulipVersion": { + "example": "3.2", + "type": "String" + } + } + }, + "@errorSharingAccountNotLoggedIn": { + "description": "Error title when sharing content received from other apps fails, when there is no account logged in" + }, + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "@errorSharingTitle": { + "description": "Error title when sharing content received from other apps fails" + }, + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "example": "foo.txt", + "type": "String" + }, + "size": { + "example": "20.2", + "type": "String" + } + } + }, + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "@loginPageTitle": { + "description": "Title for login page." + }, + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@mutedUser": { + "description": "Text to display in place of a muted user's name." + }, + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "@noStatusText": { + "description": "The text part of the status button sub-label in self-user profile page when status text is not set." + }, + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "numOthers": { + "example": "4", + "type": "int" + }, + "senderFullName": { + "example": "Alice", + "type": "String" + } + } + }, + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "@pollVoterNames": { + "description": "The list of people who voted for a poll option, wrapped in parentheses.", + "placeholders": { + "voterNames": { + "example": "Alice, Bob, Chad", + "type": "String" + } + } + }, + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, "@profileButtonSendDirectMessage": {}, - "userRoleOwner": "オーナー", - "@userRoleOwner": {}, - "userRoleAdministrator": "管理者", + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "@reactionChipLabel": { + "description": "Text describing a reaction chip, with the emoji name and a list or number of votes. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "votes": { + "example": "You, Chris, Greg", + "type": "String" + } + } + }, + "@reactionChipVotesYouAndOthers": { + "description": "The number of votes on a reaction chip, where the self-user and at least one other user has voted. (An accessibility label for assistive technology.)", + "placeholders": { + "otherUsersCount": { + "example": "4", + "type": "int" + } + } + }, + "@reactionChipsLabel": { + "description": "Text identifying the container of reaction chips on a message. (An accessibility label for assistive technology.)" + }, + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." + }, + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "@seeWhoReactedSheetEmojiNameWithVoteCount": { + "description": "In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetHeaderLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.)", + "placeholders": { + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetNoReactions": { + "description": "Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened)." + }, + "@seeWhoReactedSheetUserListLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "@setStatusPageTitle": { + "description": "Title for the 'Set status' page." + }, + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "@sharePageTitle": { + "description": "Title for the page about sharing content received from other apps." + }, + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "example": "Google", + "type": "String" + } + } + }, + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "@statusButtonLabelStatusSet": { + "description": "The status button label in self-user profile page when status is set." + }, + "@statusButtonLabelStatusUnset": { + "description": "The status button label in self-user profile page when status is not set." + }, + "@statusClearButtonLabel": { + "description": "Label for the button that clears the user status, in 'Set status' page." + }, + "@statusSaveButtonLabel": { + "description": "Label for the button that saves the user status, in 'Set status' page." + }, + "@statusTextHint": { + "description": "Hint text for the status text input field in 'Set status' page." + }, + "@subscribeFailedTitle": { + "description": "Error title when subscribing to a channel failed." + }, + "@successChannelLinkCopied": { + "description": "Message when link of a channel was copied to the user's system clipboard." + }, + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "@successTopicLinkCopied": { + "description": "Message when link of a topic was copied to the user's system clipboard." + }, + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@today": { + "description": "Term to use to reference the current day." + }, + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + }, + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "otherTypist": { + "example": "Bob", + "type": "String" + }, + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "@unsubscribeConfirmationDialogConfirmButton": { + "description": "Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel." + }, + "@unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": { + "description": "Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe." + }, + "@unsubscribeConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for unsubscribing from a channel.", + "placeholders": { + "channelName": { + "example": "mobile", + "type": "String" + } + } + }, + "@unsubscribeFailedTitle": { + "description": "Error title when unsubscribing from a channel failed." + }, + "@updateStatusErrorTitle": { + "description": "Error title when updating user status failed." + }, + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "@userActiveDate": { + "description": "Indicates the date when a user was last active on Zulip (who is currently offline).\n\nThe date might be day and month if recent, or day, month, and year if less recent.", + "placeholders": { + "date": { + "example": "Aug 1, 2024", + "type": "String" + } + } + }, + "@userActiveDaysAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "days": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveHoursAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "hours": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveMinutesAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "minutes": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveNow": { + "description": "Indicates a user is currently active on Zulip (not idle or offline)" + }, + "@userActiveYesterday": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userIdle": { + "description": "Indicates a user is currently idle on Zulip (not active, but not offline)" + }, + "@userNotActiveInYear": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, "@userRoleAdministrator": {}, - "userRoleModerator": "モデレータ", - "@userRoleModerator": {}, - "userRoleMember": "メンバー", + "@userRoleGuest": {}, "@userRoleMember": {}, + "@userRoleModerator": {}, + "@userRoleOwner": {}, + "@userRoleUnknown": {}, + "@userStatusAtTheOffice": { + "description": "A suggested user status text, 'At the office'." + }, + "@userStatusBusy": { + "description": "A suggested user status text, 'Busy'." + }, + "@userStatusCommuting": { + "description": "A suggested user status text, 'Commuting'." + }, + "@userStatusInAMeeting": { + "description": "A suggested user status text, 'In a meeting'." + }, + "@userStatusOutSick": { + "description": "A suggested user status text, 'Out sick'." + }, + "@userStatusVacationing": { + "description": "A suggested user status text, 'Vacationing'." + }, + "@userStatusWorkingRemotely": { + "description": "A suggested user status text, 'Working remotely'." + }, + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "aboutPageAppVersion": "アプリのバージョン", + "aboutPageOpenSourceLicenses": "オープンソースライセンス", + "aboutPageTapToView": "タップして表示", + "aboutPageTitle": "Zulipについて", + "actionSheetOptionChannelFeed": "チャンネル一覧", + "actionSheetOptionCopyChannelLink": "チャンネルのリンクをコピー", + "actionSheetOptionCopyMessageLink": "メッセージへのリンクをコピー", + "actionSheetOptionCopyMessageText": "メッセージ本文をコピー", + "actionSheetOptionCopyTopicLink": "トピックのリンクをコピー", + "actionSheetOptionEditMessage": "メッセージを編集", + "actionSheetOptionFollowTopic": "トピックをフォロー", + "actionSheetOptionHideMutedMessage": "ミュートしたメッセージを再び非表示にする", + "actionSheetOptionListOfTopics": "トピック一覧", + "actionSheetOptionMarkAsUnread": "ここから未読にする", + "actionSheetOptionMarkChannelAsRead": "チャンネルを既読にする", + "actionSheetOptionMarkTopicAsRead": "トピックを既読にする", + "actionSheetOptionMuteTopic": "トピックをミュート", + "actionSheetOptionQuoteMessage": "メッセージを引用", + "actionSheetOptionResolveTopic": "解決済みにする", + "actionSheetOptionSeeWhoReacted": "リアクションした人を見る", + "actionSheetOptionShare": "共有", + "actionSheetOptionStarMessage": "メッセージにスターを付ける", + "actionSheetOptionSubscribe": "チャンネルに参加", + "actionSheetOptionUnfollowTopic": "トピックのフォローを解除", + "actionSheetOptionUnmuteTopic": "トピックのミュートを解除", + "actionSheetOptionUnresolveTopic": "未解決にする", + "actionSheetOptionUnstarMessage": "メッセージのスターを外す", + "actionSheetOptionUnsubscribe": "チャンネルから退出", + "actionSheetOptionViewReadReceipts": "既読確認を表示", + "actionSheetReadReceipts": "既読確認", + "actionSheetReadReceiptsErrorReadCount": "既読情報の読み込みに失敗しました。", + "actionSheetReadReceiptsReadCount": "{count, plural, =1{このメッセージは {count} 人に読まれています:} other{このメッセージは {count} 人に読まれています:}}", + "actionSheetReadReceiptsZeroReadCount": "このメッセージはまだ誰も読んでいません。", + "appVersionUnknownPlaceholder": "(…)", + "channelFeedButtonTooltip": "チャンネルフィード", + "channelsEmptyPlaceholder": "まだ参加しているチャンネルはありません。", + "channelsPageTitle": "チャンネル", + "chooseAccountButtonAddAnAccount": "新しいアカウントを追加", + "chooseAccountPageLogOutButton": "ログアウト", + "chooseAccountPageTitle": "アカウントを選択", + "combinedFeedPageTitle": "統合フィード", + "composeBoxAttachFilesTooltip": "ファイルを添付", + "composeBoxAttachFromCameraTooltip": "写真を撮る", + "composeBoxAttachMediaTooltip": "画像や動画を添付", + "composeBoxBannerButtonCancel": "キャンセル", + "composeBoxBannerButtonSave": "保存", + "composeBoxBannerLabelEditMessage": "メッセージを編集", + "composeBoxChannelContentHint": "{destination} にメッセージを送信", + "composeBoxDmContentHint": "@{user} さんにメッセージ", + "composeBoxEnterTopicOrSkipHintText": "トピックを入力(省略時は「{defaultTopicName}」)", + "composeBoxGenericContentHint": "メッセージを入力", + "composeBoxGroupDmContentHint": "グループにメッセージ", + "composeBoxLoadingMessage": "(メッセージ {messageId} を読み込み中)", + "composeBoxSelfDmContentHint": "メモを書き留める", + "composeBoxSendTooltip": "送信", + "composeBoxTopicHintText": "トピック", + "composeBoxUploadingFilename": "{filename} をアップロード中…", + "contentValidationErrorEmpty": "メッセージが空です!", + "contentValidationErrorQuoteAndReplyInProgress": "引用が完了するまでお待ちください。", + "contentValidationErrorTooLong": "メッセージは10000文字以内で入力してください。", + "contentValidationErrorUploadInProgress": "アップロードが完了するまでお待ちください。", + "dialogCancel": "キャンセル", + "dialogClose": "閉じる", + "dialogContinue": "続行", + "discardDraftConfirmationDialogConfirmButton": "破棄", + "discardDraftConfirmationDialogTitle": "作成中のメッセージを破棄しますか?", + "discardDraftForEditConfirmationDialogMessage": "メッセージを編集すると、作成中の内容は破棄されます。", + "discardDraftForOutboxConfirmationDialogMessage": "未送信メッセージを復元すると、作成中の内容は破棄されます。", + "dmsWithOthersPageTitle": "{others}とのDM", + "dmsWithYourselfPageTitle": "自分とのDM", + "editAlreadyInProgressMessage": "他の編集が進行中です。完了するまでお待ちください。", + "editAlreadyInProgressTitle": "メッセージを編集できません", + "emojiPickerSearchEmoji": "絵文字を検索", + "emojiReactionsMore": "その他", + "emptyMessageList": "ここにはメッセージがありません。", + "emptyMessageListSearch": "検索結果はありません。", + "errorAccountLoggedIn": "{server} の {email} アカウントは、すでにアカウント一覧に追加されています。", + "errorAccountLoggedInTitle": "このアカウントはすでにログインしています", + "errorBannerCannotPostInChannelLabel": "このチャンネルに投稿する権限がありません。", + "errorBannerDeactivatedDmLabel": "無効化されたユーザーにはメッセージを送信できません。", + "errorConnectingToServerDetails": "Zulip({serverUrl})への接続でエラーが発生しました。再試行します:\n\n{error}", + "errorConnectingToServerShort": "Zulip への接続でエラーが発生しました。再試行中…", + "errorContentNotInsertedTitle": "コンテンツを挿入できませんでした", + "errorContentToInsertIsEmpty": "挿入しようとしたファイルが空、またはアクセスできません。", + "errorCopyingFailed": "コピーに失敗しました", + "errorCouldNotConnectTitle": "接続できませんでした", + "errorCouldNotEditMessageTitle": "メッセージを編集できませんでした", + "errorCouldNotFetchMessageSource": "メッセージのソースを取得できませんでした。", + "errorCouldNotOpenLink": "リンクを開けませんでした:{url}", + "errorCouldNotOpenLinkTitle": "リンクを開けませんでした", + "errorCouldNotShowUserProfile": "ユーザープロフィールを表示できませんでした。", + "errorDialogContinue": "OK", + "errorDialogLearnMore": "詳しく見る", + "errorDialogTitle": "エラー", + "errorFailedToUploadFileTitle": "ファイルのアップロードに失敗しました: {filename}", + "errorFilesTooLarge": "{num, plural, =1{添付したファイルは} other{添付した {num} 個のファイルは}}サーバーの上限 {maxFileUploadSizeMib} MiB を超えているため、アップロードできません:\n\n{listMessage}", + "errorFilesTooLargeTitle": "{num, plural, =1{ファイルが大きすぎます} other{ファイルが大きすぎます}}", + "errorFollowTopicFailed": "トピックをフォローできませんでした", + "errorHandlingEventDetails": "Zulip({serverUrl})からのイベント処理でエラーが発生しました。再試行します。\n\nエラー:{error}\n\nイベント:{event}", + "errorHandlingEventTitle": "Zulip のイベント処理でエラーが発生しました。再接続を試行しています…", + "errorInvalidApiKeyMessage": "{url} のアカウントを認証できませんでした。もう一度ログインするか、別のアカウントを使用してください。", + "errorInvalidResponse": "サーバーから無効な応答が返されました。", + "errorLoginCouldNotConnect": "サーバーに接続できませんでした:\n{url}", + "errorLoginFailedTitle": "ログインに失敗しました", + "errorLoginInvalidInputTitle": "入力が正しくありません", + "errorMalformedResponse": "サーバーが不正なレスポンスを返しました(HTTPステータス {httpStatus})", + "errorMalformedResponseWithCause": "サーバーが不正なレスポンスを返しました(HTTPステータス {httpStatus}、詳細: {details})", + "errorMarkAsReadFailedTitle": "既読にできませんでした", + "errorMarkAsUnreadFailedTitle": "未読にできませんでした", + "errorMessageDoesNotSeemToExist": "そのメッセージは見つかりませんでした。", + "errorMessageEditNotSaved": "メッセージを保存できませんでした", + "errorMessageNotSent": "メッセージを送信できませんでした", + "errorMuteTopicFailed": "トピックをミュートできませんでした", + "errorNetworkRequestFailed": "ネットワークエラーが発生しました", + "errorNotificationOpenAccountNotFound": "この通知に関連付けられたアカウントが見つかりませんでした。", + "errorNotificationOpenTitle": "通知を開けませんでした", + "errorQuotationFailed": "引用できませんでした", + "errorReactionAddingFailedTitle": "リアクションを追加できませんでした", + "errorReactionRemovingFailedTitle": "リアクションを削除できませんでした", + "errorRequestFailed": "ネットワークリクエストに失敗しました:HTTP ステータス {httpStatus}", + "errorResolveTopicFailedTitle": "トピックを解決済みにできませんでした", + "errorServerMessage": "サーバーからの応答:\n\n{message}", + "errorServerVersionUnsupportedMessage": "{url} で動作している Zulip Server {zulipVersion} はサポート対象外です。サポートされる最小バージョンは Zulip Server {minSupportedZulipVersion} です。", + "errorSharingAccountNotLoggedIn": "ログインしていません。アカウントにログインしてから、もう一度お試しください。", + "errorSharingFailed": "共有に失敗しました", + "errorSharingTitle": "コンテンツを共有できませんでした", + "errorStarMessageFailedTitle": "メッセージにスターを付けられませんでした", + "errorUnfollowTopicFailed": "トピックのフォロー解除ができませんでした", + "errorUnmuteTopicFailed": "トピックのミュート解除ができませんでした", + "errorUnresolveTopicFailedTitle": "トピックを未解決にできませんでした", + "errorUnstarMessageFailedTitle": "メッセージのスターを外せませんでした", + "errorVideoPlayerFailed": "動画を再生できません。", + "errorWebAuthOperationalError": "予期しないエラーが発生しました。", + "errorWebAuthOperationalErrorTitle": "問題が発生しました", + "experimentalFeatureSettingsPageTitle": "実験的機能", + "experimentalFeatureSettingsWarning": "これらのオプションは、まだ開発中で未完成の機能を有効にします。正常に動作しない場合や、アプリの他の部分に不具合を引き起こす可能性があります。\n\nこの設定は、Zulip の開発に携わる人が試験的に利用することを目的としています。", + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "inboxEmptyPlaceholder": "未読メッセージはありません。下のボタンから、統合フィードまたはチャンネル一覧を表示できます。", + "inboxPageTitle": "受信箱", + "initialAnchorSettingDescription": "メッセージ一覧を、最初の未読メッセージから開くか、最新のメッセージから開くかを選択できます。", + "initialAnchorSettingFirstUnreadAlways": "最初の未読メッセージ", + "initialAnchorSettingFirstUnreadConversations": "会話ビューでは最初の未読メッセージ、それ以外では最新メッセージ", + "initialAnchorSettingNewestAlways": "最新のメッセージ", + "initialAnchorSettingTitle": "メッセージ一覧の開始位置", + "invisibleMode": "ステータス非表示", + "lightboxCopyLinkTooltip": "リンクをコピー", + "lightboxVideoCurrentPosition": "再生位置", + "lightboxVideoDuration": "再生時間", + "logOutConfirmationDialogConfirmButton": "ログアウト", + "logOutConfirmationDialogMessage": "今後このアカウントを使うには、組織のURLとアカウント情報を再度入力する必要があります。", + "logOutConfirmationDialogTitle": "ログアウトしますか?", + "loginAddAnAccountPageTitle": "アカウントを追加", + "loginEmailLabel": "メールアドレス", + "loginErrorMissingEmail": "メールアドレスを入力してください。", + "loginErrorMissingPassword": "パスワードを入力してください。", + "loginErrorMissingUsername": "ユーザー名を入力してください。", + "loginFormSubmitLabel": "ログイン", + "loginHidePassword": "パスワードを非表示", + "loginMethodDivider": "または", + "loginPageTitle": "ログイン", + "loginPasswordLabel": "パスワード", + "loginServerUrlLabel": "Zulip サーバーのURL", + "loginUsernameLabel": "ユーザー名", + "mainMenuMyProfile": "自分のプロフィール", + "manyPeopleTyping": "複数のユーザーが入力中…", + "markAllAsReadLabel": "すべてのメッセージを既読にする", + "markAsReadComplete": "{num, plural, =1{1} other{{num}}}件のメッセージを既読にしました。", + "markAsReadInProgress": "メッセージを既読にしています…", + "markAsUnreadComplete": "{num, plural, =1{1} other{{num}}}件のメッセージを未読にしました。", + "markAsUnreadInProgress": "メッセージを未読にしています…", + "markReadOnScrollSettingAlways": "常に既読にする", + "markReadOnScrollSettingConversations": "会話ビューのみ", + "markReadOnScrollSettingConversationsDescription": "メッセージは、単一のトピックまたはダイレクトメッセージの会話を表示しているときのみ、自動的に既読になります。", + "markReadOnScrollSettingDescription": "メッセージをスクロールしたとき、自動的に既読にしますか?", + "markReadOnScrollSettingNever": "既読にしない", + "markReadOnScrollSettingTitle": "スクロールでメッセージを既読にする", + "mentionsPageTitle": "メンション", + "messageIsEditedLabel": "編集済み", + "messageIsMovedLabel": "移動済み", + "messageListGroupYouAndOthers": "自分と{others}", + "messageListGroupYouWithYourself": "自分とのメッセージ", + "messageNotSentLabel": "メッセージ未送信", + "mutedUser": "ミュート中のユーザー", + "newDmFabButtonLabel": "新しいDM", + "newDmSheetComposeButtonLabel": "作成", + "newDmSheetNoUsersFound": "ユーザーが見つかりません", + "newDmSheetScreenTitle": "新しいDM", + "newDmSheetSearchHintEmpty": "1人以上のユーザーを追加", + "newDmSheetSearchHintSomeSelected": "別のユーザーを追加…", + "noEarlierMessages": "これより前のメッセージはありません", + "noStatusText": "ステータス文なし", + "notifGroupDmConversationLabel": "{senderFullName} から 自分と{numOthers, plural, =1{ほか1人} other{ほか{numOthers}人}}へ", + "notifSelfUser": "自分", + "onePersonTyping": "{typist} さんが入力中…", + "openLinksWithInAppBrowser": "リンクをアプリ内ブラウザで開く", + "permissionsDeniedCameraAccess": "画像をアップロードするには、[設定] でZulipに追加の権限を付与してください。", + "permissionsDeniedReadExternalStorage": "ファイルをアップロードするには、[設定] でZulipに追加の権限を付与してください。", + "permissionsNeededOpenSettings": "設定を開く", + "permissionsNeededTitle": "権限が必要です", + "pinnedSubscriptionsLabel": "ピン留め済み", + "pollVoterNames": "({voterNames})", + "pollWidgetOptionsMissing": "この投票にはまだ選択肢がありません。", + "pollWidgetQuestionMissing": "質問がありません。", + "preparingEditMessageContentInput": "準備中…", + "profileButtonSendDirectMessage": "ダイレクトメッセージを送信", + "reactedEmojiSelfUser": "自分", + "reactionChipLabel": "{emojiName}: {votes}件", + "reactionChipVotesYouAndOthers": "{otherUsersCount, plural, =1{自分とほか1人} other{自分とほか{otherUsersCount}人}}", + "reactionChipsLabel": "リアクション", + "recentDmConversationsEmptyPlaceholder": "まだダイレクトメッセージはありません!会話を始めてみませんか?", + "recentDmConversationsPageTitle": "ダイレクトメッセージ", + "recentDmConversationsSectionHeader": "ダイレクトメッセージ", + "revealButtonLabel": "メッセージを表示", + "savingMessageEditFailedLabel": "編集未保存", + "savingMessageEditLabel": "保存中…", + "scrollToBottomTooltip": "最下部へ移動", + "searchMessagesClearButtonTooltip": "クリア", + "searchMessagesHintText": "検索", + "searchMessagesPageTitle": "検索", + "seeWhoReactedSheetEmojiNameWithVoteCount": "{emojiName}:{num, plural, =1{1件} other{{num}件}}", + "seeWhoReactedSheetHeaderLabel": "絵文字リアクション(合計 {num} 件)", + "seeWhoReactedSheetNoReactions": "このメッセージにはリアクションがありません。", + "seeWhoReactedSheetUserListLabel": "{emojiName} のリアクション件数({num}件)", + "serverUrlValidationErrorEmpty": "URLを入力してください。", + "serverUrlValidationErrorInvalidUrl": "有効なURLを入力してください。", + "serverUrlValidationErrorNoUseEmail": "メールアドレスではなく、サーバーURLを入力してください。", + "serverUrlValidationErrorUnsupportedScheme": "サーバーURLは http:// または https:// で始まる必要があります。", + "setStatusPageTitle": "ステータスの設定", + "settingsPageTitle": "設定", + "sharePageTitle": "共有", + "signInWithFoo": "{method}でログイン", + "snackBarDetails": "詳細", + "spoilerDefaultHeaderText": "内容を隠す", + "starredMessagesPageTitle": "スター付きメッセージ", + "statusButtonLabelStatusSet": "ステータス", + "statusButtonLabelStatusUnset": "ステータスを設定", + "statusClearButtonLabel": "クリア", + "statusSaveButtonLabel": "保存", + "statusTextHint": "自分のステータス", + "subscribeFailedTitle": "チャンネルへの参加に失敗しました", + "successChannelLinkCopied": "チャンネルのリンクをコピーしました", + "successLinkCopied": "リンクをコピーしました", + "successMessageLinkCopied": "メッセージのリンクをコピーしました", + "successMessageTextCopied": "メッセージ本文をコピーしました", + "successTopicLinkCopied": "トピックのリンクをコピーしました", + "switchAccountButton": "アカウントを切り替える", + "themeSettingDark": "ダークテーマ", + "themeSettingLight": "ライトテーマ", + "themeSettingSystem": "自動テーマ", + "themeSettingTitle": "テーマ", + "today": "今日", + "topicValidationErrorMandatoryButEmpty": "この組織ではトピックの入力が必須です。", + "topicValidationErrorTooLong": "トピックは60文字以内で入力してください。", + "topicsButtonTooltip": "トピック", + "tryAnotherAccountButton": "別のアカウントを試す", + "tryAnotherAccountMessage": "{url} のアカウントの読み込みに時間がかかっています。", + "turnOffInvisibleModeErrorTitle": "非表示モードをオフにできません。もう一度お試しください。", + "turnOnInvisibleModeErrorTitle": "非表示モードを有効にできませんでした。もう一度お試しください。", + "twoPeopleTyping": "{typist} さんと {otherTypist} さんが入力中…", + "unknownChannelName": "(不明なチャンネル)", + "unknownUserName": "(不明なユーザー)", + "unpinnedSubscriptionsLabel": "ピン留めなし", + "unsubscribeConfirmationDialogConfirmButton": "チャンネルから退出", + "unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": "このチャンネルを退出すると、再び参加できない可能性があります。", + "unsubscribeConfirmationDialogTitle": "{channelName} から退出しますか?", + "unsubscribeFailedTitle": "チャンネルからの退出に失敗しました", + "updateStatusErrorTitle": "ステータスの更新に失敗しました。もう一度お試しください。", + "upgradeWelcomeDialogDismiss": "はじめよう", + "upgradeWelcomeDialogLinkText": "お知らせブログ記事をご確認ください!", + "upgradeWelcomeDialogMessage": "より速く、洗練されたデザインで、これまでと同じ使い心地をお楽しみいただけます。", + "upgradeWelcomeDialogTitle": "新しいZulipアプリへようこそ!", + "userActiveDate": "{date}にオンライン", + "userActiveDaysAgo": "{days, plural, =1{1} other{{days}}}日前にオンライン", + "userActiveHoursAgo": "{hours, plural, =1{1} other{{hours}}}時間前にオンライン", + "userActiveMinutesAgo": "{minutes, plural, =1{1} other{{minutes}}}分前にオンライン", + "userActiveNow": "オンライン", + "userActiveYesterday": "昨日オンライン", + "userIdle": "退席中", + "userNotActiveInYear": "1年以上オフラインです", + "userRoleAdministrator": "管理者", "userRoleGuest": "ゲスト", - "@userRoleGuest": {}, + "userRoleMember": "メンバー", + "userRoleModerator": "モデレータ", + "userRoleOwner": "オーナー", "userRoleUnknown": "不明", - "@userRoleUnknown": {} + "userStatusAtTheOffice": "出社中", + "userStatusBusy": "取り込み中", + "userStatusCommuting": "移動中", + "userStatusInAMeeting": "会議中", + "userStatusOutSick": "病欠中", + "userStatusVacationing": "休暇中", + "userStatusWorkingRemotely": "在宅勤務中", + "wildcardMentionAll": "全員", + "wildcardMentionAllDmDescription": "受信者に通知", + "wildcardMentionChannel": "チャンネル", + "wildcardMentionChannelDescription": "チャンネル参加者に通知", + "wildcardMentionEveryone": "全員", + "wildcardMentionStream": "チャンネル", + "wildcardMentionStreamDescription": "ストリーム参加者に通知", + "wildcardMentionTopic": "トピック", + "wildcardMentionTopicDescription": "トピック参加者に通知", + "yesterday": "昨日", + "zulipAppTitle": "Zulip" } 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..3d17fe3952 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -1,780 +1,1518 @@ { - "errorConnectingToServerDetails": "Błąd połączenia z Zulip {serverUrl}. Spróbujmy ponownie:\n\n{error}", - "@errorConnectingToServerDetails": { - "description": "Dialog error message for a generic unknown error connecting to the server with details.", - "placeholders": { - "serverUrl": { - "type": "String", - "example": "http://example.com/" - }, - "error": { - "type": "String", - "example": "Invalid format" - } - } - }, - "aboutPageAppVersion": "Wydanie apki", "@aboutPageAppVersion": { "description": "Label for Zulip app version in About Zulip page" }, - "aboutPageTapToView": "Dotknij, aby pokazać", + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, "@aboutPageTapToView": { "description": "Item subtitle in About Zulip page to navigate to Licenses page" }, - "logOutConfirmationDialogConfirmButton": "Wyloguj", - "@logOutConfirmationDialogConfirmButton": { - "description": "Label for the 'Log out' button on a confirmation dialog for logging out." - }, - "chooseAccountButtonAddAnAccount": "Dodaj konto", - "@chooseAccountButtonAddAnAccount": { - "description": "Label for ChooseAccountPage button to add an account" + "@aboutPageTitle": { + "description": "Title for About Zulip page." }, - "profileButtonSendDirectMessage": "Wyślij wiadomość bezpośrednią", - "@profileButtonSendDirectMessage": { - "description": "Label for button in profile screen to navigate to DMs with the shown user." + "@actionSheetOptionChannelFeed": { + "description": "Label for navigating to a channel's channel-feed page." }, - "permissionsNeededTitle": "Wymagane uprawnienia", - "@permissionsNeededTitle": { - "description": "Title for dialog asking the user to grant additional permissions." + "@actionSheetOptionCopyChannelLink": { + "description": "Label for copy channel link button on action sheet." }, - "permissionsNeededOpenSettings": "Otwórz ustawienia", - "@permissionsNeededOpenSettings": { - "description": "Button label for permissions dialog button that opens the system settings screen." + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." }, - "actionSheetOptionCopyMessageText": "Skopiuj tekst wiadomości", "@actionSheetOptionCopyMessageText": { "description": "Label for copy message text button on action sheet." }, - "actionSheetOptionCopyMessageLink": "Skopiuj odnośnik do wiadomości", - "@actionSheetOptionCopyMessageLink": { - "description": "Label for copy message link button on action sheet." - }, - "actionSheetOptionShare": "Udostępnij", - "@actionSheetOptionShare": { - "description": "Label for share button on action sheet." + "@actionSheetOptionCopyTopicLink": { + "description": "Label for copy topic link button in action sheet." }, - "actionSheetOptionQuoteAndReply": "Odpowiedz cytując", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." }, - "actionSheetOptionStarMessage": "Oznacz gwiazdką", - "@actionSheetOptionStarMessage": { - "description": "Label for star button on action sheet." + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." }, - "actionSheetOptionUnstarMessage": "Odbierz gwiazdkę", - "@actionSheetOptionUnstarMessage": { - "description": "Label for unstar button on action sheet." + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." }, - "errorWebAuthOperationalErrorTitle": "Coś poszło nie tak", - "@errorWebAuthOperationalErrorTitle": { - "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." }, - "actionSheetOptionMarkAsUnread": "Odtąd oznacz jako nieprzeczytane", "@actionSheetOptionMarkAsUnread": { "description": "Label for mark as unread button on action sheet." }, - "logOutConfirmationDialogMessage": "Aby użyć tego konta należy wypełnić URL organizacji oraz dane konta.", - "@logOutConfirmationDialogMessage": { - "description": "Message for a confirmation dialog for logging out." + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." }, - "permissionsDeniedCameraAccess": "Aby odebrać obraz Zulip musi uzyskać dodatkowe uprawnienia w Ustawieniach.", - "@permissionsDeniedCameraAccess": { - "description": "Message for dialog asking the user to grant permissions for camera access." + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." }, - "permissionsDeniedReadExternalStorage": "Aby odebrać pliki Zulip musi uzyskać dodatkowe uprawnienia w Ustawieniach.", - "@permissionsDeniedReadExternalStorage": { - "description": "Message for dialog asking the user to grant permissions for external storage read access." + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." }, - "errorWebAuthOperationalError": "Wystąpił niespodziewany błąd.", - "@errorWebAuthOperationalError": { - "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." }, - "errorAccountLoggedInTitle": "Konto już wylogowane", - "@errorAccountLoggedInTitle": { - "description": "Error title on attempting to log into an account that's already logged in." + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." }, - "errorAccountLoggedIn": "Konto {email} na {server} znajduje się już na liście dodanych kont.", - "@errorAccountLoggedIn": { - "description": "Error message on attempting to log into an account that's already logged in.", - "placeholders": { - "email": { - "type": "String", - "example": "user@example.com" - }, - "server": { - "type": "String", - "example": "https://example.com" - } - } + "@actionSheetOptionSeeWhoReacted": { + "description": "Label for the 'See who reacted' button in the message action sheet." }, - "errorCouldNotFetchMessageSource": "Nie można uzyskać źródłowej wiadomości", - "@errorCouldNotFetchMessageSource": { - "description": "Error message when the source of a message could not be fetched." + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." }, - "errorCopyingFailed": "Nie udało się skopiować", - "@errorCopyingFailed": { - "description": "Error message when copying the text of a message to the user's system clipboard failed." + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." }, - "errorFilesTooLargeTitle": "{num, plural, =1{Plik} other{Pliki}} ponad limit", - "@errorFilesTooLargeTitle": { - "description": "Error title when attached files are too large in size.", - "placeholders": { - "num": { - "type": "int", - "example": "4" - } - } + "@actionSheetOptionSubscribe": { + "description": "Label in the channel action sheet for subscribing to the channel." }, - "errorLoginInvalidInputTitle": "Błędny wsad", - "@errorLoginInvalidInputTitle": { - "description": "Error title for login when input is invalid." + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." }, - "errorLoginFailedTitle": "Logowanie bez powodzenia", - "@errorLoginFailedTitle": { - "description": "Error title for login when signing into a Zulip server fails." + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." }, - "errorMessageNotSent": "Nie wysłano wiadomości", - "@errorMessageNotSent": { - "description": "Error message for compose box when a message could not be sent." + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." }, - "errorLoginCouldNotConnect": "Nie udało się połączyć z serwerem:\n{url}", - "@errorLoginCouldNotConnect": { - "description": "Error message when the app could not connect to the server.", - "placeholders": { - "url": { - "type": "String", - "example": "http://example.com/" - } - } + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." }, - "errorFilesTooLarge": "{num, plural, =1{Plik jest} other{{num} Pliki są}} ponad limit serwera {maxFileUploadSizeMib} MiB i nie zostaną przyjęte:\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" - } - } + "@actionSheetOptionUnsubscribe": { + "description": "Label in the channel action sheet for unsubscribing from the channel." }, - "errorServerMessage": "Odpowiedź serwera:\n\n{message}", - "@errorServerMessage": { - "description": "Error message that quotes an error from the server.", - "placeholders": { - "message": { - "type": "String", - "example": "Invalid format" - } - } + "@actionSheetOptionViewReadReceipts": { + "description": "Label for the 'View read receipts' button in the message action sheet." }, - "errorConnectingToServerShort": "Błąd połączenia z Zulip. Ponawiam…", - "@errorConnectingToServerShort": { - "description": "Short error message for a generic unknown error connecting to the server." + "@actionSheetReadReceipts": { + "description": "Title for the \"Read receipts\" bottom sheet." }, - "errorHandlingEventTitle": "Błąd obsługi zdarzenia Zulip. Ponnawiam połączenie…", - "@errorHandlingEventTitle": { - "description": "Error title on failing to handle a Zulip server event." + "@actionSheetReadReceiptsErrorReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when loading read receipts failed." }, - "errorHandlingEventDetails": "Błąd zdarzenia Zulip z {serverUrl}; ponawiam.\n\nBłąd: {error}\n\nZdarzenie: {event}", - "@errorHandlingEventDetails": { - "description": "Error details on failing to handle a Zulip server event.", + "@actionSheetReadReceiptsReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when one or more people have read the message.", "placeholders": { - "serverUrl": { - "type": "String", - "example": "https://chat.example.com" - }, - "error": { - "type": "String", - "example": "Unexpected null value" - }, - "event": { - "type": "String", - "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')" + "count": { + "example": "1", + "type": "int" } } }, - "errorSharingFailed": "Udostępnianie bez powodzenia", - "@errorSharingFailed": { - "description": "Error message when sharing a message failed." + "@actionSheetReadReceiptsZeroReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when no one has read the message." }, - "errorStarMessageFailedTitle": "Dodanie gwiazdki bez powodzenia", - "@errorStarMessageFailedTitle": { - "description": "Error title when starring a message failed." + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." }, - "errorUnstarMessageFailedTitle": "Odebranie gwiazdki bez powodzenia", - "@errorUnstarMessageFailedTitle": { - "description": "Error title when unstarring a message failed." + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" }, - "successLinkCopied": "Skopiowano odnośnik", - "@successLinkCopied": { - "description": "Success message after copy link action completed." + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." }, - "successMessageTextCopied": "Skopiowano tekst wiadomości", - "@successMessageTextCopied": { - "description": "Message when content of a message was copied to the user's system clipboard." + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." }, - "successMessageLinkCopied": "Skopiowano odnośnik wiadomości", - "@successMessageLinkCopied": { - "description": "Message when link of a message was copied to the user's system clipboard." + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" }, - "errorBannerCannotPostInChannelLabel": "Nie masz uprawnień do dodawania wpisów w tym kanale.", - "@errorBannerCannotPostInChannelLabel": { - "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." }, - "composeBoxAttachFilesTooltip": "Dołącz pliki", "@composeBoxAttachFilesTooltip": { "description": "Tooltip for compose box icon to attach a file to the message." }, - "composeBoxAttachMediaTooltip": "Dołącz obrazy lub wideo", + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, "@composeBoxAttachMediaTooltip": { "description": "Tooltip for compose box icon to attach media to the message." }, - "composeBoxAttachFromCameraTooltip": "Zrób zdjęcie", - "@composeBoxAttachFromCameraTooltip": { - "description": "Tooltip for compose box icon to attach an image from the camera to the message." + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." }, - "composeBoxGenericContentHint": "Wpisz wiadomość", - "@composeBoxGenericContentHint": { - "description": "Hint text for content input when sending a message." + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "example": "#channel name > topic name", + "type": "String" + } + } }, - "composeBoxDmContentHint": "Napisz do @{user}", "@composeBoxDmContentHint": { "description": "Hint text for content input when sending a message to one other person.", "placeholders": { "user": { - "type": "String", - "example": "channel name" + "example": "channel name", + "type": "String" } } }, - "composeBoxGroupDmContentHint": "Napisz do grupy", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "example": "general chat", + "type": "String" + } + } + }, + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, "@composeBoxGroupDmContentHint": { "description": "Hint text for content input when sending a message to a group." }, - "composeBoxSelfDmContentHint": "Zanotuj coś na przyszłość", - "@composeBoxSelfDmContentHint": { - "description": "Hint text for content input when sending a message to yourself." - }, - "composeBoxChannelContentHint": "Wiadomość #{channel} > {topic}", - "@composeBoxChannelContentHint": { - "description": "Hint text for content input when sending a message to a channel", + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", "placeholders": { - "channel": { - "type": "String", - "example": "channel name" - }, - "topic": { - "type": "String", - "example": "topic name" + "messageId": { + "example": "1234", + "type": "int" } } }, - "composeBoxUnknownChannelName": "(nieznany kanał)", - "@composeBoxUnknownChannelName": { - "description": "Replacement name for channel when it cannot be found in the store." + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." }, - "composeBoxTopicHintText": "Wątek", "@composeBoxTopicHintText": { "description": "Hint text for topic input widget in compose box." }, - "composeBoxUploadingFilename": "Przekazywanie {filename}…", "@composeBoxUploadingFilename": { "description": "Placeholder in compose box showing the specified file is currently uploading.", "placeholders": { "filename": { - "type": "String", - "example": "file.txt" + "example": "file.txt", + "type": "String" } } }, - "unknownUserName": "(nieznany użytkownik)", - "@unknownUserName": { - "description": "Name placeholder to use for a user when we don't know their name." + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." }, - "messageListGroupYouAndOthers": "Ty i {others}", - "@messageListGroupYouAndOthers": { - "description": "Message list recipient header for a DM group with others.", - "placeholders": { - "others": { - "type": "String", - "example": "Alice, Bob" - } - } + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." }, - "contentValidationErrorTooLong": "Wiadomość nie może być dłuższa niż 10000 znaków.", "@contentValidationErrorTooLong": { "description": "Content validation error message when the message is too long." }, - "dialogCancel": "Anuluj", + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, "@dialogCancel": { "description": "Button label in dialogs to cancel." }, - "dialogContinue": "Kontynuuj", + "@dialogClose": { + "description": "Button label in dialogs to close." + }, "@dialogContinue": { "description": "Button label in dialogs to proceed." }, - "errorDialogTitle": "Błąd", - "@errorDialogTitle": { - "description": "Generic title for error dialog." + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." }, - "snackBarDetails": "Szczegóły", - "@snackBarDetails": { - "description": "Button label for snack bar button that opens a dialog with more details." + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." }, - "lightboxCopyLinkTooltip": "Skopiuj odnośnik", - "@lightboxCopyLinkTooltip": { - "description": "Tooltip in lightbox for the copy link action." + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." }, - "loginPageTitle": "Zaloguj", - "@loginPageTitle": { - "description": "Title for login page." + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." }, - "loginFormSubmitLabel": "Zaloguj", - "@loginFormSubmitLabel": { - "description": "Button text to submit login credentials." + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } }, - "loginMethodDivider": "LUB", - "@loginMethodDivider": { - "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." }, - "signInWithFoo": "Logowanie z {method}", - "@signInWithFoo": { - "description": "Button to use {method} to sign in to the app.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", "placeholders": { - "method": { - "type": "String", - "example": "Google" + "email": { + "example": "user@example.com", + "type": "String" + }, + "server": { + "example": "https://example.com", + "type": "String" } } }, - "loginAddAnAccountPageTitle": "Dodaj konto", - "@loginAddAnAccountPageTitle": { - "description": "Title for page to add a Zulip account." + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." }, - "loginServerUrlInputLabel": "URL serwera Zulip", - "@loginServerUrlInputLabel": { - "description": "Input label in login page for Zulip server URL entry." + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." }, - "loginHidePassword": "Ukryj hasło", - "@loginHidePassword": { - "description": "Icon label for button to hide password in input form." + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." }, - "loginEmailLabel": "Adres email", - "@loginEmailLabel": { - "description": "Label for input when an email is required to log in." + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "error": { + "example": "Invalid format", + "type": "String" + }, + "serverUrl": { + "example": "http://example.com/", + "type": "String" + } + } }, - "loginErrorMissingEmail": "Proszę podaj swój email.", - "@loginErrorMissingEmail": { - "description": "Error message when an empty email was provided." + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." }, - "loginPasswordLabel": "Hasło", - "@loginPasswordLabel": { - "description": "Label for password input field." + "@errorContentNotInsertedTitle": { + "description": "Title for error dialog when an attempt to insert rich content failed." }, - "loginErrorMissingPassword": "Proszę wprowadź hasło.", - "@loginErrorMissingPassword": { - "description": "Error message when an empty password was provided." + "@errorContentToInsertIsEmpty": { + "description": "Error message when the rich content to be inserted is empty or cannot be accessed." }, - "loginUsernameLabel": "Użytkownik", - "@loginUsernameLabel": { - "description": "Label for input when a username is required to log in." + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." }, - "loginErrorMissingUsername": "Proszę podaj nazwę użytkownika.", - "@loginErrorMissingUsername": { - "description": "Error message when an empty username was provided." + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." }, - "topicValidationErrorMandatoryButEmpty": "Wątki są wymagane przez tę organizację.", - "@topicValidationErrorMandatoryButEmpty": { - "description": "Topic validation error when topic is required but was empty." + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." }, - "errorInvalidResponse": "Nieprawidłowa odpowiedź serwera", - "@errorInvalidResponse": { - "description": "Error message when an API call returned an invalid response." + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." }, - "errorVideoPlayerFailed": "Nie da rady odtworzyć wideo", - "@errorVideoPlayerFailed": { - "description": "Error message when a video fails to play." + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "example": "https://chat.example.com", + "type": "String" + } + } }, - "serverUrlValidationErrorEmpty": "Proszę podaj URL.", - "@serverUrlValidationErrorEmpty": { - "description": "Error message when URL is empty" + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." }, - "serverUrlValidationErrorInvalidUrl": "Proszę podaj poprawny URL.", - "@serverUrlValidationErrorInvalidUrl": { - "description": "Error message when URL is not in a valid format." + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." }, - "serverUrlValidationErrorNoUseEmail": "Proszę podaj adres URL serwera a nie swój email.", - "@serverUrlValidationErrorNoUseEmail": { - "description": "Error message when URL looks like an email" + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." }, - "spoilerDefaultHeaderText": "Spoiler", - "@spoilerDefaultHeaderText": { - "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." }, - "markAllAsReadLabel": "Oznacz wszystkie jako przeczytane", - "@markAllAsReadLabel": { - "description": "Button text to mark messages as read." + "@errorDialogTitle": { + "description": "Generic title for error dialog." }, - "markAsReadComplete": "Oznaczono {num, plural, =1{1 wiadomość} other{{num} wiadomości}} jako przeczytane.", - "@markAsReadComplete": { - "description": "Message when marking messages as read has completed.", + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", "placeholders": { + "listMessage": { + "example": "foo.txt\nbar.txt", + "type": "String" + }, + "maxFileUploadSizeMib": { + "example": "15", + "type": "int" + }, "num": { - "type": "int", - "example": "4" + "example": "2", + "type": "int" } } }, - "topicValidationErrorTooLong": "Tytuł nie może być dłuższy niż 60 znaków.", - "@topicValidationErrorTooLong": { - "description": "Topic validation error when topic is too long." + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "error": { + "example": "Unexpected null value", + "type": "String" + }, + "event": { + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')", + "type": "String" + }, + "serverUrl": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + } + }, + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." }, - "errorMalformedResponse": "Zdeforomowana odpowiedź serwera; status HTTP {httpStatus}", "@errorMalformedResponse": { "description": "Error message when an API call fails because we could not parse the response.", "placeholders": { "httpStatus": { - "type": "int", - "example": "200" + "example": "200", + "type": "int" } } }, - "errorRequestFailed": "Błąd uzyskania sieci: status HTTP {httpStatus}", - "@errorRequestFailed": { - "description": "Error message when an API call fails.", + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", "placeholders": { + "details": { + "example": "type 'Null' is not a subtype of type 'String' in type cast", + "type": "String" + }, "httpStatus": { - "type": "int", - "example": "500" + "example": "200", + "type": "int" } } }, - "errorMarkAsReadFailedTitle": "Oznaczanie jako przeczytane bez powodzenia", "@errorMarkAsReadFailedTitle": { "description": "Error title when mark as read action failed." }, - "markAsUnreadInProgress": "Oznaczanie jako nieprzeczytane…", - "@markAsUnreadInProgress": { - "description": "Progress message when marking messages as unread." - }, - "errorMarkAsUnreadFailedTitle": "Oznaczanie jako nieprzeczytane bez powodzenia", "@errorMarkAsUnreadFailedTitle": { "description": "Error title when mark as unread action failed." }, - "today": "Dzisiaj", - "@today": { - "description": "Term to use to reference the current day." - }, - "yesterday": "Wczoraj", - "@yesterday": { - "description": "Term to use to reference the previous day." + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." }, - "userRoleOwner": "Właściciel", - "@userRoleOwner": { - "description": "Label for UserRole.owner" + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." }, - "userRoleAdministrator": "Administrator", - "@userRoleAdministrator": { - "description": "Label for UserRole.administrator" + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." }, - "userRoleMember": "Członek", - "@userRoleMember": { - "description": "Label for UserRole.member" + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." }, - "userRoleGuest": "Gość", - "@userRoleGuest": { - "description": "Label for UserRole.guest" + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." }, - "userRoleUnknown": "Nieznany", - "@userRoleUnknown": { - "description": "Label for UserRole.unknown" + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" }, - "recentDmConversationsPageTitle": "Wiadomości bezpośrednie", - "@recentDmConversationsPageTitle": { - "description": "Title for the page with a list of DM conversations." + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" }, - "combinedFeedPageTitle": "Mieszany widok", - "@combinedFeedPageTitle": { - "description": "Page title for the 'Combined feed' message view." + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." }, - "starredMessagesPageTitle": "Wiadomości z gwiazdką", - "@starredMessagesPageTitle": { - "description": "Page title for the 'Starred messages' message view." + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" }, - "channelFeedButtonTooltip": "Strumień kanału", - "@channelFeedButtonTooltip": { - "description": "Tooltip for button to navigate to a given channel's feed" + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" }, - "notifGroupDmConversationLabel": "{senderFullName} do ciebie i {numOthers, plural, =1{1 innego} other{{numOthers} innych}}", - "@notifGroupDmConversationLabel": { - "description": "Label for a group DM conversation notification.", + "@errorRequestFailed": { + "description": "Error message when an API call fails.", "placeholders": { - "senderFullName": { - "type": "String", - "example": "Alice" - }, - "numOthers": { - "type": "int", - "example": "4" + "httpStatus": { + "example": "500", + "type": "int" } } }, - "notifSelfUser": "Ty", - "@notifSelfUser": { - "description": "Display name for the user themself, to show after replying in an Android notification" + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." }, - "onePersonTyping": "{typist} coś pisze…", - "@onePersonTyping": { - "description": "Text to display when there is one user typing.", + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", "placeholders": { - "typist": { - "type": "String", - "example": "Alice" + "message": { + "example": "Invalid format", + "type": "String" } } }, - "twoPeopleTyping": "{typist} i {otherTypist} coś piszą…", - "@twoPeopleTyping": { - "description": "Text to display when there are two users typing.", + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", "placeholders": { - "typist": { - "type": "String", - "example": "Alice" + "minSupportedZulipVersion": { + "example": "4.0", + "type": "String" }, - "otherTypist": { - "type": "String", - "example": "Bob" + "url": { + "example": "http://chat.example.com/", + "type": "String" + }, + "zulipVersion": { + "example": "3.2", + "type": "String" } } }, - "manyPeopleTyping": "Wielu ludzi coś pisze…", - "@manyPeopleTyping": { - "description": "Text to display when there are multiple users typing." + "@errorSharingAccountNotLoggedIn": { + "description": "Error title when sharing content received from other apps fails, when there is no account logged in" }, - "messageIsEditedLabel": "ZMIENIONO", - "@messageIsEditedLabel": { - "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." }, - "pollWidgetQuestionMissing": "Brak pytania.", - "@pollWidgetQuestionMissing": { - "description": "Text to display for a poll when the question is missing" + "@errorSharingTitle": { + "description": "Error title when sharing content received from other apps fails" }, - "pollWidgetOptionsMissing": "Ta sonda nie ma opcji do wyboru.", - "@pollWidgetOptionsMissing": { - "description": "Text to display for a poll when it has no options" + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." }, - "errorNotificationOpenTitle": "Otwieranie powiadomienia bez powodzenia", - "@errorNotificationOpenTitle": { - "description": "Error title when notification opening fails" + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." }, - "errorNotificationOpenAccountMissing": "Konto związane z tym powiadomieniem już nie istnieje.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." }, - "aboutPageOpenSourceLicenses": "Licencje otwartego źródła", - "@aboutPageOpenSourceLicenses": { - "description": "Item title in About Zulip page to navigate to Licenses page" + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." }, - "serverUrlValidationErrorUnsupportedScheme": "Adres URL serwera musi zaczynać się od http:// or https://.", - "@serverUrlValidationErrorUnsupportedScheme": { - "description": "Error message when URL has an unsupported scheme." + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." }, - "errorMessageDoesNotSeemToExist": "Taka wiadomość raczej nie istnieje.", - "@errorMessageDoesNotSeemToExist": { - "description": "Error message when loading a message that does not exist." + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." }, - "chooseAccountPageTitle": "Wybierz konto", - "@chooseAccountPageTitle": { - "description": "Title for the page to choose between Zulip accounts." + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." }, - "chooseAccountPageLogOutButton": "Wyloguj", - "@chooseAccountPageLogOutButton": { - "description": "Label for the 'Log out' button for an account on the choose-account page" + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." }, - "composeBoxSendTooltip": "Wyślij", - "@composeBoxSendTooltip": { - "description": "Tooltip for send button in compose box." + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" }, - "messageListGroupYouWithYourself": "Ty ze sobą", - "@messageListGroupYouWithYourself": { - "description": "Message list recipient header for a DM group that only includes yourself." + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "example": "foo.txt", + "type": "String" + }, + "size": { + "example": "20.2", + "type": "String" + } + } + }, + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." }, - "logOutConfirmationDialogTitle": "Wylogować?", "@logOutConfirmationDialogTitle": { "description": "Title for a confirmation dialog for logging out." }, - "aboutPageTitle": "O Zulip", - "@aboutPageTitle": { - "description": "Title for About Zulip page." + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." }, - "errorLoginCouldNotConnectTitle": "Nie można połączyć", - "@errorLoginCouldNotConnectTitle": { - "description": "Error title when the app could not connect to the server." + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." }, - "contentValidationErrorEmpty": "Nie masz nic do wysłania!", - "@contentValidationErrorEmpty": { - "description": "Content validation error message when the message is empty." + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." }, - "errorDialogContinue": "OK", - "@errorDialogContinue": { - "description": "Button label in error dialogs to acknowledge the error and close the dialog." + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." }, - "errorFailedToUploadFileTitle": "Nie udało się załadować pliku: {filename}", - "@errorFailedToUploadFileTitle": { - "description": "Error title when the specified file failed to upload.", - "placeholders": { - "filename": { - "type": "String", - "example": "file.txt" - } - } + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." }, - "errorMalformedResponseWithCause": "Zdeformowana odpowiedź serwera; status HTTP {httpStatus}; {details}", - "@errorMalformedResponseWithCause": { - "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", - "placeholders": { - "httpStatus": { - "type": "int", - "example": "200" - }, - "details": { - "type": "String", - "example": "type 'Null' is not a subtype of type 'String' in type cast" - } - } + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." }, - "errorQuotationFailed": "Cytowanie bez powodzenia", - "@errorQuotationFailed": { - "description": "Error message when quoting a message failed." + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." }, - "errorBannerDeactivatedDmLabel": "Nie można wysyłać wiadomości do dezaktywowanych użytkowników.", - "@errorBannerDeactivatedDmLabel": { - "description": "Label text for error banner when sending a message to one or multiple deactivated users." + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." }, - "contentValidationErrorUploadInProgress": "Zaczekaj na zakończenie przekazywania.", - "@contentValidationErrorUploadInProgress": { - "description": "Content validation error message when attachments have not finished uploading." + "@loginPageTitle": { + "description": "Title for login page." }, - "messageIsMovedLabel": "PRZENIESIONO", - "@messageIsMovedLabel": { - "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." }, - "markAsUnreadComplete": "Oznaczono {num, plural, =1{1 wiadomość} other{{num} wiadomości}} jako nieprzeczytane.", "@markAsUnreadComplete": { "description": "Message when marking messages as unread has completed.", "placeholders": { "num": { - "type": "int", - "example": "4" + "example": "4", + "type": "int" } } }, - "contentValidationErrorQuoteAndReplyInProgress": "Zaczekaj na zakończenie pobierania cytatu.", - "@contentValidationErrorQuoteAndReplyInProgress": { - "description": "Content validation error message when a quotation has not completed yet." + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." }, - "errorNetworkRequestFailed": "Dostęp do sieci bez powodzenia", - "@errorNetworkRequestFailed": { - "description": "Error message when a network request fails." + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." }, - "markAsReadInProgress": "Oznaczanie wiadomości jako przeczytane…", - "@markAsReadInProgress": { - "description": "Progress message when marking messages as read." + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." }, - "mentionsPageTitle": "Wzmianki", - "@mentionsPageTitle": { - "description": "Page title for the 'Mentions' message view." + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." }, - "userRoleModerator": "Moderator", - "@userRoleModerator": { - "description": "Label for UserRole.moderator" + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." }, - "actionSheetOptionMuteTopic": "Wycisz wątek", - "@actionSheetOptionMuteTopic": { - "description": "Label for muting a topic on action sheet." + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." }, - "actionSheetOptionUnmuteTopic": "Wznów wątek", - "@actionSheetOptionUnmuteTopic": { - "description": "Label for unmuting a topic on action sheet." + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." }, - "actionSheetOptionFollowTopic": "Śledź wątek", - "@actionSheetOptionFollowTopic": { - "description": "Label for following a topic on action sheet." + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." }, - "actionSheetOptionUnfollowTopic": "Nie śledź wątku", - "@actionSheetOptionUnfollowTopic": { - "description": "Label for unfollowing a topic on action sheet." + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" }, - "switchAccountButton": "Przełącz konto", - "@switchAccountButton": { - "description": "Label for main-menu button leading to the choose-account page." + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" }, - "tryAnotherAccountMessage": "Twoje konto na {url} wymaga jeszcze chwili na załadowanie.", - "@tryAnotherAccountMessage": { - "description": "Message that appears on the loading screen after waiting for some time.", - "url": { - "type": "String", - "example": "http://chat.example.com/" + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } } }, - "tryAnotherAccountButton": "Sprawdź inne konto", - "@tryAnotherAccountButton": { - "description": "Label for loading screen button prompting user to try another account." - }, - "errorFollowTopicFailed": "Śledzenie bez powodzenia", - "@errorFollowTopicFailed": { - "description": "Error message when following a topic failed." + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." }, - "inboxPageTitle": "Odebrane", - "@inboxPageTitle": { - "description": "Title for the page with unreads." + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" }, - "channelsPageTitle": "Kanały", - "@channelsPageTitle": { - "description": "Title for the page with a list of subscribed channels." + "@mutedUser": { + "description": "Name for a muted user to display all over the app." }, - "emojiReactionsMore": "więcej", - "@emojiReactionsMore": { - "description": "Label for a button opening the emoji picker." + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." }, - "emojiPickerSearchEmoji": "Szukaj emoji", - "@emojiPickerSearchEmoji": { - "description": "Hint text for the emoji picker search text field." + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." }, - "dialogClose": "Zamknij", - "@dialogClose": { - "description": "Button label in dialogs to close." + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." }, - "errorReactionRemovingFailedTitle": "Usuwanie reakcji bez powodzenia", - "@errorReactionRemovingFailedTitle": { - "description": "Error title when removing a message reaction fails" + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." }, - "errorReactionAddingFailedTitle": "Dodanie reakcji bez powodzenia", - "@errorReactionAddingFailedTitle": { - "description": "Error title when adding a message reaction fails" + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" }, - "errorMuteTopicFailed": "Wyciszenie bez powodzenia", - "@errorMuteTopicFailed": { - "description": "Error message when muting a topic failed." + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected" }, - "mainMenuMyProfile": "Mój profil", - "@mainMenuMyProfile": { - "description": "Label for main-menu button leading to the user's own profile." + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." }, - "errorUnfollowTopicFailed": "Nie śledź bez powodzenia", - "@errorUnfollowTopicFailed": { - "description": "Error message when unfollowing a topic failed." + "@noStatusText": { + "description": "The text part of the status button sub-label in self-user profile page when status text is not set." }, - "errorUnmuteTopicFailed": "Wznowienie bez powodzenia", - "@errorUnmuteTopicFailed": { - "description": "Error message when unmuting a topic failed." - } + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "numOthers": { + "example": "4", + "type": "int" + }, + "senderFullName": { + "example": "Alice", + "type": "String" + } + } + }, + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "@pollVoterNames": { + "description": "The list of people who voted for a poll option, wrapped in parentheses.", + "placeholders": { + "voterNames": { + "example": "Alice, Bob, Chad", + "type": "String" + } + } + }, + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "@reactionChipLabel": { + "description": "Text describing a reaction chip, with the emoji name and a list or number of votes. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "votes": { + "example": "You, Chris, Greg", + "type": "String" + } + } + }, + "@reactionChipVotesYouAndOthers": { + "description": "The number of votes on a reaction chip, where the self-user and at least one other user has voted. (An accessibility label for assistive technology.)", + "placeholders": { + "otherUsersCount": { + "example": "4", + "type": "int" + } + } + }, + "@reactionChipsLabel": { + "description": "Text identifying the container of reaction chips on a message. (An accessibility label for assistive technology.)" + }, + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." + }, + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "@seeWhoReactedSheetEmojiNameWithVoteCount": { + "description": "In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetHeaderLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.)", + "placeholders": { + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetNoReactions": { + "description": "Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened)." + }, + "@seeWhoReactedSheetUserListLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "@setStatusPageTitle": { + "description": "Title for the 'Set status' page." + }, + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "@sharePageTitle": { + "description": "Title for the page about sharing content received from other apps." + }, + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "example": "Google", + "type": "String" + } + } + }, + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "@statusButtonLabelStatusSet": { + "description": "The status button label in self-user profile page when status is set." + }, + "@statusButtonLabelStatusUnset": { + "description": "The status button label in self-user profile page when status is not set." + }, + "@statusClearButtonLabel": { + "description": "Label for the button that clears the user status, in 'Set status' page." + }, + "@statusSaveButtonLabel": { + "description": "Label for the button that saves the user status, in 'Set status' page." + }, + "@statusTextHint": { + "description": "Hint text for the status text input field in 'Set status' page." + }, + "@subscribeFailedTitle": { + "description": "Error title when subscribing to a channel failed." + }, + "@successChannelLinkCopied": { + "description": "Message when link of a channel was copied to the user's system clipboard." + }, + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "@successTopicLinkCopied": { + "description": "Message when link of a topic was copied to the user's system clipboard." + }, + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@today": { + "description": "Term to use to reference the current day." + }, + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + }, + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "otherTypist": { + "example": "Bob", + "type": "String" + }, + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "@unsubscribeConfirmationDialogConfirmButton": { + "description": "Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel." + }, + "@unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": { + "description": "Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe." + }, + "@unsubscribeConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for unsubscribing from a channel.", + "placeholders": { + "channelName": { + "example": "mobile", + "type": "String" + } + } + }, + "@unsubscribeFailedTitle": { + "description": "Error title when unsubscribing from a channel failed." + }, + "@updateStatusErrorTitle": { + "description": "Error title when updating user status failed." + }, + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "@userActiveDate": { + "description": "Indicates the date when a user was last active on Zulip (who is currently offline).\n\nThe date might be day and month if recent, or day, month, and year if less recent.", + "placeholders": { + "date": { + "example": "Aug 1, 2024", + "type": "String" + } + } + }, + "@userActiveDaysAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "days": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveHoursAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "hours": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveMinutesAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "minutes": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveNow": { + "description": "Indicates a user is currently active on Zulip (not idle or offline)" + }, + "@userActiveYesterday": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userIdle": { + "description": "Indicates a user is currently idle on Zulip (not active, but not offline)" + }, + "@userNotActiveInYear": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "@userStatusAtTheOffice": { + "description": "A suggested user status text, 'At the office'." + }, + "@userStatusBusy": { + "description": "A suggested user status text, 'Busy'." + }, + "@userStatusCommuting": { + "description": "A suggested user status text, 'Commuting'." + }, + "@userStatusInAMeeting": { + "description": "A suggested user status text, 'In a meeting'." + }, + "@userStatusOutSick": { + "description": "A suggested user status text, 'Out sick'." + }, + "@userStatusVacationing": { + "description": "A suggested user status text, 'Vacationing'." + }, + "@userStatusWorkingRemotely": { + "description": "A suggested user status text, 'Working remotely'." + }, + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "aboutPageAppVersion": "Wydanie apki", + "aboutPageOpenSourceLicenses": "Licencje otwartego źródła", + "aboutPageTapToView": "Dotknij, aby pokazać", + "aboutPageTitle": "O Zulip", + "actionSheetOptionChannelFeed": "Strumień kanału", + "actionSheetOptionCopyChannelLink": "Skopiuj odnośnik do kanału", + "actionSheetOptionCopyMessageLink": "Skopiuj odnośnik do wiadomości", + "actionSheetOptionCopyMessageText": "Skopiuj tekst wiadomości", + "actionSheetOptionCopyTopicLink": "Skopiuj odnośnik do wątku", + "actionSheetOptionEditMessage": "Zmień wiadomość", + "actionSheetOptionFollowTopic": "Śledź wątek", + "actionSheetOptionHideMutedMessage": "Ukryj ponownie wyciszone wiadomości", + "actionSheetOptionListOfTopics": "Lista wątków", + "actionSheetOptionMarkAsUnread": "Odtąd oznacz jako nieprzeczytane", + "actionSheetOptionMarkChannelAsRead": "Oznacz kanał jako przeczytany", + "actionSheetOptionMarkTopicAsRead": "Oznacz wątek jako przeczytany", + "actionSheetOptionMuteTopic": "Wycisz wątek", + "actionSheetOptionQuoteMessage": "Cytuj wiadomość", + "actionSheetOptionResolveTopic": "Oznacz jako rozwiązany", + "actionSheetOptionSeeWhoReacted": "Pokaż kto zareagował", + "actionSheetOptionShare": "Udostępnij", + "actionSheetOptionStarMessage": "Oznacz gwiazdką", + "actionSheetOptionSubscribe": "Subskrybuj", + "actionSheetOptionUnfollowTopic": "Nie śledź wątku", + "actionSheetOptionUnmuteTopic": "Wznów wątek", + "actionSheetOptionUnresolveTopic": "Oznacz brak rozwiązania", + "actionSheetOptionUnstarMessage": "Odbierz gwiazdkę", + "actionSheetOptionUnsubscribe": "Odsubskrybuj", + "actionSheetOptionViewReadReceipts": "Zobacz potwierdzenia odczytu", + "actionSheetReadReceipts": "Potwierdzenia odczytu", + "actionSheetReadReceiptsErrorReadCount": "Ładowanie potwierdzeń odczytu bez powodzenia.", + "actionSheetReadReceiptsReadCount": "{count, plural, =1{Ta wiadomość została przeczytana przez {count} osobę:} other{Ta wiadomość została przeczytana przez {count} osób:}}", + "actionSheetReadReceiptsZeroReadCount": "Nikt jeszcze nie widział tej wiadomości.", + "appVersionUnknownPlaceholder": "(…)", + "channelFeedButtonTooltip": "Strumień kanału", + "channelsEmptyPlaceholder": "Nie śledzisz żadnego z kanałów.", + "channelsPageTitle": "Kanały", + "chooseAccountButtonAddAnAccount": "Dodaj konto", + "chooseAccountPageLogOutButton": "Wyloguj", + "chooseAccountPageTitle": "Wybierz konto", + "combinedFeedPageTitle": "Mieszany widok", + "composeBoxAttachFilesTooltip": "Dołącz pliki", + "composeBoxAttachFromCameraTooltip": "Zrób zdjęcie", + "composeBoxAttachMediaTooltip": "Dołącz obrazy lub wideo", + "composeBoxBannerButtonCancel": "Anuluj", + "composeBoxBannerButtonSave": "Zapisz", + "composeBoxBannerLabelEditMessage": "Zmień wiadomość", + "composeBoxChannelContentHint": "Wiadomość do {destination}", + "composeBoxDmContentHint": "Napisz do @{user}", + "composeBoxEnterTopicOrSkipHintText": "Wpisz tytuł wątku (pomiń aby uzyskać “{defaultTopicName}”)", + "composeBoxGenericContentHint": "Wpisz wiadomość", + "composeBoxGroupDmContentHint": "Napisz do grupy", + "composeBoxLoadingMessage": "(ładowanie wiadomości {messageId})", + "composeBoxSelfDmContentHint": "Zanotuj coś na przyszłość", + "composeBoxSendTooltip": "Wyślij", + "composeBoxTopicHintText": "Wątek", + "composeBoxUploadingFilename": "Przekazywanie {filename}…", + "contentValidationErrorEmpty": "Nie masz nic do wysłania!", + "contentValidationErrorQuoteAndReplyInProgress": "Zaczekaj na zakończenie pobierania cytatu.", + "contentValidationErrorTooLong": "Wiadomość nie może być dłuższa niż 10000 znaków.", + "contentValidationErrorUploadInProgress": "Zaczekaj na zakończenie przekazywania.", + "dialogCancel": "Anuluj", + "dialogClose": "Zamknij", + "dialogContinue": "Kontynuuj", + "discardDraftConfirmationDialogConfirmButton": "Odrzuć", + "discardDraftConfirmationDialogTitle": "Czy chcesz przerwać szykowanie wpisu?", + "discardDraftForEditConfirmationDialogMessage": "Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.", + "discardDraftForOutboxConfirmationDialogMessage": "Przywracając wiadomość, która nie została wysłana, wyczyścisz zawartość kreatora nowej.", + "dmsWithOthersPageTitle": "DM z {others}", + "dmsWithYourselfPageTitle": "DM do siebie", + "editAlreadyInProgressMessage": "Operacja zmiany w toku. Zaczekaj na jej zakończenie.", + "editAlreadyInProgressTitle": "Nie udało się zapisać zmiany", + "emojiPickerSearchEmoji": "Szukaj emoji", + "emojiReactionsMore": "więcej", + "emptyMessageList": "Póki co brak wiadomości.", + "emptyMessageListSearch": "Brak wyników wyszukiwania.", + "errorAccountLoggedIn": "Konto {email} na {server} znajduje się już na liście dodanych kont.", + "errorAccountLoggedInTitle": "Konto już wylogowane", + "errorBannerCannotPostInChannelLabel": "Nie masz uprawnień do dodawania wpisów w tym kanale.", + "errorBannerDeactivatedDmLabel": "Nie można wysyłać wiadomości do dezaktywowanych użytkowników.", + "errorConnectingToServerDetails": "Błąd połączenia z Zulip {serverUrl}. Spróbujmy ponownie:\n\n{error}", + "errorConnectingToServerShort": "Błąd połączenia z Zulip. Ponawiam…", + "errorContentNotInsertedTitle": "Dodanie zawartości bez powodzenia", + "errorContentToInsertIsEmpty": "Plik do dodania jest pusty lub nie ma do niego dostępu.", + "errorCopyingFailed": "Nie udało się skopiować", + "errorCouldNotConnectTitle": "Brak połączenia", + "errorCouldNotEditMessageTitle": "Nie można zmienić wiadomości", + "errorCouldNotFetchMessageSource": "Nie można uzyskać źródłowej wiadomości.", + "errorCouldNotOpenLink": "Nie można otworzyć: {url}", + "errorCouldNotOpenLinkTitle": "Nie udało się otworzyć odnośnika", + "errorCouldNotShowUserProfile": "Nie udało się wyświetlić profilu.", + "errorDialogContinue": "OK", + "errorDialogLearnMore": "Dowiedz się więcej", + "errorDialogTitle": "Błąd", + "errorFailedToUploadFileTitle": "Nie udało się załadować pliku: {filename}", + "errorFilesTooLarge": "{num, plural, =1{Plik jest} other{{num} Pliki są}} ponad limit serwera {maxFileUploadSizeMib} MiB i nie zostaną przyjęte:\n\n{listMessage}", + "errorFilesTooLargeTitle": "{num, plural, =1{Plik} other{Pliki}} ponad limit", + "errorFollowTopicFailed": "Śledzenie bez powodzenia", + "errorHandlingEventDetails": "Błąd zdarzenia Zulip z {serverUrl}; ponawiam.\n\nBłąd: {error}\n\nZdarzenie: {event}", + "errorHandlingEventTitle": "Błąd obsługi zdarzenia Zulip. Ponnawiam połączenie…", + "errorInvalidApiKeyMessage": "Konto w ramach {url} nie zostało przyjęte. Spróbuj ponownie lub skorzystaj z innego konta.", + "errorInvalidResponse": "Nieprawidłowa odpowiedź serwera.", + "errorLoginCouldNotConnect": "Nie udało się połączyć z serwerem:\n{url}", + "errorLoginFailedTitle": "Logowanie bez powodzenia", + "errorLoginInvalidInputTitle": "Błędny wsad", + "errorMalformedResponse": "Zdeforomowana odpowiedź serwera; status HTTP {httpStatus}", + "errorMalformedResponseWithCause": "Zdeformowana odpowiedź serwera; status HTTP {httpStatus}; {details}", + "errorMarkAsReadFailedTitle": "Oznaczanie jako przeczytane bez powodzenia", + "errorMarkAsUnreadFailedTitle": "Oznaczanie jako nieprzeczytane bez powodzenia", + "errorMessageDoesNotSeemToExist": "Taka wiadomość raczej nie istnieje.", + "errorMessageEditNotSaved": "Nie zapisano wiadomości", + "errorMessageNotSent": "Nie wysłano wiadomości", + "errorMuteTopicFailed": "Wyciszenie bez powodzenia", + "errorNetworkRequestFailed": "Dostęp do sieci bez powodzenia", + "errorNotificationOpenAccountNotFound": "Nie odnaleziono konta powiązanego z tym powiadomieniem.", + "errorNotificationOpenTitle": "Otwieranie powiadomienia bez powodzenia", + "errorQuotationFailed": "Cytowanie bez powodzenia", + "errorReactionAddingFailedTitle": "Dodanie reakcji bez powodzenia", + "errorReactionRemovingFailedTitle": "Usuwanie reakcji bez powodzenia", + "errorRequestFailed": "Błąd uzyskania sieci: status HTTP {httpStatus}", + "errorResolveTopicFailedTitle": "Nie udało się oznaczyć jako rozwiązany", + "errorServerMessage": "Odpowiedź serwera:\n\n{message}", + "errorServerVersionUnsupportedMessage": "{url} uruchamia Zulip Server {zulipVersion}, który nie jest obsługiwany. Minimalna obsługiwana wersja to Zulip Server {minSupportedZulipVersion}.", + "errorSharingAccountNotLoggedIn": "Brak zalogowanego użytkownika. Proszę zaloguj się i spróbuj ponownie.", + "errorSharingFailed": "Udostępnianie bez powodzenia", + "errorSharingTitle": "Udostępnianie zawartości bez powodzenia", + "errorStarMessageFailedTitle": "Dodanie gwiazdki bez powodzenia", + "errorUnfollowTopicFailed": "Nie śledź bez powodzenia", + "errorUnmuteTopicFailed": "Wznowienie bez powodzenia", + "errorUnresolveTopicFailedTitle": "Nie udało się oznaczyć brak rozwiązania", + "errorUnstarMessageFailedTitle": "Odebranie gwiazdki bez powodzenia", + "errorVideoPlayerFailed": "Nie da rady odtworzyć wideo.", + "errorWebAuthOperationalError": "Wystąpił niespodziewany błąd.", + "errorWebAuthOperationalErrorTitle": "Coś poszło nie tak", + "experimentalFeatureSettingsPageTitle": "Funkcje eksperymentalne", + "experimentalFeatureSettingsWarning": "W ten sposób aktywujesz funkcje, które są w fazie testów. Mogą one nie działać lub powodować problemy z tym co bez nich działa poprawnie.\n\nTo ustawienie przewidziane jest dla tych, którzy pracują nad ulepszeniem aplikacji Zulip.", + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "inboxEmptyPlaceholder": "Obecnie brak nowych wiadomości. Skorzystaj z przycisków u dołu ekranu aby przejść do widoku mieszanego lub listy kanałów.", + "inboxPageTitle": "Odebrane", + "initialAnchorSettingDescription": "Możesz wybrać czy bardziej odpowiada Ci odczyt nieprzeczytanych lub najnowszych wiadomości.", + "initialAnchorSettingFirstUnreadAlways": "Pierwsza nieprzeczytana wiadomość", + "initialAnchorSettingFirstUnreadConversations": "Pierwsza nieprzeczytana wiadomość w widoku dyskusji, wszędzie indziej najnowsza wiadomość", + "initialAnchorSettingNewestAlways": "Najnowsza wiadomość", + "initialAnchorSettingTitle": "Pokaż wiadomości w porządku", + "invisibleMode": "Tryb ukrycia", + "lightboxCopyLinkTooltip": "Skopiuj odnośnik", + "lightboxVideoCurrentPosition": "Obecna pozycja", + "lightboxVideoDuration": "Długość wideo", + "logOutConfirmationDialogConfirmButton": "Wyloguj", + "logOutConfirmationDialogMessage": "Aby użyć tego konta należy wskazać URL organizacji oraz dane konta.", + "logOutConfirmationDialogTitle": "Wylogować?", + "loginAddAnAccountPageTitle": "Dodaj konto", + "loginEmailLabel": "Adres email", + "loginErrorMissingEmail": "Proszę podaj swój email.", + "loginErrorMissingPassword": "Proszę wprowadź hasło.", + "loginErrorMissingUsername": "Proszę podaj nazwę użytkownika.", + "loginFormSubmitLabel": "Zaloguj", + "loginHidePassword": "Ukryj hasło", + "loginMethodDivider": "LUB", + "loginPageTitle": "Zaloguj", + "loginPasswordLabel": "Hasło", + "loginServerUrlLabel": "URL serwera Zulip", + "loginUsernameLabel": "Użytkownik", + "mainMenuMyProfile": "Mój profil", + "manyPeopleTyping": "Wielu ludzi coś pisze…", + "markAllAsReadLabel": "Oznacz wszystkie jako przeczytane", + "markAsReadComplete": "Oznaczono {num, plural, =1{1 wiadomość} other{{num} wiadomości}} jako przeczytane.", + "markAsReadInProgress": "Oznaczanie wiadomości jako przeczytane…", + "markAsUnreadComplete": "Oznaczono {num, plural, =1{1 wiadomość} other{{num} wiadomości}} jako nieprzeczytane.", + "markAsUnreadInProgress": "Oznaczanie jako nieprzeczytane…", + "markReadOnScrollSettingAlways": "Zawsze", + "markReadOnScrollSettingConversations": "Tylko w widoku dyskusji", + "markReadOnScrollSettingConversationsDescription": "Wiadomości zostaną z automatu oznaczone jako przeczytane tylko w pojedyczym wątku lub w wymianie wiadomości bezpośrednich.", + "markReadOnScrollSettingDescription": "Czy chcesz z automatu oznaczać wiadomości jako przeczytane przy przewijaniu?", + "markReadOnScrollSettingNever": "Nigdy", + "markReadOnScrollSettingTitle": "Oznacz wiadomości jako przeczytane przy przwijaniu", + "mentionsPageTitle": "Wzmianki", + "messageIsEditedLabel": "ZMIENIONO", + "messageIsMovedLabel": "PRZENIESIONO", + "messageListGroupYouAndOthers": "Ty i {others}", + "messageListGroupYouWithYourself": "Zapiski na własne konto", + "messageNotSentLabel": "NIE WYSŁANO WIADOMOŚCI", + "mutedUser": "Wyciszony użytkownik", + "newDmFabButtonLabel": "Nowa DM", + "newDmSheetComposeButtonLabel": "Utwórz", + "newDmSheetNoUsersFound": "Nie odnaleziono użytkowników", + "newDmSheetScreenTitle": "Nowa DM", + "newDmSheetSearchHintEmpty": "Dodaj jednego lub więcej użytkowników", + "newDmSheetSearchHintSomeSelected": "Dodaj kolejnego użytkownika…", + "noEarlierMessages": "Brak historii", + "noStatusText": "Brak tekstu stanu", + "notifGroupDmConversationLabel": "{senderFullName} do ciebie i {numOthers, plural, =1{1 innego} other{{numOthers} innych}}", + "notifSelfUser": "Ty", + "onePersonTyping": "{typist} coś pisze…", + "openLinksWithInAppBrowser": "Otwieraj odnośniki w aplikacji", + "permissionsDeniedCameraAccess": "Aby odebrać obraz Zulip musi uzyskać dodatkowe uprawnienia w Ustawieniach.", + "permissionsDeniedReadExternalStorage": "Aby odebrać pliki Zulip musi uzyskać dodatkowe uprawnienia w Ustawieniach.", + "permissionsNeededOpenSettings": "Otwórz ustawienia", + "permissionsNeededTitle": "Wymagane uprawnienia", + "pinnedSubscriptionsLabel": "Przypięte", + "pollVoterNames": "({voterNames})", + "pollWidgetOptionsMissing": "Ta sonda nie ma opcji do wyboru.", + "pollWidgetQuestionMissing": "Brak pytania.", + "preparingEditMessageContentInput": "Przygotowywanie…", + "profileButtonSendDirectMessage": "Wyślij wiadomość bezpośrednią", + "reactedEmojiSelfUser": "Ty", + "reactionChipLabel": "{emojiName}: {votes}", + "reactionChipVotesYouAndOthers": "{otherUsersCount, plural, =1{Ty i 1 inny} other{Ty i {otherUsersCount} innych}}", + "reactionChipsLabel": "Reakcje", + "recentDmConversationsEmptyPlaceholder": "Brak wiadomości w archiwum! Może warto rozpocząć dyskusję?", + "recentDmConversationsPageTitle": "Wiadomości bezpośrednie", + "recentDmConversationsSectionHeader": "Wiadomości bezpośrednie", + "revealButtonLabel": "Odsłoń wiadomość", + "savingMessageEditFailedLabel": "NIE ZAPISANO ZMIANY", + "savingMessageEditLabel": "ZAPIS ZMIANY…", + "scrollToBottomTooltip": "Przewiń do dołu", + "searchMessagesClearButtonTooltip": "Wyczyść", + "searchMessagesHintText": "Szukaj", + "searchMessagesPageTitle": "Szukaj", + "seeWhoReactedSheetEmojiNameWithVoteCount": "{emojiName}: {num, plural, =1{1 głos} other{{num} głosów}}", + "seeWhoReactedSheetHeaderLabel": "Reakcje emoji (łącznie {num})", + "seeWhoReactedSheetNoReactions": "Brak reakcji na tę wiadomość.", + "seeWhoReactedSheetUserListLabel": "Głosów {emojiName} ({num})", + "serverUrlValidationErrorEmpty": "Proszę podaj URL.", + "serverUrlValidationErrorInvalidUrl": "Proszę podaj poprawny URL.", + "serverUrlValidationErrorNoUseEmail": "Proszę podaj adres URL serwera a nie swój email.", + "serverUrlValidationErrorUnsupportedScheme": "Adres URL serwera musi zaczynać się od http:// or https://.", + "setStatusPageTitle": "Ustaw stan", + "settingsPageTitle": "Ustawienia", + "sharePageTitle": "Udostępnij", + "signInWithFoo": "Logowanie z {method}", + "snackBarDetails": "Szczegóły", + "spoilerDefaultHeaderText": "Spoiler", + "starredMessagesPageTitle": "Wiadomości z gwiazdką", + "statusButtonLabelStatusSet": "Stan", + "statusButtonLabelStatusUnset": "Ustaw stan", + "statusClearButtonLabel": "Wyczyść", + "statusSaveButtonLabel": "Zapisz", + "statusTextHint": "Twój stan", + "subscribeFailedTitle": "Subskrypcja bez powodzenia", + "successChannelLinkCopied": "Skopiowano odnośnik do kanału", + "successLinkCopied": "Skopiowano odnośnik", + "successMessageLinkCopied": "Skopiowano odnośnik wiadomości", + "successMessageTextCopied": "Skopiowano tekst wiadomości", + "successTopicLinkCopied": "Skopiowano odnośnik do wątku", + "switchAccountButton": "Przełącz konto", + "themeSettingDark": "Ciemny", + "themeSettingLight": "Jasny", + "themeSettingSystem": "Systemowy", + "themeSettingTitle": "WYSTRÓJ", + "today": "Dzisiaj", + "topicValidationErrorMandatoryButEmpty": "Wątki są wymagane przez tę organizację.", + "topicValidationErrorTooLong": "Tytuł nie może być dłuższy niż 60 znaków.", + "topicsButtonTooltip": "Wątki", + "tryAnotherAccountButton": "Użyj innego konta", + "tryAnotherAccountMessage": "Twoje konto na {url} wymaga jeszcze chwili na załadowanie.", + "turnOffInvisibleModeErrorTitle": "Problem z wyłączeniem trybu ukrycia. Spróbuj ponownie.", + "turnOnInvisibleModeErrorTitle": "Problem z włączeniem trybu ukrycia. Spróbuj ponownie.", + "twoPeopleTyping": "{typist} i {otherTypist} coś piszą…", + "unknownChannelName": "(nieznany kanał)", + "unknownUserName": "(nieznany użytkownik)", + "unpinnedSubscriptionsLabel": "Odpięte", + "unsubscribeConfirmationDialogConfirmButton": "Odsubskrybuj", + "unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": "Po opuszczeniu kanału możesz utracić możliwość powrotu.", + "unsubscribeConfirmationDialogTitle": "Odsubskrybować z {channelName}?", + "unsubscribeFailedTitle": "Odsubskrybowanie bez powdzenia", + "updateStatusErrorTitle": "Błąd aktualizacji stanu. Spróbuj ponownie.", + "upgradeWelcomeDialogDismiss": "Zaczynajmy", + "upgradeWelcomeDialogLinkText": "Sprawdź blog pod kątem obwieszczenia!", + "upgradeWelcomeDialogMessage": "Napotkasz na znane rozwiązania, które upakowaliśmy w szybszy i elegancki pakiet.", + "upgradeWelcomeDialogTitle": "Witaj w nowej apce Zulip!", + "userActiveDate": "Aktywny {date}", + "userActiveDaysAgo": "Aktywny {days, plural, =1{1 dzień} other{{days} dni}} temu", + "userActiveHoursAgo": "Aktywny {hours, plural, =1{1 godzinę} other{{hours} godzin}} temu", + "userActiveMinutesAgo": "Aktywny {minutes, plural, =1{1 minutę} other{{minutes} minut}} temu", + "userActiveNow": "Dostępny", + "userActiveYesterday": "Aktywny wczoraj", + "userIdle": "Bezczynny", + "userNotActiveInYear": "Brak aktywności za ostatni rok", + "userRoleAdministrator": "Administrator", + "userRoleGuest": "Gość", + "userRoleMember": "Członek", + "userRoleModerator": "Moderator", + "userRoleOwner": "Właściciel", + "userRoleUnknown": "Nieznany", + "userStatusAtTheOffice": "W biurze", + "userStatusBusy": "Zajęty", + "userStatusCommuting": "W drodze", + "userStatusInAMeeting": "Na spotkaniu", + "userStatusOutSick": "Chorobowe", + "userStatusVacationing": "Na urlopie", + "userStatusWorkingRemotely": "Praca zdalna", + "wildcardMentionAll": "wszyscy", + "wildcardMentionAllDmDescription": "Powiadom zainteresowanych", + "wildcardMentionChannel": "kanał", + "wildcardMentionChannelDescription": "Powiadom w kanale", + "wildcardMentionEveryone": "każdy", + "wildcardMentionStream": "strumień", + "wildcardMentionStreamDescription": "Powiadom w strumieniu", + "wildcardMentionTopic": "wątek", + "wildcardMentionTopicDescription": "Powiadom w wątku", + "yesterday": "Wczoraj", + "zulipAppTitle": "Zulip" } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index fc7e7c964d..e4c0d20b7d 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -1,780 +1,1518 @@ { - "aboutPageTitle": "О Zulip", - "@aboutPageTitle": { - "description": "Title for About Zulip page." - }, - "aboutPageAppVersion": "Версия приложения", "@aboutPageAppVersion": { "description": "Label for Zulip app version in About Zulip page" }, - "aboutPageOpenSourceLicenses": "Лицензии открытого исходного кода", "@aboutPageOpenSourceLicenses": { "description": "Item title in About Zulip page to navigate to Licenses page" }, - "aboutPageTapToView": "Нажмите для просмотра", "@aboutPageTapToView": { "description": "Item subtitle in About Zulip page to navigate to Licenses page" }, - "chooseAccountPageTitle": "Выберите учетную запись", - "@chooseAccountPageTitle": { - "description": "Title for the page to choose between Zulip accounts." + "@aboutPageTitle": { + "description": "Title for About Zulip page." }, - "chooseAccountPageLogOutButton": "Выход из системы", - "@chooseAccountPageLogOutButton": { - "description": "Label for the 'Log out' button for an account on the choose-account page" + "@actionSheetOptionChannelFeed": { + "description": "Label for navigating to a channel's channel-feed page." }, - "logOutConfirmationDialogTitle": "Выйти из системы?", - "@logOutConfirmationDialogTitle": { - "description": "Title for a confirmation dialog for logging out." + "@actionSheetOptionCopyChannelLink": { + "description": "Label for copy channel link button on action sheet." }, - "logOutConfirmationDialogMessage": "Чтобы использовать эту учетную запись в будущем, вам придется заново ввести URL-адрес вашей организации и информацию о вашей учетной записи.", - "@logOutConfirmationDialogMessage": { - "description": "Message for a confirmation dialog for logging out." + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." }, - "logOutConfirmationDialogConfirmButton": "Выйти", - "@logOutConfirmationDialogConfirmButton": { - "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." }, - "chooseAccountButtonAddAnAccount": "Добавить учетную запись", - "@chooseAccountButtonAddAnAccount": { - "description": "Label for ChooseAccountPage button to add an account" + "@actionSheetOptionCopyTopicLink": { + "description": "Label for copy topic link button in action sheet." }, - "profileButtonSendDirectMessage": "Отправить личное сообщение", - "@profileButtonSendDirectMessage": { - "description": "Label for button in profile screen to navigate to DMs with the shown user." + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." }, - "permissionsNeededTitle": "Требуются разрешения", - "@permissionsNeededTitle": { - "description": "Title for dialog asking the user to grant additional permissions." + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." }, - "permissionsNeededOpenSettings": "Открыть настройки", - "@permissionsNeededOpenSettings": { - "description": "Button label for permissions dialog button that opens the system settings screen." + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." }, - "permissionsDeniedCameraAccess": "Для загрузки изображения, пожалуйста, предоставьте Zulip дополнительные разрешения в настройках.", - "@permissionsDeniedCameraAccess": { - "description": "Message for dialog asking the user to grant permissions for camera access." + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." }, - "permissionsDeniedReadExternalStorage": "Для загрузки файлов, пожалуйста, предоставьте Zulip дополнительные разрешения в настройках.", - "@permissionsDeniedReadExternalStorage": { - "description": "Message for dialog asking the user to grant permissions for external storage read access." + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." }, - "actionSheetOptionCopyMessageText": "Скопировать текст сообщения", - "@actionSheetOptionCopyMessageText": { - "description": "Label for copy message text button on action sheet." + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." }, - "actionSheetOptionCopyMessageLink": "Скопировать ссылку на сообщение", - "@actionSheetOptionCopyMessageLink": { - "description": "Label for copy message link button on action sheet." + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." }, - "actionSheetOptionMarkAsUnread": "Отметить как непрочитанные начиная отсюда", - "@actionSheetOptionMarkAsUnread": { - "description": "Label for mark as unread button on action sheet." + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "@actionSheetOptionSeeWhoReacted": { + "description": "Label for the 'See who reacted' button in the message action sheet." }, - "actionSheetOptionShare": "Поделиться", "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionQuoteAndReply": "Ответить с цитированием", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." - }, - "actionSheetOptionStarMessage": "Отметить сообщение", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." }, - "actionSheetOptionUnstarMessage": "Снять отметку с сообщения", + "@actionSheetOptionSubscribe": { + "description": "Label in the channel action sheet for subscribing to the channel." + }, + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, "@actionSheetOptionUnstarMessage": { "description": "Label for unstar button on action sheet." }, - "errorWebAuthOperationalErrorTitle": "Что-то пошло не так", - "@errorWebAuthOperationalErrorTitle": { - "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + "@actionSheetOptionUnsubscribe": { + "description": "Label in the channel action sheet for unsubscribing from the channel." }, - "errorWebAuthOperationalError": "Произошла непредвиденная ошибка.", - "@errorWebAuthOperationalError": { - "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + "@actionSheetOptionViewReadReceipts": { + "description": "Label for the 'View read receipts' button in the message action sheet." }, - "errorAccountLoggedInTitle": "Вход в учетную запись уже выполнен", - "@errorAccountLoggedInTitle": { - "description": "Error title on attempting to log into an account that's already logged in." + "@actionSheetReadReceipts": { + "description": "Title for the \"Read receipts\" bottom sheet." }, - "errorAccountLoggedIn": "Учетная запись {email} на {server} уже присутствует.", - "@errorAccountLoggedIn": { - "description": "Error message on attempting to log into an account that's already logged in.", + "@actionSheetReadReceiptsErrorReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when loading read receipts failed." + }, + "@actionSheetReadReceiptsReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when one or more people have read the message.", "placeholders": { - "email": { - "type": "String", - "example": "user@example.com" - }, - "server": { - "type": "String", - "example": "https://example.com" + "count": { + "example": "1", + "type": "int" } } }, - "errorCouldNotFetchMessageSource": "Не удалось извлечь источник сообщения", - "@errorCouldNotFetchMessageSource": { - "description": "Error message when the source of a message could not be fetched." + "@actionSheetReadReceiptsZeroReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when no one has read the message." }, - "errorCopyingFailed": "Сбой копирования", - "@errorCopyingFailed": { - "description": "Error message when copying the text of a message to the user's system clipboard failed." + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." }, - "errorFailedToUploadFileTitle": "Не удалось загрузить файл: {filename}", - "@errorFailedToUploadFileTitle": { - "description": "Error title when the specified file failed to upload.", - "placeholders": { - "filename": { - "type": "String", - "example": "file.txt" - } - } + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" }, - "errorStarMessageFailedTitle": "Не удалось отметить сообщение", - "@errorStarMessageFailedTitle": { - "description": "Error title when starring a message failed." + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." }, - "errorUnstarMessageFailedTitle": "Не удалось снять отметку с сообщения", - "@errorUnstarMessageFailedTitle": { - "description": "Error title when unstarring a message failed." + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." }, - "serverUrlValidationErrorUnsupportedScheme": "URL-адрес сервера должен начинаться с http:// или https://.", - "@serverUrlValidationErrorUnsupportedScheme": { - "description": "Error message when URL has an unsupported scheme." + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" }, - "starredMessagesPageTitle": "Отмеченные сообщения", - "@starredMessagesPageTitle": { - "description": "Page title for the 'Starred messages' message view." + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" }, - "errorLoginInvalidInputTitle": "Неверный ввод", - "@errorLoginInvalidInputTitle": { - "description": "Error title for login when input is invalid." + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." }, - "tryAnotherAccountButton": "Попробовать другую учетную запись", - "@tryAnotherAccountButton": { - "description": "Label for loading screen button prompting user to try another account." + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." }, - "errorQuotationFailed": "Цитирование не удалось", - "@errorQuotationFailed": { - "description": "Error message when quoting a message failed." + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." }, - "errorFilesTooLargeTitle": "Слишком большой размер {num, plural, =1{файла} other{файлов}}", - "@errorFilesTooLargeTitle": { - "description": "Error title when attached files are too large in size.", + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", "placeholders": { - "num": { - "type": "int", - "example": "4" + "destination": { + "example": "#channel name > topic name", + "type": "String" } } }, - "composeBoxSelfDmContentHint": "Сделать заметку", - "@composeBoxSelfDmContentHint": { - "description": "Hint text for content input when sending a message to yourself." + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "example": "channel name", + "type": "String" + } + } + }, + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "example": "general chat", + "type": "String" + } + } + }, + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." }, - "composeBoxGroupDmContentHint": "Сообщение для группы", "@composeBoxGroupDmContentHint": { "description": "Hint text for content input when sending a message to a group." }, - "contentValidationErrorEmpty": "Нечего отправлять!", - "@contentValidationErrorEmpty": { - "description": "Content validation error message when the message is empty." + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "example": "1234", + "type": "int" + } + } }, - "loginPageTitle": "Вход в систему", - "@loginPageTitle": { - "description": "Title for login page." + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." }, - "loginFormSubmitLabel": "Войти", - "@loginFormSubmitLabel": { - "description": "Button text to submit login credentials." + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." }, - "loginMethodDivider": "ИЛИ", - "@loginMethodDivider": { - "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." }, - "signInWithFoo": "Войти с помощью {method}", - "@signInWithFoo": { - "description": "Button to use {method} to sign in to the app.", + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", "placeholders": { - "method": { - "type": "String", - "example": "Google" + "filename": { + "example": "file.txt", + "type": "String" } } }, - "loginAddAnAccountPageTitle": "Добавление учетной записи", - "@loginAddAnAccountPageTitle": { - "description": "Title for page to add a Zulip account." + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." }, - "loginServerUrlInputLabel": "URL вашего сервера Zulip", - "@loginServerUrlInputLabel": { - "description": "Input label in login page for Zulip server URL entry." + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." }, - "loginHidePassword": "Скрыть пароль", - "@loginHidePassword": { - "description": "Icon label for button to hide password in input form." + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." }, - "loginEmailLabel": "Адрес почты", - "@loginEmailLabel": { - "description": "Label for input when an email is required to log in." + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." }, - "loginErrorMissingEmail": "Пожалуйста, введите ваш адрес электронной почты.", - "@loginErrorMissingEmail": { - "description": "Error message when an empty email was provided." + "@dialogCancel": { + "description": "Button label in dialogs to cancel." }, - "topicValidationErrorMandatoryButEmpty": "Темы обязательны в этой организации.", - "@topicValidationErrorMandatoryButEmpty": { - "description": "Topic validation error when topic is required but was empty." + "@dialogClose": { + "description": "Button label in dialogs to close." }, - "errorNetworkRequestFailed": "Сбой сетевого запроса", - "@errorNetworkRequestFailed": { - "description": "Error message when a network request fails." + "@dialogContinue": { + "description": "Button label in dialogs to proceed." }, - "errorMalformedResponseWithCause": "Сервер вернул некорректный ответ; HTTP-статус {httpStatus}; {details}", - "@errorMalformedResponseWithCause": { - "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", - "placeholders": { - "httpStatus": { - "type": "int", - "example": "200" - }, - "details": { - "type": "String", - "example": "type 'Null' is not a subtype of type 'String' in type cast" - } - } + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." }, - "spoilerDefaultHeaderText": "Спойлер", - "@spoilerDefaultHeaderText": { - "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." }, - "markAllAsReadLabel": "Отметить все сообщения как прочитанные", - "@markAllAsReadLabel": { - "description": "Button text to mark messages as read." + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." }, - "markAsReadComplete": "Отметка прочтения установлена для {num, plural, =1{1 сообщения} other{{num} шт. сообщений}}.", - "@markAsReadComplete": { - "description": "Message when marking messages as read has completed.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", "placeholders": { - "num": { - "type": "int", - "example": "4" + "others": { + "example": "Alice, Bob", + "type": "String" } } }, - "markAsReadInProgress": "Помечаем сообщения как прочитанные…", - "@markAsReadInProgress": { - "description": "Progress message when marking messages as read." + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." }, - "errorMarkAsReadFailedTitle": "Не удалось установить отметку прочтения", - "@errorMarkAsReadFailedTitle": { - "description": "Error title when mark as read action failed." + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." }, - "userRoleOwner": "Владелец", - "@userRoleOwner": { - "description": "Label for UserRole.owner" - }, - "userRoleAdministrator": "Администратор", - "@userRoleAdministrator": { - "description": "Label for UserRole.administrator" - }, - "userRoleModerator": "Модератор", - "@userRoleModerator": { - "description": "Label for UserRole.moderator" - }, - "errorNotificationOpenTitle": "Не удалось открыть оповещения", - "@errorNotificationOpenTitle": { - "description": "Error title when notification opening fails" - }, - "errorNotificationOpenAccountMissing": "Учетной записи, связанной с этим оповещением, больше нет.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." }, - "switchAccountButton": "Сменить учетную запись", - "@switchAccountButton": { - "description": "Label for main-menu button leading to the choose-account page." - }, - "tryAnotherAccountMessage": "Ваша учетная запись на {url} загружается медленно.", - "@tryAnotherAccountMessage": { - "description": "Message that appears on the loading screen after waiting for some time.", - "url": { - "type": "String", - "example": "http://chat.example.com/" - } + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." }, - "actionSheetOptionMuteTopic": "Отключить тему", - "@actionSheetOptionMuteTopic": { - "description": "Label for muting a topic on action sheet." + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." }, - "actionSheetOptionUnmuteTopic": "Включить тему", - "@actionSheetOptionUnmuteTopic": { - "description": "Label for unmuting a topic on action sheet." + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." }, - "errorLoginFailedTitle": "Не удалось войти в систему", - "@errorLoginFailedTitle": { - "description": "Error title for login when signing into a Zulip server fails." + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." }, - "errorLoginCouldNotConnect": "Не удалось подключиться к серверу:\n{url}", - "@errorLoginCouldNotConnect": { - "description": "Error message when the app could not connect to the server.", + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", "placeholders": { - "url": { - "type": "String", - "example": "http://example.com/" + "email": { + "example": "user@example.com", + "type": "String" + }, + "server": { + "example": "https://example.com", + "type": "String" } } }, - "errorLoginCouldNotConnectTitle": "Не удалось подключиться", - "@errorLoginCouldNotConnectTitle": { - "description": "Error title when the app could not connect to the server." - }, - "errorMessageDoesNotSeemToExist": "Это сообщение, похоже, отсутствует.", - "@errorMessageDoesNotSeemToExist": { - "description": "Error message when loading a message that does not exist." + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." }, - "errorServerMessage": "Ответ сервера:\n\n{message}", - "@errorServerMessage": { - "description": "Error message that quotes an error from the server.", - "placeholders": { - "message": { - "type": "String", - "example": "Invalid format" - } - } + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." }, - "errorConnectingToServerShort": "Ошибка подключения к Zulip. Повторяем попытку…", - "@errorConnectingToServerShort": { - "description": "Short error message for a generic unknown error connecting to the server." + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." }, - "errorConnectingToServerDetails": "Ошибка подключения к Zulip на {serverUrl}. Повторим попытку:\n\n{error}", "@errorConnectingToServerDetails": { "description": "Dialog error message for a generic unknown error connecting to the server with details.", "placeholders": { - "serverUrl": { - "type": "String", - "example": "http://example.com/" - }, "error": { - "type": "String", - "example": "Invalid format" - } - } - }, - "errorMuteTopicFailed": "Не удалось отключить тему", - "@errorMuteTopicFailed": { - "description": "Error message when muting a topic failed." - }, - "errorUnfollowTopicFailed": "Не удалось прекратить отслеживать тему", - "@errorUnfollowTopicFailed": { - "description": "Error message when unfollowing a topic failed." - }, - "composeBoxGenericContentHint": "Ввести сообщение", - "@composeBoxGenericContentHint": { - "description": "Hint text for content input when sending a message." - }, - "composeBoxChannelContentHint": "Сообщение для #{channel} > {topic}", - "@composeBoxChannelContentHint": { - "description": "Hint text for content input when sending a message to a channel", - "placeholders": { - "channel": { - "type": "String", - "example": "channel name" + "example": "Invalid format", + "type": "String" }, - "topic": { - "type": "String", - "example": "topic name" + "serverUrl": { + "example": "http://example.com/", + "type": "String" } } }, - "composeBoxSendTooltip": "Отправить", - "@composeBoxSendTooltip": { - "description": "Tooltip for send button in compose box." - }, - "composeBoxTopicHintText": "Тема", - "@composeBoxTopicHintText": { - "description": "Hint text for topic input widget in compose box." - }, - "messageListGroupYouWithYourself": "Вы с собой", - "@messageListGroupYouWithYourself": { - "description": "Message list recipient header for a DM group that only includes yourself." - }, - "contentValidationErrorTooLong": "Длина сообщения не должна превышать 10000 символов.", - "@contentValidationErrorTooLong": { - "description": "Content validation error message when the message is too long." + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." }, - "errorDialogTitle": "Ошибка", - "@errorDialogTitle": { - "description": "Generic title for error dialog." + "@errorContentNotInsertedTitle": { + "description": "Title for error dialog when an attempt to insert rich content failed." }, - "serverUrlValidationErrorNoUseEmail": "Пожалуйста, введите URL-адрес сервера, а не свой email.", - "@serverUrlValidationErrorNoUseEmail": { - "description": "Error message when URL looks like an email" + "@errorContentToInsertIsEmpty": { + "description": "Error message when the rich content to be inserted is empty or cannot be accessed." }, - "errorVideoPlayerFailed": "Не удается воспроизвести видео", - "@errorVideoPlayerFailed": { - "description": "Error message when a video fails to play." + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." }, - "inboxPageTitle": "Входящие", - "@inboxPageTitle": { - "description": "Title for the page with unreads." + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." }, - "dialogClose": "Закрыть", - "@dialogClose": { - "description": "Button label in dialogs to close." + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." }, - "snackBarDetails": "Подробности", - "@snackBarDetails": { - "description": "Button label for snack bar button that opens a dialog with more details." + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." }, - "errorMalformedResponse": "Сервер вернул некорректный ответ; HTTP-статус {httpStatus}", - "@errorMalformedResponse": { - "description": "Error message when an API call fails because we could not parse the response.", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", "placeholders": { - "httpStatus": { - "type": "int", - "example": "200" + "url": { + "example": "https://chat.example.com", + "type": "String" } } }, - "today": "Сегодня", - "@today": { - "description": "Term to use to reference the current day." + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." }, - "userRoleUnknown": "Неизвестно", - "@userRoleUnknown": { - "description": "Label for UserRole.unknown" + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." }, - "combinedFeedPageTitle": "Объединенная лента", - "@combinedFeedPageTitle": { - "description": "Page title for the 'Combined feed' message view." + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." }, - "twoPeopleTyping": "{typist} и {otherTypist} набирают сообщения…", - "@twoPeopleTyping": { - "description": "Text to display when there are two users typing.", + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", "placeholders": { - "typist": { - "type": "String", - "example": "Alice" - }, - "otherTypist": { - "type": "String", - "example": "Bob" + "filename": { + "example": "file.txt", + "type": "String" } } }, - "manyPeopleTyping": "Несколько человек набирают сообщения…", - "@manyPeopleTyping": { - "description": "Text to display when there are multiple users typing." - }, - "messageIsEditedLabel": "ИЗМЕНЕНО", - "@messageIsEditedLabel": { - "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" - }, - "errorReactionAddingFailedTitle": "Не удалось добавить реакцию", - "@errorReactionAddingFailedTitle": { - "description": "Error title when adding a message reaction fails" - }, - "errorReactionRemovingFailedTitle": "Не удалось удалить реакцию", - "@errorReactionRemovingFailedTitle": { - "description": "Error title when removing a message reaction fails" - }, - "emojiReactionsMore": "еще", - "@emojiReactionsMore": { - "description": "Label for a button opening the emoji picker." - }, - "emojiPickerSearchEmoji": "Поиск эмодзи", - "@emojiPickerSearchEmoji": { - "description": "Hint text for the emoji picker search text field." - }, - "errorMarkAsUnreadFailedTitle": "Не удалось снять отметку прочтения", - "@errorMarkAsUnreadFailedTitle": { - "description": "Error title when mark as unread action failed." - }, - "actionSheetOptionFollowTopic": "Отслеживать тему", - "@actionSheetOptionFollowTopic": { - "description": "Label for following a topic on action sheet." - }, - "actionSheetOptionUnfollowTopic": "Не отслеживать тему", - "@actionSheetOptionUnfollowTopic": { - "description": "Label for unfollowing a topic on action sheet." - }, - "errorFilesTooLarge": "Размер {num, plural, =1{файла} other{{num} файлов}} превышает предел для сервера {maxFileUploadSizeMib} МиБ, загрузка невозможна:\n\n{listMessage}", "@errorFilesTooLarge": { "description": "Error message when attached files are too large in size.", "placeholders": { - "num": { - "type": "int", - "example": "2" + "listMessage": { + "example": "foo.txt\nbar.txt", + "type": "String" }, "maxFileUploadSizeMib": { - "type": "int", - "example": "15" + "example": "15", + "type": "int" }, - "listMessage": { - "type": "String", - "example": "foo.txt\nbar.txt" + "num": { + "example": "2", + "type": "int" } } }, - "errorHandlingEventDetails": "Ошибка обработки события Zulip от {serverUrl}; повторим попытку.\n\nОшибка: {error}\n\nСобытие: {event}", + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, "@errorHandlingEventDetails": { "description": "Error details on failing to handle a Zulip server event.", "placeholders": { - "serverUrl": { - "type": "String", - "example": "https://chat.example.com" - }, "error": { - "type": "String", - "example": "Unexpected null value" + "example": "Unexpected null value", + "type": "String" }, "event": { - "type": "String", - "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')" + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')", + "type": "String" + }, + "serverUrl": { + "example": "https://chat.example.com", + "type": "String" } } }, - "errorHandlingEventTitle": "Ошибка обработки события Zulip. Повторная попытка соединения…", "@errorHandlingEventTitle": { "description": "Error title on failing to handle a Zulip server event." }, - "successMessageTextCopied": "Текст сообщения скопирован", - "@successMessageTextCopied": { - "description": "Message when content of a message was copied to the user's system clipboard." + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + } }, - "errorInvalidResponse": "Получен недопустимый ответ сервера", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." }, - "successMessageLinkCopied": "Ссылка на сообщение скопирована", - "@successMessageLinkCopied": { - "description": "Message when link of a message was copied to the user's system clipboard." + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "example": "http://example.com/", + "type": "String" + } + } }, - "contentValidationErrorQuoteAndReplyInProgress": "Пожалуйста, дождитесь завершения цитирования.", - "@contentValidationErrorQuoteAndReplyInProgress": { - "description": "Content validation error message when a quotation has not completed yet." + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." }, - "unknownUserName": "(неизвестный пользователь)", - "@unknownUserName": { - "description": "Name placeholder to use for a user when we don't know their name." + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." }, - "errorDialogContinue": "OK", - "@errorDialogContinue": { - "description": "Button label in error dialogs to acknowledge the error and close the dialog." + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "example": "200", + "type": "int" + } + } }, - "errorUnmuteTopicFailed": "Не удалось включить тему", - "@errorUnmuteTopicFailed": { - "description": "Error message when unmuting a topic failed." + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "details": { + "example": "type 'Null' is not a subtype of type 'String' in type cast", + "type": "String" + }, + "httpStatus": { + "example": "200", + "type": "int" + } + } }, - "errorFollowTopicFailed": "Не удалось начать отслеживать тему", - "@errorFollowTopicFailed": { - "description": "Error message when following a topic failed." + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." }, - "composeBoxUnknownChannelName": "(неизвестный канал)", - "@composeBoxUnknownChannelName": { - "description": "Replacement name for channel when it cannot be found in the store." + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." }, - "dialogContinue": "Продолжить", - "@dialogContinue": { - "description": "Button label in dialogs to proceed." + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." }, - "composeBoxUploadingFilename": "Загрузка {filename}…", - "@composeBoxUploadingFilename": { - "description": "Placeholder in compose box showing the specified file is currently uploading.", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "@errorRequestFailed": { + "description": "Error message when an API call fails.", "placeholders": { - "filename": { - "type": "String", - "example": "file.txt" + "httpStatus": { + "example": "500", + "type": "int" } } }, - "messageListGroupYouAndOthers": "Вы и {others}", - "@messageListGroupYouAndOthers": { - "description": "Message list recipient header for a DM group with others.", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", "placeholders": { - "others": { - "type": "String", - "example": "Alice, Bob" + "message": { + "example": "Invalid format", + "type": "String" } } }, - "markAsUnreadComplete": "Отметка прочтения снята для {num, plural, =1{1 сообщения} other{{num} шт. сообщений}}.", - "@markAsUnreadComplete": { - "description": "Message when marking messages as unread has completed.", + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", "placeholders": { - "num": { - "type": "int", - "example": "4" + "minSupportedZulipVersion": { + "example": "4.0", + "type": "String" + }, + "url": { + "example": "http://chat.example.com/", + "type": "String" + }, + "zulipVersion": { + "example": "3.2", + "type": "String" } } }, - "markAsUnreadInProgress": "Помечаем сообщения как непрочитанные…", - "@markAsUnreadInProgress": { - "description": "Progress message when marking messages as unread." + "@errorSharingAccountNotLoggedIn": { + "description": "Error title when sharing content received from other apps fails, when there is no account logged in" }, - "channelsPageTitle": "Каналы", - "@channelsPageTitle": { - "description": "Title for the page with a list of subscribed channels." + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." }, - "pollWidgetQuestionMissing": "Нет вопроса.", - "@pollWidgetQuestionMissing": { - "description": "Text to display for a poll when the question is missing" + "@errorSharingTitle": { + "description": "Error title when sharing content received from other apps fails" }, - "userRoleGuest": "Гость", - "@userRoleGuest": { - "description": "Label for UserRole.guest" + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." }, - "channelFeedButtonTooltip": "Лента канала", - "@channelFeedButtonTooltip": { - "description": "Tooltip for button to navigate to a given channel's feed" + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." }, - "messageIsMovedLabel": "ПЕРЕМЕЩЕНО", - "@messageIsMovedLabel": { - "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." }, - "onePersonTyping": "{typist} набирает сообщение…", - "@onePersonTyping": { - "description": "Text to display when there is one user typing.", - "placeholders": { - "typist": { - "type": "String", - "example": "Alice" - } - } + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." }, - "contentValidationErrorUploadInProgress": "Пожалуйста, дождитесь завершения загрузки.", - "@contentValidationErrorUploadInProgress": { - "description": "Content validation error message when attachments have not finished uploading." + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." }, - "dialogCancel": "Отмена", - "@dialogCancel": { - "description": "Button label in dialogs to cancel." + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." }, - "lightboxCopyLinkTooltip": "Скопировать ссылку", - "@lightboxCopyLinkTooltip": { - "description": "Tooltip in lightbox for the copy link action." + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." }, - "recentDmConversationsPageTitle": "Личные сообщения", - "@recentDmConversationsPageTitle": { - "description": "Title for the page with a list of DM conversations." + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." }, - "mainMenuMyProfile": "Мой профиль", - "@mainMenuMyProfile": { - "description": "Label for main-menu button leading to the user's own profile." + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" }, - "errorRequestFailed": "Сбой сетевого запроса: HTTP-статус {httpStatus}", - "@errorRequestFailed": { - "description": "Error message when an API call fails.", + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", "placeholders": { - "httpStatus": { - "type": "int", - "example": "500" + "filename": { + "example": "foo.txt", + "type": "String" + }, + "size": { + "example": "20.2", + "type": "String" } } }, - "userRoleMember": "Участник", - "@userRoleMember": { - "description": "Label for UserRole.member" + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." }, - "yesterday": "Вчера", - "@yesterday": { - "description": "Term to use to reference the previous day." + "@inboxPageTitle": { + "description": "Title for the page with unreads." }, - "errorSharingFailed": "Не удалось поделиться", - "@errorSharingFailed": { - "description": "Error message when sharing a message failed." + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." }, - "successLinkCopied": "Ссылка скопирована", - "@successLinkCopied": { - "description": "Success message after copy link action completed." + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." }, - "composeBoxAttachFilesTooltip": "Прикрепить файлы", - "@composeBoxAttachFilesTooltip": { - "description": "Tooltip for compose box icon to attach a file to the message." + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." }, - "errorBannerDeactivatedDmLabel": "Нельзя отправить сообщение отключенным пользователям.", - "@errorBannerDeactivatedDmLabel": { - "description": "Label text for error banner when sending a message to one or multiple deactivated users." + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." }, - "errorBannerCannotPostInChannelLabel": "У вас нет права писать в этом канале.", - "@errorBannerCannotPostInChannelLabel": { - "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." }, - "composeBoxAttachFromCameraTooltip": "Сделать снимок", - "@composeBoxAttachFromCameraTooltip": { - "description": "Tooltip for compose box icon to attach an image from the camera to the message." + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." }, - "composeBoxAttachMediaTooltip": "Прикрепить изображения или видео", - "@composeBoxAttachMediaTooltip": { - "description": "Tooltip for compose box icon to attach media to the message." + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." }, - "composeBoxDmContentHint": "Сообщение для @{user}", - "@composeBoxDmContentHint": { - "description": "Hint text for content input when sending a message to one other person.", - "placeholders": { - "user": { - "type": "String", - "example": "channel name" - } - } + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." }, - "loginErrorMissingUsername": "Пожалуйста, введите ваше имя пользователя.", "@loginErrorMissingUsername": { "description": "Error message when an empty username was provided." }, - "loginPasswordLabel": "Пароль", + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "@loginPageTitle": { + "description": "Title for login page." + }, "@loginPasswordLabel": { "description": "Label for password input field." }, - "loginErrorMissingPassword": "Пожалуйста, введите пароль.", - "@loginErrorMissingPassword": { - "description": "Error message when an empty password was provided." + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." }, - "loginUsernameLabel": "Имя пользователя", "@loginUsernameLabel": { "description": "Label for input when a username is required to log in." }, - "topicValidationErrorTooLong": "Длина темы не должна превышать 60 символов.", - "@topicValidationErrorTooLong": { - "description": "Topic validation error when topic is too long." + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." }, - "serverUrlValidationErrorEmpty": "Пожалуйста, введите URL-адрес.", - "@serverUrlValidationErrorEmpty": { - "description": "Error message when URL is empty" + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." }, - "serverUrlValidationErrorInvalidUrl": "Пожалуйста, введите корректный URL-адрес.", - "@serverUrlValidationErrorInvalidUrl": { - "description": "Error message when URL is not in a valid format." + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." }, - "mentionsPageTitle": "Упоминания", - "@mentionsPageTitle": { - "description": "Page title for the 'Mentions' message view." + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } }, - "notifGroupDmConversationLabel": "{senderFullName} вам и еще {numOthers, plural, =1{1 чел.} other{{numOthers} чел.}}", - "@notifGroupDmConversationLabel": { - "description": "Label for a group DM conversation notification.", + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", "placeholders": { - "senderFullName": { - "type": "String", - "example": "Alice" - }, - "numOthers": { - "type": "int", - "example": "4" + "num": { + "example": "4", + "type": "int" } } }, - "notifSelfUser": "Вы", - "@notifSelfUser": { - "description": "Display name for the user themself, to show after replying in an Android notification" + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." }, - "pollWidgetOptionsMissing": "В опросе пока нет вариантов ответа.", - "@pollWidgetOptionsMissing": { - "description": "Text to display for a poll when it has no options" + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." }, - "errorMessageNotSent": "Сообщение не отправлено", - "@errorMessageNotSent": { - "description": "Error message for compose box when a message could not be sent." - } + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected" + }, + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "@noStatusText": { + "description": "The text part of the status button sub-label in self-user profile page when status text is not set." + }, + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "numOthers": { + "example": "4", + "type": "int" + }, + "senderFullName": { + "example": "Alice", + "type": "String" + } + } + }, + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "@pollVoterNames": { + "description": "The list of people who voted for a poll option, wrapped in parentheses.", + "placeholders": { + "voterNames": { + "example": "Alice, Bob, Chad", + "type": "String" + } + } + }, + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "@reactionChipLabel": { + "description": "Text describing a reaction chip, with the emoji name and a list or number of votes. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "votes": { + "example": "You, Chris, Greg", + "type": "String" + } + } + }, + "@reactionChipVotesYouAndOthers": { + "description": "The number of votes on a reaction chip, where the self-user and at least one other user has voted. (An accessibility label for assistive technology.)", + "placeholders": { + "otherUsersCount": { + "example": "4", + "type": "int" + } + } + }, + "@reactionChipsLabel": { + "description": "Text identifying the container of reaction chips on a message. (An accessibility label for assistive technology.)" + }, + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." + }, + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "@seeWhoReactedSheetEmojiNameWithVoteCount": { + "description": "In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetHeaderLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.)", + "placeholders": { + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetNoReactions": { + "description": "Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened)." + }, + "@seeWhoReactedSheetUserListLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "@setStatusPageTitle": { + "description": "Title for the 'Set status' page." + }, + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "@sharePageTitle": { + "description": "Title for the page about sharing content received from other apps." + }, + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "example": "Google", + "type": "String" + } + } + }, + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "@statusButtonLabelStatusSet": { + "description": "The status button label in self-user profile page when status is set." + }, + "@statusButtonLabelStatusUnset": { + "description": "The status button label in self-user profile page when status is not set." + }, + "@statusClearButtonLabel": { + "description": "Label for the button that clears the user status, in 'Set status' page." + }, + "@statusSaveButtonLabel": { + "description": "Label for the button that saves the user status, in 'Set status' page." + }, + "@statusTextHint": { + "description": "Hint text for the status text input field in 'Set status' page." + }, + "@subscribeFailedTitle": { + "description": "Error title when subscribing to a channel failed." + }, + "@successChannelLinkCopied": { + "description": "Message when link of a channel was copied to the user's system clipboard." + }, + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "@successTopicLinkCopied": { + "description": "Message when link of a topic was copied to the user's system clipboard." + }, + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@today": { + "description": "Term to use to reference the current day." + }, + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + }, + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "otherTypist": { + "example": "Bob", + "type": "String" + }, + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "@unsubscribeConfirmationDialogConfirmButton": { + "description": "Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel." + }, + "@unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": { + "description": "Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe." + }, + "@unsubscribeConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for unsubscribing from a channel.", + "placeholders": { + "channelName": { + "example": "mobile", + "type": "String" + } + } + }, + "@unsubscribeFailedTitle": { + "description": "Error title when unsubscribing from a channel failed." + }, + "@updateStatusErrorTitle": { + "description": "Error title when updating user status failed." + }, + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "@userActiveDate": { + "description": "Indicates the date when a user was last active on Zulip (who is currently offline).\n\nThe date might be day and month if recent, or day, month, and year if less recent.", + "placeholders": { + "date": { + "example": "Aug 1, 2024", + "type": "String" + } + } + }, + "@userActiveDaysAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "days": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveHoursAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "hours": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveMinutesAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "minutes": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveNow": { + "description": "Indicates a user is currently active on Zulip (not idle or offline)" + }, + "@userActiveYesterday": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userIdle": { + "description": "Indicates a user is currently idle on Zulip (not active, but not offline)" + }, + "@userNotActiveInYear": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "@userStatusAtTheOffice": { + "description": "A suggested user status text, 'At the office'." + }, + "@userStatusBusy": { + "description": "A suggested user status text, 'Busy'." + }, + "@userStatusCommuting": { + "description": "A suggested user status text, 'Commuting'." + }, + "@userStatusInAMeeting": { + "description": "A suggested user status text, 'In a meeting'." + }, + "@userStatusOutSick": { + "description": "A suggested user status text, 'Out sick'." + }, + "@userStatusVacationing": { + "description": "A suggested user status text, 'Vacationing'." + }, + "@userStatusWorkingRemotely": { + "description": "A suggested user status text, 'Working remotely'." + }, + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "aboutPageAppVersion": "Версия приложения", + "aboutPageOpenSourceLicenses": "Лицензии открытого исходного кода", + "aboutPageTapToView": "Нажмите для просмотра", + "aboutPageTitle": "О Zulip", + "actionSheetOptionChannelFeed": "Лента канала", + "actionSheetOptionCopyChannelLink": "Скопировать ссылку на канал", + "actionSheetOptionCopyMessageLink": "Скопировать ссылку на сообщение", + "actionSheetOptionCopyMessageText": "Скопировать текст сообщения", + "actionSheetOptionCopyTopicLink": "Скопировать ссылку на тему", + "actionSheetOptionEditMessage": "Редактировать сообщение", + "actionSheetOptionFollowTopic": "Отслеживать тему", + "actionSheetOptionHideMutedMessage": "Скрыть заглушенное сообщение", + "actionSheetOptionListOfTopics": "Список тем", + "actionSheetOptionMarkAsUnread": "Отметить как непрочитанные начиная отсюда", + "actionSheetOptionMarkChannelAsRead": "Отметить канал как прочитанный", + "actionSheetOptionMarkTopicAsRead": "Отметить тему как прочитанную", + "actionSheetOptionMuteTopic": "Заглушить тему", + "actionSheetOptionQuoteMessage": "Цитировать сообщение", + "actionSheetOptionResolveTopic": "Поставить отметку \"решено\"", + "actionSheetOptionSeeWhoReacted": "Посмотреть отреагировавших", + "actionSheetOptionShare": "Поделиться", + "actionSheetOptionStarMessage": "Отметить сообщение", + "actionSheetOptionSubscribe": "Подписаться", + "actionSheetOptionUnfollowTopic": "Не отслеживать тему", + "actionSheetOptionUnmuteTopic": "Включить оповещения темы", + "actionSheetOptionUnresolveTopic": "Снять отметку \"решено\"", + "actionSheetOptionUnstarMessage": "Снять отметку с сообщения", + "actionSheetOptionUnsubscribe": "Отписаться", + "actionSheetOptionViewReadReceipts": "Посмотреть подтверждения прочтения", + "actionSheetReadReceipts": "Подтверждения прочтения", + "actionSheetReadReceiptsErrorReadCount": "Не удалось загрузить подтверждения прочтения.", + "actionSheetReadReceiptsReadCount": "{count, plural, one{Это сообщение было прочитано {count} пользователем:} other{Это сообщение было прочитано {count} пользователями:}}", + "actionSheetReadReceiptsZeroReadCount": "Это сообщение еще никто не прочитал.", + "appVersionUnknownPlaceholder": "(…)", + "channelFeedButtonTooltip": "Лента канала", + "channelsEmptyPlaceholder": "Вы ещё не подписаны ни на один канал.", + "channelsPageTitle": "Каналы", + "chooseAccountButtonAddAnAccount": "Добавить учетную запись", + "chooseAccountPageLogOutButton": "Выход из системы", + "chooseAccountPageTitle": "Выберите учетную запись", + "combinedFeedPageTitle": "Объединенная лента", + "composeBoxAttachFilesTooltip": "Прикрепить файлы", + "composeBoxAttachFromCameraTooltip": "Сделать снимок", + "composeBoxAttachMediaTooltip": "Прикрепить изображения или видео", + "composeBoxBannerButtonCancel": "Отмена", + "composeBoxBannerButtonSave": "Сохранить", + "composeBoxBannerLabelEditMessage": "Редактирование сообщения", + "composeBoxChannelContentHint": "Сообщение для {destination}", + "composeBoxDmContentHint": "Сообщение для @{user}", + "composeBoxEnterTopicOrSkipHintText": "Укажите тему (или оставьте “{defaultTopicName}”)", + "composeBoxGenericContentHint": "Ввести сообщение", + "composeBoxGroupDmContentHint": "Сообщение для группы", + "composeBoxLoadingMessage": "(загрузка сообщения {messageId})", + "composeBoxSelfDmContentHint": "Сделать заметку", + "composeBoxSendTooltip": "Отправить", + "composeBoxTopicHintText": "Тема", + "composeBoxUploadingFilename": "Загрузка {filename}…", + "contentValidationErrorEmpty": "Нечего отправлять!", + "contentValidationErrorQuoteAndReplyInProgress": "Пожалуйста, дождитесь завершения цитирования.", + "contentValidationErrorTooLong": "Длина сообщения не должна превышать 10000 символов.", + "contentValidationErrorUploadInProgress": "Пожалуйста, дождитесь завершения загрузки.", + "dialogCancel": "Отмена", + "dialogClose": "Закрыть", + "dialogContinue": "Продолжить", + "discardDraftConfirmationDialogConfirmButton": "Сбросить", + "discardDraftConfirmationDialogTitle": "Отказаться от написанного сообщения?", + "discardDraftForEditConfirmationDialogMessage": "При изменении сообщения текст из поля для редактирования удаляется.", + "discardDraftForOutboxConfirmationDialogMessage": "При восстановлении неотправленного сообщения содержимое поля редактирования очищается.", + "dmsWithOthersPageTitle": "ЛС с {others}", + "dmsWithYourselfPageTitle": "ЛС с собой", + "editAlreadyInProgressMessage": "Редактирование уже выполняется. Дождитесь завершения.", + "editAlreadyInProgressTitle": "Редактирование недоступно", + "emojiPickerSearchEmoji": "Поиск эмодзи", + "emojiReactionsMore": "ещё", + "emptyMessageList": "Здесь нет сообщений.", + "emptyMessageListSearch": "Ничего не найдено.", + "errorAccountLoggedIn": "Учетная запись {email} на {server} уже присутствует.", + "errorAccountLoggedInTitle": "Вход в учетную запись уже выполнен", + "errorBannerCannotPostInChannelLabel": "У вас нет права писать в этом канале.", + "errorBannerDeactivatedDmLabel": "Нельзя отправить сообщение отключенным пользователям.", + "errorConnectingToServerDetails": "Ошибка подключения к Zulip на {serverUrl}. Повторим попытку:\n\n{error}", + "errorConnectingToServerShort": "Ошибка подключения к Zulip. Повторяем попытку…", + "errorContentNotInsertedTitle": "Содержимое не вставлено", + "errorContentToInsertIsEmpty": "Файл для вставки пустой, или к нему нет доступа.", + "errorCopyingFailed": "Сбой копирования", + "errorCouldNotConnectTitle": "Нет связи с сервером", + "errorCouldNotEditMessageTitle": "Сбой редактирования", + "errorCouldNotFetchMessageSource": "Не удалось извлечь источник сообщения.", + "errorCouldNotOpenLink": "Не удалось открыть ссылку: {url}", + "errorCouldNotOpenLinkTitle": "Не удалось открыть ссылку", + "errorCouldNotShowUserProfile": "Не удалось показать профиль пользователя.", + "errorDialogContinue": "OK", + "errorDialogLearnMore": "Узнать больше", + "errorDialogTitle": "Ошибка", + "errorFailedToUploadFileTitle": "Не удалось загрузить файл: {filename}", + "errorFilesTooLarge": "Размер {num, plural, =1{файла} other{{num} файлов}} превышает предел для сервера {maxFileUploadSizeMib} МиБ, загрузка невозможна:\n\n{listMessage}", + "errorFilesTooLargeTitle": "Слишком большой размер {num, plural, =1{файла} other{файлов}}", + "errorFollowTopicFailed": "Не удалось начать отслеживать тему", + "errorHandlingEventDetails": "Ошибка обработки события Zulip от {serverUrl}; повторим попытку.\n\nОшибка: {error}\n\nСобытие: {event}", + "errorHandlingEventTitle": "Ошибка обработки события Zulip. Повторная попытка соединения…", + "errorInvalidApiKeyMessage": "Не удалось войти в вашу учётную запись {url}. Попробуйте ещё раз или используйте другую учётную запись.", + "errorInvalidResponse": "Сервер отправил недопустимый ответ.", + "errorLoginCouldNotConnect": "Не удалось подключиться к серверу:\n{url}", + "errorLoginFailedTitle": "Не удалось войти в систему", + "errorLoginInvalidInputTitle": "Неверный ввод", + "errorMalformedResponse": "Сервер вернул некорректный ответ; HTTP-статус {httpStatus}", + "errorMalformedResponseWithCause": "Сервер вернул некорректный ответ; HTTP-статус {httpStatus}; {details}", + "errorMarkAsReadFailedTitle": "Не удалось установить отметку прочтения", + "errorMarkAsUnreadFailedTitle": "Не удалось снять отметку прочтения", + "errorMessageDoesNotSeemToExist": "Это сообщение, похоже, отсутствует.", + "errorMessageEditNotSaved": "Сообщение не сохранено", + "errorMessageNotSent": "Сообщение не отправлено", + "errorMuteTopicFailed": "Не удалось заглушить тему", + "errorNetworkRequestFailed": "Сбой сетевого запроса", + "errorNotificationOpenAccountNotFound": "Учетная запись, связанная с этим уведомлением, не найдена.", + "errorNotificationOpenTitle": "Не удалось открыть оповещения", + "errorQuotationFailed": "Цитирование не удалось", + "errorReactionAddingFailedTitle": "Не удалось добавить реакцию", + "errorReactionRemovingFailedTitle": "Не удалось удалить реакцию", + "errorRequestFailed": "Сбой сетевого запроса: HTTP-статус {httpStatus}", + "errorResolveTopicFailedTitle": "Не удалось отметить тему как решенную", + "errorServerMessage": "Ответ сервера:\n\n{message}", + "errorServerVersionUnsupportedMessage": "{url} использует Zulip Server {zulipVersion}, который не поддерживается. Минимальная поддерживаемая версия — Zulip Server {minSupportedZulipVersion}.", + "errorSharingAccountNotLoggedIn": "Не выполнен вход с учетной записью. Пожалуйста, войдите в систему и повторите попытку.", + "errorSharingFailed": "Не удалось поделиться", + "errorSharingTitle": "Не удалось поделиться содержанием", + "errorStarMessageFailedTitle": "Не удалось отметить сообщение", + "errorUnfollowTopicFailed": "Не удалось прекратить отслеживать тему", + "errorUnmuteTopicFailed": "Не удалось включить оповещения темы", + "errorUnresolveTopicFailedTitle": "Не удалось отметить тему как нерешенную", + "errorUnstarMessageFailedTitle": "Не удалось снять отметку с сообщения", + "errorVideoPlayerFailed": "Не удается воспроизвести видео.", + "errorWebAuthOperationalError": "Произошла непредвиденная ошибка.", + "errorWebAuthOperationalErrorTitle": "Что-то пошло не так", + "experimentalFeatureSettingsPageTitle": "Экспериментальные функции", + "experimentalFeatureSettingsWarning": "Эти параметры включают возможности, которые все ещё находятся в разработке и не готовы. Они могут не работать и вызывать проблемы в других местах приложения.\n\nЦель этих настроек — экспериментирование людьми, работающими над разработкой Zulip.", + "filenameAndSizeInMiB": "{filename}: {size} МиБ", + "inboxEmptyPlaceholder": "Нет непрочитанных входящих сообщений. Используйте кнопки ниже для просмотра объединенной ленты или списка каналов.", + "inboxPageTitle": "Входящие", + "initialAnchorSettingDescription": "Можно открывать ленту сообщений на первом непрочитанном сообщении или на самом новом.", + "initialAnchorSettingFirstUnreadAlways": "Первое непрочитанное сообщение", + "initialAnchorSettingFirstUnreadConversations": "Первое непрочитанное сообщение при просмотре бесед, самое новое в остальных местах", + "initialAnchorSettingNewestAlways": "Самое новое сообщение", + "initialAnchorSettingTitle": "Где открывать ленту сообщений", + "invisibleMode": "Режим невидимости", + "lightboxCopyLinkTooltip": "Скопировать ссылку", + "lightboxVideoCurrentPosition": "Место воспроизведения", + "lightboxVideoDuration": "Длительность видео", + "logOutConfirmationDialogConfirmButton": "Выйти", + "logOutConfirmationDialogMessage": "Чтобы использовать эту учетную запись в будущем, вам придется заново ввести URL-адрес вашей организации и информацию о вашей учетной записи.", + "logOutConfirmationDialogTitle": "Выйти из системы?", + "loginAddAnAccountPageTitle": "Добавление учетной записи", + "loginEmailLabel": "Адрес почты", + "loginErrorMissingEmail": "Пожалуйста, введите ваш адрес электронной почты.", + "loginErrorMissingPassword": "Пожалуйста, введите пароль.", + "loginErrorMissingUsername": "Пожалуйста, введите ваше имя пользователя.", + "loginFormSubmitLabel": "Войти", + "loginHidePassword": "Скрыть пароль", + "loginMethodDivider": "ИЛИ", + "loginPageTitle": "Вход в систему", + "loginPasswordLabel": "Пароль", + "loginServerUrlLabel": "URL вашего сервера Zulip", + "loginUsernameLabel": "Имя пользователя", + "mainMenuMyProfile": "Мой профиль", + "manyPeopleTyping": "Несколько человек набирают сообщения…", + "markAllAsReadLabel": "Отметить все сообщения как прочитанные", + "markAsReadComplete": "Отметка прочтения установлена для {num, plural, one{{num} сообщения} other{{num} сообщений}}.", + "markAsReadInProgress": "Помечаем сообщения как прочитанные…", + "markAsUnreadComplete": "Отметка прочтения снята для {num, plural, one{{num} сообщения} other{{num} сообщений}}.", + "markAsUnreadInProgress": "Помечаем сообщения как непрочитанные…", + "markReadOnScrollSettingAlways": "Всегда", + "markReadOnScrollSettingConversations": "Только при просмотре бесед", + "markReadOnScrollSettingConversationsDescription": "Сообщения будут автоматически помечаться как прочитанные только при просмотре отдельной темы или личной беседы.", + "markReadOnScrollSettingDescription": "При прокрутке сообщений автоматически отмечать их как прочитанные?", + "markReadOnScrollSettingNever": "Никогда", + "markReadOnScrollSettingTitle": "Отмечать сообщения как прочитанные при прокрутке", + "mentionsPageTitle": "Упоминания", + "messageIsEditedLabel": "ИЗМЕНЕНО", + "messageIsMovedLabel": "ПЕРЕМЕЩЕНО", + "messageListGroupYouAndOthers": "Вы и {others}", + "messageListGroupYouWithYourself": "Сообщения с собой", + "messageNotSentLabel": "СООБЩЕНИЕ НЕ ОТПРАВЛЕНО", + "mutedUser": "Заглушенный пользователь", + "newDmFabButtonLabel": "Новое ЛС", + "newDmSheetComposeButtonLabel": "Написать", + "newDmSheetNoUsersFound": "Никто не найден", + "newDmSheetScreenTitle": "Новое ЛС", + "newDmSheetSearchHintEmpty": "Добавить пользователей", + "newDmSheetSearchHintSomeSelected": "Добавить ещё…", + "noEarlierMessages": "Предшествующих сообщений нет", + "noStatusText": "Нет текста статуса", + "notifGroupDmConversationLabel": "{senderFullName} вам и ещё {numOthers, plural, one{{numOthers} другому} other{{numOthers} другим}}", + "notifSelfUser": "Вы", + "onePersonTyping": "{typist} набирает сообщение…", + "openLinksWithInAppBrowser": "Открывать ссылки внутри приложения", + "permissionsDeniedCameraAccess": "Для загрузки изображения, пожалуйста, предоставьте Zulip дополнительные разрешения в настройках.", + "permissionsDeniedReadExternalStorage": "Для загрузки файлов, пожалуйста, предоставьте Zulip дополнительные разрешения в настройках.", + "permissionsNeededOpenSettings": "Открыть настройки", + "permissionsNeededTitle": "Требуются разрешения", + "pinnedSubscriptionsLabel": "Закреплены", + "pollVoterNames": "({voterNames})", + "pollWidgetOptionsMissing": "В опросе пока нет вариантов ответа.", + "pollWidgetQuestionMissing": "Нет вопроса.", + "preparingEditMessageContentInput": "Подготовка…", + "profileButtonSendDirectMessage": "Отправить личное сообщение", + "reactedEmojiSelfUser": "Вы", + "reactionChipLabel": "{emojiName}: {votes}", + "reactionChipVotesYouAndOthers": "{otherUsersCount, plural, one{Вы и еще {otherUsersCount} человек} few{Вы и еще {otherUsersCount} человека} many{Вы и еще {otherUsersCount} человек} other{Вы и еще {otherUsersCount} человек}}", + "reactionChipsLabel": "Реакции", + "recentDmConversationsEmptyPlaceholder": "У вас пока нет личных сообщений! Почему бы не начать беседу?", + "recentDmConversationsPageTitle": "Личные сообщения", + "recentDmConversationsSectionHeader": "Личные сообщения", + "revealButtonLabel": "Показать сообщение", + "savingMessageEditFailedLabel": "ПРАВКИ НЕ СОХРАНЕНЫ", + "savingMessageEditLabel": "ЗАПИСЬ ПРАВОК…", + "scrollToBottomTooltip": "Пролистать вниз", + "searchMessagesClearButtonTooltip": "Очистить", + "searchMessagesHintText": "Поиск", + "searchMessagesPageTitle": "Поиск", + "seeWhoReactedSheetEmojiNameWithVoteCount": "{emojiName}: {num, plural, one{1 голос} few{{num} голоса} many{{num} голосов} other{{num} голосов}}", + "seeWhoReactedSheetHeaderLabel": "Эмодзи-реакции (всего: {num})", + "seeWhoReactedSheetNoReactions": "На это сообщение нет реакций.", + "seeWhoReactedSheetUserListLabel": "Голоса за {emojiName} ({num})", + "serverUrlValidationErrorEmpty": "Пожалуйста, введите URL-адрес.", + "serverUrlValidationErrorInvalidUrl": "Пожалуйста, введите корректный URL-адрес.", + "serverUrlValidationErrorNoUseEmail": "Пожалуйста, введите URL-адрес сервера, а не свой email.", + "serverUrlValidationErrorUnsupportedScheme": "URL-адрес сервера должен начинаться с http:// или https://.", + "setStatusPageTitle": "Установить статус", + "settingsPageTitle": "Настройки", + "sharePageTitle": "Поделиться", + "signInWithFoo": "Войти с помощью {method}", + "snackBarDetails": "Подробности", + "spoilerDefaultHeaderText": "Спойлер", + "starredMessagesPageTitle": "Отмеченные сообщения", + "statusButtonLabelStatusSet": "Статус", + "statusButtonLabelStatusUnset": "Установить статус", + "statusClearButtonLabel": "Очистить", + "statusSaveButtonLabel": "Сохранить", + "statusTextHint": "Ваш статус", + "subscribeFailedTitle": "Подписаться не удалось", + "successChannelLinkCopied": "Ссылка на канал скопирована", + "successLinkCopied": "Ссылка скопирована", + "successMessageLinkCopied": "Ссылка на сообщение скопирована", + "successMessageTextCopied": "Текст сообщения скопирован", + "successTopicLinkCopied": "Ссылка на тему скопирована", + "switchAccountButton": "Сменить учетную запись", + "themeSettingDark": "Темный", + "themeSettingLight": "Светлый", + "themeSettingSystem": "Системный", + "themeSettingTitle": "РЕЖИМ", + "today": "Сегодня", + "topicValidationErrorMandatoryButEmpty": "Темы обязательны в этой организации.", + "topicValidationErrorTooLong": "Длина темы не должна превышать 60 символов.", + "topicsButtonTooltip": "Темы", + "tryAnotherAccountButton": "Попробовать другую учетную запись", + "tryAnotherAccountMessage": "Ваша учетная запись на {url} загружается медленно.", + "turnOffInvisibleModeErrorTitle": "Не удалось отключить режим невидимости. Повторите попытку позже.", + "turnOnInvisibleModeErrorTitle": "Не удалось включить режим невидимости. Повторите попытку позже.", + "twoPeopleTyping": "{typist} и {otherTypist} набирают сообщения…", + "unknownChannelName": "(неизвестный канал)", + "unknownUserName": "(неизвестный пользователь)", + "unpinnedSubscriptionsLabel": "Откреплены", + "unsubscribeConfirmationDialogConfirmButton": "Отписаться", + "unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": "Покинув этот канал, возможно, вы не сможете присоединиться вновь.", + "unsubscribeConfirmationDialogTitle": "Отменить подписку на {channelName}?", + "unsubscribeFailedTitle": "Не удалось отписаться", + "updateStatusErrorTitle": "Ошибка обновления статуса пользователя. Попробуйте ещё раз.", + "upgradeWelcomeDialogDismiss": "Приступим", + "upgradeWelcomeDialogLinkText": "Ознакомьтесь с анонсом в блоге!", + "upgradeWelcomeDialogMessage": "Вы найдете привычные возможности в более быстром и легком приложении.", + "upgradeWelcomeDialogTitle": "Добро пожаловать в новое приложение Zulip!", + "userActiveDate": "Был/а на связи {date}", + "userActiveDaysAgo": "Был/а на связи {days, plural, one{{days} день} few{{days} дня} many{{days} дней} other{{days} дней}} назад", + "userActiveHoursAgo": "Был/а на связи {hours, plural, one{{hours} час} few{{hours} часа} many{{hours} часов} other{{hours} часов}} назад", + "userActiveMinutesAgo": "Был/а на связи {minutes, plural, one{{minutes} минуту} few{{minutes} минуты} many{{minutes} минут} other{{minutes} минут}} назад", + "userActiveNow": "На связи", + "userActiveYesterday": "Был/а на связи вчера", + "userIdle": "Бездействует", + "userNotActiveInYear": "Не выходил/а на связь за последний год", + "userRoleAdministrator": "Администратор", + "userRoleGuest": "Гость", + "userRoleMember": "Участник", + "userRoleModerator": "Модератор", + "userRoleOwner": "Владелец", + "userRoleUnknown": "Неизвестно", + "userStatusAtTheOffice": "В офисе", + "userStatusBusy": "В делах", + "userStatusCommuting": "В дороге", + "userStatusInAMeeting": "На встрече", + "userStatusOutSick": "Болею", + "userStatusVacationing": "В отпуске", + "userStatusWorkingRemotely": "Работаю дистанционно", + "wildcardMentionAll": "все", + "wildcardMentionAllDmDescription": "Оповестить получателей", + "wildcardMentionChannel": "канал", + "wildcardMentionChannelDescription": "Оповестить канал", + "wildcardMentionEveryone": "каждый", + "wildcardMentionStream": "канал", + "wildcardMentionStreamDescription": "Оповестить канал", + "wildcardMentionTopic": "тема", + "wildcardMentionTopicDescription": "Оповестить тему", + "yesterday": "Вчера", + "zulipAppTitle": "Zulip" } diff --git a/assets/l10n/app_sk.arb b/assets/l10n/app_sk.arb index 4ad83e6790..4d6279d7b1 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": { @@ -119,10 +119,6 @@ "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionQuoteAndReply": "Citovať a odpovedať", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." - }, "actionSheetOptionStarMessage": "Ohviezdičkovať správu", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." @@ -171,10 +167,6 @@ } } }, - "errorLoginCouldNotConnectTitle": "Nepodarilo sa pripojiť", - "@errorLoginCouldNotConnectTitle": { - "description": "Error title when the app could not connect to the server." - }, "errorMessageDoesNotSeemToExist": "Správa zrejme neexistuje.", "@errorMessageDoesNotSeemToExist": { "description": "Error message when loading a message that does not exist." diff --git a/assets/l10n/app_sl.arb b/assets/l10n/app_sl.arb new file mode 100644 index 0000000000..15a54a0b13 --- /dev/null +++ b/assets/l10n/app_sl.arb @@ -0,0 +1,1518 @@ +{ + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "@actionSheetOptionChannelFeed": { + "description": "Label for navigating to a channel's channel-feed page." + }, + "@actionSheetOptionCopyChannelLink": { + "description": "Label for copy channel link button on action sheet." + }, + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "@actionSheetOptionCopyTopicLink": { + "description": "Label for copy topic link button in action sheet." + }, + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "@actionSheetOptionSeeWhoReacted": { + "description": "Label for the 'See who reacted' button in the message action sheet." + }, + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "@actionSheetOptionSubscribe": { + "description": "Label in the channel action sheet for subscribing to the channel." + }, + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "@actionSheetOptionUnsubscribe": { + "description": "Label in the channel action sheet for unsubscribing from the channel." + }, + "@actionSheetOptionViewReadReceipts": { + "description": "Label for the 'View read receipts' button in the message action sheet." + }, + "@actionSheetReadReceipts": { + "description": "Title for the \"Read receipts\" bottom sheet." + }, + "@actionSheetReadReceiptsErrorReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when loading read receipts failed." + }, + "@actionSheetReadReceiptsReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when one or more people have read the message.", + "placeholders": { + "count": { + "example": "1", + "type": "int" + } + } + }, + "@actionSheetReadReceiptsZeroReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when no one has read the message." + }, + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "example": "#channel name > topic name", + "type": "String" + } + } + }, + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "example": "channel name", + "type": "String" + } + } + }, + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "example": "general chat", + "type": "String" + } + } + }, + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "example": "1234", + "type": "int" + } + } + }, + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "example": "user@example.com", + "type": "String" + }, + "server": { + "example": "https://example.com", + "type": "String" + } + } + }, + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "error": { + "example": "Invalid format", + "type": "String" + }, + "serverUrl": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "@errorContentNotInsertedTitle": { + "description": "Title for error dialog when an attempt to insert rich content failed." + }, + "@errorContentToInsertIsEmpty": { + "description": "Error message when the rich content to be inserted is empty or cannot be accessed." + }, + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "listMessage": { + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB", + "type": "String" + }, + "maxFileUploadSizeMib": { + "example": "15", + "type": "int" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "error": { + "example": "Unexpected null value", + "type": "String" + }, + "event": { + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')", + "type": "String" + }, + "serverUrl": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + } + }, + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "details": { + "example": "type 'Null' is not a subtype of type 'String' in type cast", + "type": "String" + }, + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "example": "500", + "type": "int" + } + } + }, + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "example": "Invalid format", + "type": "String" + } + } + }, + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "minSupportedZulipVersion": { + "example": "4.0", + "type": "String" + }, + "url": { + "example": "http://chat.example.com/", + "type": "String" + }, + "zulipVersion": { + "example": "3.2", + "type": "String" + } + } + }, + "@errorSharingAccountNotLoggedIn": { + "description": "Error title when sharing content received from other apps fails, when there is no account logged in" + }, + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "@errorSharingTitle": { + "description": "Error title when sharing content received from other apps fails" + }, + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "example": "foo.txt", + "type": "String" + }, + "size": { + "example": "20.2", + "type": "String" + } + } + }, + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "@loginPageTitle": { + "description": "Title for login page." + }, + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "@noStatusText": { + "description": "The text part of the status button sub-label in self-user profile page when status text is not set." + }, + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "numOthers": { + "example": "4", + "type": "int" + }, + "senderFullName": { + "example": "Alice", + "type": "String" + } + } + }, + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "@pollVoterNames": { + "description": "The list of people who voted for a poll option, wrapped in parentheses.", + "placeholders": { + "voterNames": { + "example": "Alice, Bob, Chad", + "type": "String" + } + } + }, + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "@reactionChipLabel": { + "description": "Text describing a reaction chip, with the emoji name and a list or number of votes. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "votes": { + "example": "You, Chris, Greg", + "type": "String" + } + } + }, + "@reactionChipVotesYouAndOthers": { + "description": "The number of votes on a reaction chip, where the self-user and at least one other user has voted. (An accessibility label for assistive technology.)", + "placeholders": { + "otherUsersCount": { + "example": "4", + "type": "int" + } + } + }, + "@reactionChipsLabel": { + "description": "Text identifying the container of reaction chips on a message. (An accessibility label for assistive technology.)" + }, + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." + }, + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "@seeWhoReactedSheetEmojiNameWithVoteCount": { + "description": "In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetHeaderLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.)", + "placeholders": { + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetNoReactions": { + "description": "Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened)." + }, + "@seeWhoReactedSheetUserListLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "@setStatusPageTitle": { + "description": "Title for the 'Set status' page." + }, + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "@sharePageTitle": { + "description": "Title for the page about sharing content received from other apps." + }, + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "example": "Google", + "type": "String" + } + } + }, + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "@statusButtonLabelStatusSet": { + "description": "The status button label in self-user profile page when status is set." + }, + "@statusButtonLabelStatusUnset": { + "description": "The status button label in self-user profile page when status is not set." + }, + "@statusClearButtonLabel": { + "description": "Label for the button that clears the user status, in 'Set status' page." + }, + "@statusSaveButtonLabel": { + "description": "Label for the button that saves the user status, in 'Set status' page." + }, + "@statusTextHint": { + "description": "Hint text for the status text input field in 'Set status' page." + }, + "@subscribeFailedTitle": { + "description": "Error title when subscribing to a channel failed." + }, + "@successChannelLinkCopied": { + "description": "Message when link of a channel was copied to the user's system clipboard." + }, + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "@successTopicLinkCopied": { + "description": "Message when link of a topic was copied to the user's system clipboard." + }, + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@today": { + "description": "Term to use to reference the current day." + }, + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + }, + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "otherTypist": { + "example": "Bob", + "type": "String" + }, + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "@unsubscribeConfirmationDialogConfirmButton": { + "description": "Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel." + }, + "@unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": { + "description": "Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe." + }, + "@unsubscribeConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for unsubscribing from a channel.", + "placeholders": { + "channelName": { + "example": "mobile", + "type": "String" + } + } + }, + "@unsubscribeFailedTitle": { + "description": "Error title when unsubscribing from a channel failed." + }, + "@updateStatusErrorTitle": { + "description": "Error title when updating user status failed." + }, + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "@userActiveDate": { + "description": "Indicates the date when a user was last active on Zulip (who is currently offline).\n\nThe date might be day and month if recent, or day, month, and year if less recent.", + "placeholders": { + "date": { + "example": "Aug 1, 2024", + "type": "String" + } + } + }, + "@userActiveDaysAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "days": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveHoursAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "hours": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveMinutesAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "minutes": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveNow": { + "description": "Indicates a user is currently active on Zulip (not idle or offline)" + }, + "@userActiveYesterday": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userIdle": { + "description": "Indicates a user is currently idle on Zulip (not active, but not offline)" + }, + "@userNotActiveInYear": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "@userStatusAtTheOffice": { + "description": "A suggested user status text, 'At the office'." + }, + "@userStatusBusy": { + "description": "A suggested user status text, 'Busy'." + }, + "@userStatusCommuting": { + "description": "A suggested user status text, 'Commuting'." + }, + "@userStatusInAMeeting": { + "description": "A suggested user status text, 'In a meeting'." + }, + "@userStatusOutSick": { + "description": "A suggested user status text, 'Out sick'." + }, + "@userStatusVacationing": { + "description": "A suggested user status text, 'Vacationing'." + }, + "@userStatusWorkingRemotely": { + "description": "A suggested user status text, 'Working remotely'." + }, + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "aboutPageAppVersion": "Različica aplikacije", + "aboutPageOpenSourceLicenses": "Odprtokodne licence", + "aboutPageTapToView": "Dotaknite se za ogled", + "aboutPageTitle": "O Zulipu", + "actionSheetOptionChannelFeed": "Vir kanala", + "actionSheetOptionCopyChannelLink": "Kopiraj povezavo do kanala", + "actionSheetOptionCopyMessageLink": "Kopiraj povezavo do sporočila", + "actionSheetOptionCopyMessageText": "Kopiraj besedilo sporočila", + "actionSheetOptionCopyTopicLink": "Kopiraj povezavo do teme", + "actionSheetOptionEditMessage": "Uredi sporočilo", + "actionSheetOptionFollowTopic": "Sledi temi", + "actionSheetOptionHideMutedMessage": "Znova skrij utišano sporočilo", + "actionSheetOptionListOfTopics": "Seznam tem", + "actionSheetOptionMarkAsUnread": "Od tu naprej označi kot neprebrano", + "actionSheetOptionMarkChannelAsRead": "Označi kanal kot prebran", + "actionSheetOptionMarkTopicAsRead": "Označi temo kot prebrano", + "actionSheetOptionMuteTopic": "Utišaj temo", + "actionSheetOptionQuoteMessage": "Citiraj sporočilo", + "actionSheetOptionResolveTopic": "Označi kot razrešeno", + "actionSheetOptionSeeWhoReacted": "Poglej, kdo se je odzval", + "actionSheetOptionShare": "Deli", + "actionSheetOptionStarMessage": "Označi sporočilo z zvezdico", + "actionSheetOptionSubscribe": "Naroči se", + "actionSheetOptionUnfollowTopic": "Prenehaj slediti temi", + "actionSheetOptionUnmuteTopic": "Prekliči utišanje teme", + "actionSheetOptionUnresolveTopic": "Označi kot nerazrešeno", + "actionSheetOptionUnstarMessage": "Odstrani zvezdico s sporočila", + "actionSheetOptionUnsubscribe": "Prekliči naročnino", + "actionSheetOptionViewReadReceipts": "Poglej potrdila o branju", + "actionSheetReadReceipts": "Potrdila o branju", + "actionSheetReadReceiptsErrorReadCount": "Nalaganje potrdil o branju ni uspelo.", + "actionSheetReadReceiptsReadCount": "{count, plural, one{To sporočilo je prebrala {count} oseba:} two{To sporočilo sta prebrali {count} osebi:} few{To sporočilo so prebrale {count} osebe:} other{To sporočilo je prebralo {count} oseb:}}", + "actionSheetReadReceiptsZeroReadCount": "Tega sporočila še nihče ni prebral.", + "appVersionUnknownPlaceholder": "(...)", + "channelFeedButtonTooltip": "Sporočila kanala", + "channelsEmptyPlaceholder": "Niste še naročeni na noben kanal.", + "channelsPageTitle": "Kanali", + "chooseAccountButtonAddAnAccount": "Dodaj račun", + "chooseAccountPageLogOutButton": "Odjava", + "chooseAccountPageTitle": "Izberite račun", + "combinedFeedPageTitle": "Združen prikaz", + "composeBoxAttachFilesTooltip": "Pripni datoteke", + "composeBoxAttachFromCameraTooltip": "Fotografiraj", + "composeBoxAttachMediaTooltip": "Pripni fotografije ali videoposnetke", + "composeBoxBannerButtonCancel": "Prekliči", + "composeBoxBannerButtonSave": "Shrani", + "composeBoxBannerLabelEditMessage": "Uredi sporočilo", + "composeBoxChannelContentHint": "Sporočilo {destination}", + "composeBoxDmContentHint": "Sporočilo @{user}", + "composeBoxEnterTopicOrSkipHintText": "Vnesite temo (ali pustite prazno za »{defaultTopicName}«)", + "composeBoxGenericContentHint": "Vnesite sporočilo", + "composeBoxGroupDmContentHint": "Skupinsko sporočilo", + "composeBoxLoadingMessage": "(nalaganje sporočila {messageId})", + "composeBoxSelfDmContentHint": "Zapišite opombo zase", + "composeBoxSendTooltip": "Pošlji", + "composeBoxTopicHintText": "Tema", + "composeBoxUploadingFilename": "Nalaganje {filename}…", + "contentValidationErrorEmpty": "Ni vsebine za pošiljanje!", + "contentValidationErrorQuoteAndReplyInProgress": "Počakajte, da se citat zaključi.", + "contentValidationErrorTooLong": "Dolžina sporočila ne sme presegati 10000 znakov.", + "contentValidationErrorUploadInProgress": "Počakajte, da se nalaganje konča.", + "dialogCancel": "Prekliči", + "dialogClose": "Zapri", + "dialogContinue": "Nadaljuj", + "discardDraftConfirmationDialogConfirmButton": "Zavrzi", + "discardDraftConfirmationDialogTitle": "Želite zavreči sporočilo, ki ga pišete?", + "discardDraftForEditConfirmationDialogMessage": "Ko urejate sporočilo, se prejšnja vsebina polja za pisanje zavrže.", + "discardDraftForOutboxConfirmationDialogMessage": "Ko obnovite neodposlano sporočilo, se vsebina, ki je bila prej v polju za pisanje, zavrže.", + "dmsWithOthersPageTitle": "Neposredna sporočila z {others}", + "dmsWithYourselfPageTitle": "Neposredna sporočila s samim seboj", + "editAlreadyInProgressMessage": "Urejanje je že v teku. Počakajte, da se konča.", + "editAlreadyInProgressTitle": "Urejanje sporočila ni mogoče", + "emojiPickerSearchEmoji": "Iskanje emojijev", + "emojiReactionsMore": "več", + "emptyMessageList": "Tukaj ni sporočil.", + "emptyMessageListSearch": "Ni zadetkov iskanja.", + "errorAccountLoggedIn": "Račun {email} na {server} je že na vašem seznamu računov.", + "errorAccountLoggedInTitle": "Račun je že prijavljen", + "errorBannerCannotPostInChannelLabel": "Nimate dovoljenja za objavljanje v tem kanalu.", + "errorBannerDeactivatedDmLabel": "Deaktiviranim uporabnikom ne morete pošiljati sporočil.", + "errorConnectingToServerDetails": "Napaka pri povezovanju z Zulipom na {serverUrl}. Poskusili bomo znova:\n\n{error}", + "errorConnectingToServerShort": "Napaka pri povezovanju z Zulipom. Poskušamo znova…", + "errorContentNotInsertedTitle": "Vsebina ni vstavljena", + "errorContentToInsertIsEmpty": "Datoteka za vstavljanje je prazna ali nedostopna.", + "errorCopyingFailed": "Kopiranje ni uspelo", + "errorCouldNotConnectTitle": "Povezave ni bilo mogoče vzpostaviti", + "errorCouldNotEditMessageTitle": "Sporočila ni mogoče urediti", + "errorCouldNotFetchMessageSource": "Ni bilo mogoče pridobiti vira sporočila.", + "errorCouldNotOpenLink": "Povezave ni bilo mogoče odpreti: {url}", + "errorCouldNotOpenLinkTitle": "Povezave ni mogoče odpreti", + "errorCouldNotShowUserProfile": "Uporabniškega profila ni mogoče prikazati.", + "errorDialogContinue": "V redu", + "errorDialogLearnMore": "Več o tem", + "errorDialogTitle": "Napaka", + "errorFailedToUploadFileTitle": "Nalaganje datoteke ni uspelo: {filename}", + "errorFilesTooLarge": "{num, plural, one{{num} datoteka presega} two{{num} datoteki presegata} few{{num} datoteke presegajo} other{{num} datotek presega}} omejitev velikosti strežnika ({maxFileUploadSizeMib} MiB) in {num, plural, one{ne bo naložena} two{ne bosta naloženi} few{ne bodo naložene} other{ne bodo naložene}}:\n\n{listMessage}", + "errorFilesTooLargeTitle": "\"{num, plural, one{{num} datoteka je prevelika} two{{num} datoteki sta preveliki} few{{num} datoteke so prevelike} other{{num} datotek je prevelikih}}\"", + "errorFollowTopicFailed": "Sledenje temi ni uspelo", + "errorHandlingEventDetails": "Napaka pri obravnavi posodobitve iz strežnika {serverUrl}; poskusili bomo znova.\n\nNapaka: {error}\n\nDogodek: {event}", + "errorHandlingEventTitle": "Napaka pri obravnavi posodobitve. Povezujemo se znova…", + "errorInvalidApiKeyMessage": "Vašega računa na {url} ni bilo mogoče overiti. Poskusite se znova prijaviti ali uporabite drug račun.", + "errorInvalidResponse": "Strežnik je poslal neveljaven odgovor.", + "errorLoginCouldNotConnect": "Ni se mogoče povezati s strežnikom:\n{url}", + "errorLoginFailedTitle": "Prijava ni uspela", + "errorLoginInvalidInputTitle": "Neveljaven vnos", + "errorMalformedResponse": "Strežnik je poslal napačno oblikovan odgovor; stanje HTTP {httpStatus}", + "errorMalformedResponseWithCause": "Strežnik je poslal napačno oblikovan odgovor; stanje HTTP {httpStatus}; {details}", + "errorMarkAsReadFailedTitle": "Označevanje kot prebrano ni uspelo", + "errorMarkAsUnreadFailedTitle": "Označevanje kot neprebrano ni uspelo", + "errorMessageDoesNotSeemToExist": "Zdi se, da to sporočilo ne obstaja.", + "errorMessageEditNotSaved": "Sporočilo ni bilo shranjeno", + "errorMessageNotSent": "Pošiljanje sporočila ni uspelo", + "errorMuteTopicFailed": "Utišanje teme ni uspelo", + "errorNetworkRequestFailed": "Omrežna zahteva je spodletela", + "errorNotificationOpenAccountNotFound": "Računa, povezanega s tem obvestilom, ni bilo mogoče najti.", + "errorNotificationOpenTitle": "Obvestila ni bilo mogoče odpreti", + "errorQuotationFailed": "Citiranje ni uspelo", + "errorReactionAddingFailedTitle": "Reakcije ni bilo mogoče dodati", + "errorReactionRemovingFailedTitle": "Reakcije ni bilo mogoče odstraniti", + "errorRequestFailed": "Omrežna zahteva je spodletela: Stanje HTTP {httpStatus}", + "errorResolveTopicFailedTitle": "Neuspela označitev teme kot razrešene", + "errorServerMessage": "Strežnik je sporočil:\n\n{message}", + "errorServerVersionUnsupportedMessage": "{url} uporablja strežnik Zulip {zulipVersion}, ki ni podprt. Najnižja podprta različica je strežnik Zulip {minSupportedZulipVersion}.", + "errorSharingAccountNotLoggedIn": "Noben račun ni prijavljen. Prijavite se v račun in poskusite znova.", + "errorSharingFailed": "Deljenje ni uspelo", + "errorSharingTitle": "Deljenje vsebine ni uspelo", + "errorStarMessageFailedTitle": "Sporočila ni bilo mogoče označiti z zvezdico", + "errorUnfollowTopicFailed": "Prenehanje sledenja temi ni uspelo", + "errorUnmuteTopicFailed": "Preklic utišanja teme ni uspel", + "errorUnresolveTopicFailedTitle": "Neuspela označitev teme kot nerazrešene", + "errorUnstarMessageFailedTitle": "Sporočilu ni bilo mogoče odstraniti zvezdice", + "errorVideoPlayerFailed": "Videa ni mogoče predvajati.", + "errorWebAuthOperationalError": "Prišlo je do nepričakovane napake.", + "errorWebAuthOperationalErrorTitle": "Nekaj je šlo narobe", + "experimentalFeatureSettingsPageTitle": "Eksperimentalne funkcije", + "experimentalFeatureSettingsWarning": "Te možnosti omogočajo funkcije, ki so še v razvoju in niso pripravljene. Morda ne bodo delovale in lahko povzročijo težave v drugih delih aplikacije.\n\nNamen teh nastavitev je eksperimentiranje za uporabnike, ki delajo na razvoju Zulipa.", + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "inboxEmptyPlaceholder": "V vašem nabiralniku ni neprebranih sporočil. Uporabite spodnje gumbe za ogled združenega prikaza ali seznama kanalov.", + "inboxPageTitle": "Nabiralnik", + "initialAnchorSettingDescription": "Lahko izberete, ali se tok sporočil odpre pri vašem prvem neprebranem sporočilu ali pri najnovejših sporočilih.", + "initialAnchorSettingFirstUnreadAlways": "Prvo neprebrano sporočilo", + "initialAnchorSettingFirstUnreadConversations": "Prvo neprebrano v pogovorih, najnovejše drugje", + "initialAnchorSettingNewestAlways": "Najnovejše sporočilo", + "initialAnchorSettingTitle": "Odpri tok sporočil pri", + "invisibleMode": "Nevidni način", + "lightboxCopyLinkTooltip": "Kopiraj povezavo", + "lightboxVideoCurrentPosition": "Trenutni položaj", + "lightboxVideoDuration": "Trajanje videa", + "logOutConfirmationDialogConfirmButton": "Odjavi se", + "logOutConfirmationDialogMessage": "Če boste ta račun želeli uporabljati v prihodnje, boste morali znova vnesti URL svoje organizacije in podatke za prijavo.", + "logOutConfirmationDialogTitle": "Se želite odjaviti?", + "loginAddAnAccountPageTitle": "Dodaj račun", + "loginEmailLabel": "E-poštni naslov", + "loginErrorMissingEmail": "Vnesite svoj e-poštni naslov.", + "loginErrorMissingPassword": "Vnesite svoje geslo.", + "loginErrorMissingUsername": "Vnesite svoje uporabniško ime.", + "loginFormSubmitLabel": "Prijava", + "loginHidePassword": "Skrij geslo", + "loginMethodDivider": "ALI", + "loginPageTitle": "Prijava", + "loginPasswordLabel": "Geslo", + "loginServerUrlLabel": "URL strežnika Zulip", + "loginUsernameLabel": "Uporabniško ime", + "mainMenuMyProfile": "Moj profil", + "manyPeopleTyping": "Več oseb tipka…", + "markAllAsReadLabel": "Označi vsa sporočila kot prebrana", + "markAsReadComplete": "Označeno je {num, plural, one{{num} sporočilo} two{{num} sporočili} few{{num} sporočila} other{{num} sporočil}} kot prebrano.", + "markAsReadInProgress": "Označevanje sporočil kot prebranih…", + "markAsUnreadComplete": "{num, plural, one{Označeno je {num} sporočilo kot neprebrano} two{Označeni sta {num} sporočili kot neprebrani} few{Označena so {num} sporočila kot neprebrana} other{Označeno je {num} sporočil kot neprebranih}}.", + "markAsUnreadInProgress": "Označevanje sporočil kot neprebranih…", + "markReadOnScrollSettingAlways": "Vedno", + "markReadOnScrollSettingConversations": "Samo v pogledih pogovorov", + "markReadOnScrollSettingConversationsDescription": "Sporočila bodo samodejno označena kot prebrana samo pri ogledu ene teme ali zasebnega pogovora.", + "markReadOnScrollSettingDescription": "Naj se sporočila ob pomikanju samodejno označijo kot prebrana?", + "markReadOnScrollSettingNever": "Nikoli", + "markReadOnScrollSettingTitle": "Ob pomikanju označi sporočila kot prebrana", + "mentionsPageTitle": "Omembe", + "messageIsEditedLabel": "UREJENO", + "messageIsMovedLabel": "PREMAKNJENO", + "messageListGroupYouAndOthers": "Vi in {others}", + "messageListGroupYouWithYourself": "Sporočila sebi", + "messageNotSentLabel": "SPOROČILO NI POSLANO", + "mutedUser": "Uporabnik je utišan", + "newDmFabButtonLabel": "Novo neposredno sporočilo", + "newDmSheetComposeButtonLabel": "Napiši", + "newDmSheetNoUsersFound": "Ni zadetkov med uporabniki", + "newDmSheetScreenTitle": "Novo neposredno sporočilo", + "newDmSheetSearchHintEmpty": "Dodajte enega ali več uporabnikov", + "newDmSheetSearchHintSomeSelected": "Dodajte še enega uporabnika…", + "noEarlierMessages": "Ni starejših sporočil", + "noStatusText": "Brez statusa", + "notifGroupDmConversationLabel": "{senderFullName} vam in {numOthers, plural, =1{1 drugi osebi} other{{numOthers} drugim osebam}}", + "notifSelfUser": "Vi", + "onePersonTyping": "{typist} tipka…", + "openLinksWithInAppBrowser": "Odpri povezave v brskalniku znotraj aplikacije", + "permissionsDeniedCameraAccess": "Za nalaganje slik v nastavitvah omogočite Zulipu dostop do kamere.", + "permissionsDeniedReadExternalStorage": "Za nalaganje datotek v nastavitvah omogočite Zulipu dostop do shrambe datotek.", + "permissionsNeededOpenSettings": "Odpri nastavitve", + "permissionsNeededTitle": "Potrebna so dovoljenja", + "pinnedSubscriptionsLabel": "Pripeto", + "pollVoterNames": "({voterNames})", + "pollWidgetOptionsMissing": "Ta anketa še nima odgovorov.", + "pollWidgetQuestionMissing": "Brez vprašanja.", + "preparingEditMessageContentInput": "Pripravljanje…", + "profileButtonSendDirectMessage": "Pošlji neposredno sporočilo", + "reactedEmojiSelfUser": "Vi", + "reactionChipLabel": "{emojiName}: {votes}", + "reactionChipVotesYouAndOthers": "{otherUsersCount, plural, one{Vi in 1 druga oseba} two{Vi in 2 drugi osebi} few{Vi in {otherUsersCount} druge osebe} other{Vi in {otherUsersCount} drugih}}", + "reactionChipsLabel": "Odzivi", + "recentDmConversationsEmptyPlaceholder": "Zaenkrat še nimate neposrednih sporočil! Zakaj ne bi začeli pogovora?", + "recentDmConversationsPageTitle": "Neposredna sporočila", + "recentDmConversationsSectionHeader": "Neposredna sporočila", + "revealButtonLabel": "Razkrij sporočilo", + "savingMessageEditFailedLabel": "UREJANJE NI SHRANJENO", + "savingMessageEditLabel": "SHRANJEVANJE SPREMEMB…", + "scrollToBottomTooltip": "Premakni se na konec", + "searchMessagesClearButtonTooltip": "Počisti", + "searchMessagesHintText": "Išči", + "searchMessagesPageTitle": "Iskanje", + "seeWhoReactedSheetEmojiNameWithVoteCount": "{emojiName}: {num, plural, one{1 glas} two{2 glasa} few{{num} glasovi} other{{num} glasov}}", + "seeWhoReactedSheetHeaderLabel": "Odzivi z emodžiji (skupaj {num})", + "seeWhoReactedSheetNoReactions": "To sporočilo nima odzivov.", + "seeWhoReactedSheetUserListLabel": "Glasovi za {emojiName} ({num})", + "serverUrlValidationErrorEmpty": "Vnesite URL.", + "serverUrlValidationErrorInvalidUrl": "Vnesite veljaven URL.", + "serverUrlValidationErrorNoUseEmail": "Vnesite URL strežnika, ne vašega e-poštnega naslova.", + "serverUrlValidationErrorUnsupportedScheme": "URL strežnika se mora začeti s http:// ali https://.", + "setStatusPageTitle": "Nastavi status", + "settingsPageTitle": "Nastavitve", + "sharePageTitle": "Deli", + "signInWithFoo": "Prijava z {method}", + "snackBarDetails": "Podrobnosti", + "spoilerDefaultHeaderText": "Skrito", + "starredMessagesPageTitle": "Sporočila z zvezdico", + "statusButtonLabelStatusSet": "Status", + "statusButtonLabelStatusUnset": "Nastavi status", + "statusClearButtonLabel": "Počisti", + "statusSaveButtonLabel": "Shrani", + "statusTextHint": "Vaš status", + "subscribeFailedTitle": "Naročnina ni uspela", + "successChannelLinkCopied": "Povezava do kanala kopirana", + "successLinkCopied": "Povezava je bila kopirana", + "successMessageLinkCopied": "Povezava do sporočila je bila kopirana", + "successMessageTextCopied": "Besedilo sporočila je bilo kopirano", + "successTopicLinkCopied": "Povezava do teme kopirana", + "switchAccountButton": "Preklopi račun", + "themeSettingDark": "Temna", + "themeSettingLight": "Svetla", + "themeSettingSystem": "Sistemska", + "themeSettingTitle": "TEMA", + "today": "Danes", + "topicValidationErrorMandatoryButEmpty": "Teme so v tej organizaciji obvezne.", + "topicValidationErrorTooLong": "Dolžina teme ne sme presegati 60 znakov.", + "topicsButtonTooltip": "Teme", + "tryAnotherAccountButton": "Poskusite z drugim računom", + "tryAnotherAccountMessage": "Nalaganje vašega računa iz {url} traja dlje kot običajno.", + "turnOffInvisibleModeErrorTitle": "Napaka pri izklopu nevidnega načina. Poskusite znova.", + "turnOnInvisibleModeErrorTitle": "Napaka pri vklopu nevidnega načina. Poskusite znova.", + "twoPeopleTyping": "{typist} in {otherTypist} tipkata…", + "unknownChannelName": "(neznan kanal)", + "unknownUserName": "(neznan uporabnik)", + "unpinnedSubscriptionsLabel": "Nepripeto", + "unsubscribeConfirmationDialogConfirmButton": "Prekliči naročnino", + "unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": "Ko zapustite ta kanal, se morda ne boste mogli znova pridružiti.", + "unsubscribeConfirmationDialogTitle": "Odjava iz {channelName}?", + "unsubscribeFailedTitle": "Preklic naročnine ni uspel", + "updateStatusErrorTitle": "Napaka pri posodabljanju statusa uporabnika. Poskusite znova.", + "upgradeWelcomeDialogDismiss": "Začnimo", + "upgradeWelcomeDialogLinkText": "Preberite objavo na blogu!", + "upgradeWelcomeDialogMessage": "Čaka vas znana izkušnja v hitrejši in bolj elegantni obliki.", + "upgradeWelcomeDialogTitle": "Dobrodošli v novi aplikaciji Zulip!", + "userActiveDate": "Nazadnje aktiven {date}", + "userActiveDaysAgo": "Aktiven pred {days, plural, one{1 dnevom} two{{days} dnevoma} few{{days} dnevi} other{{days} dnevi}}", + "userActiveHoursAgo": "Aktiven pred {hours, plural, one{1 uro} two{{hours} urama} few{{hours} urami} other{{hours} urami}}", + "userActiveMinutesAgo": "Aktiven pred {minutes, plural, one{1 minuto} two{{minutes} minutama} few{{minutes} minutami} other{{minutes} minutami}}", + "userActiveNow": "Trenutno aktiven", + "userActiveYesterday": "Aktiven včeraj", + "userIdle": "Nedejaven", + "userNotActiveInYear": "Ni bil aktiven v zadnjem letu", + "userRoleAdministrator": "Skrbnik", + "userRoleGuest": "Gost", + "userRoleMember": "Član", + "userRoleModerator": "Moderator", + "userRoleOwner": "Lastnik", + "userRoleUnknown": "Neznano", + "userStatusAtTheOffice": "V pisarni", + "userStatusBusy": "Zaposlen", + "userStatusCommuting": "Na poti v službo", + "userStatusInAMeeting": "Na sestanku", + "userStatusOutSick": "Na bolniški", + "userStatusVacationing": "Na dopustu", + "userStatusWorkingRemotely": "Delo na daljavo", + "wildcardMentionAll": "vsi", + "wildcardMentionAllDmDescription": "Obvesti prejemnike", + "wildcardMentionChannel": "kanal", + "wildcardMentionChannelDescription": "Obvesti kanal", + "wildcardMentionEveryone": "vsi", + "wildcardMentionStream": "tok", + "wildcardMentionStreamDescription": "Obvesti tok", + "wildcardMentionTopic": "tema", + "wildcardMentionTopicDescription": "Obvesti udeležence teme", + "yesterday": "Včeraj", + "zulipAppTitle": "Zulip" +} diff --git a/assets/l10n/app_uk.arb b/assets/l10n/app_uk.arb new file mode 100644 index 0000000000..5abc3bf35e --- /dev/null +++ b/assets/l10n/app_uk.arb @@ -0,0 +1,1518 @@ +{ + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "@actionSheetOptionChannelFeed": { + "description": "Label for navigating to a channel's channel-feed page." + }, + "@actionSheetOptionCopyChannelLink": { + "description": "Label for copy channel link button on action sheet." + }, + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "@actionSheetOptionCopyTopicLink": { + "description": "Label for copy topic link button in action sheet." + }, + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "@actionSheetOptionSeeWhoReacted": { + "description": "Label for the 'See who reacted' button in the message action sheet." + }, + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "@actionSheetOptionSubscribe": { + "description": "Label in the channel action sheet for subscribing to the channel." + }, + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "@actionSheetOptionUnsubscribe": { + "description": "Label in the channel action sheet for unsubscribing from the channel." + }, + "@actionSheetOptionViewReadReceipts": { + "description": "Label for the 'View read receipts' button in the message action sheet." + }, + "@actionSheetReadReceipts": { + "description": "Title for the \"Read receipts\" bottom sheet." + }, + "@actionSheetReadReceiptsErrorReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when loading read receipts failed." + }, + "@actionSheetReadReceiptsReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when one or more people have read the message.", + "placeholders": { + "count": { + "example": "1", + "type": "int" + } + } + }, + "@actionSheetReadReceiptsZeroReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when no one has read the message." + }, + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "example": "#channel name > topic name", + "type": "String" + } + } + }, + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "example": "channel name", + "type": "String" + } + } + }, + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "example": "general chat", + "type": "String" + } + } + }, + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "example": "1234", + "type": "int" + } + } + }, + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "example": "user@example.com", + "type": "String" + }, + "server": { + "example": "https://example.com", + "type": "String" + } + } + }, + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "error": { + "example": "Invalid format", + "type": "String" + }, + "serverUrl": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "@errorContentNotInsertedTitle": { + "description": "Title for error dialog when an attempt to insert rich content failed." + }, + "@errorContentToInsertIsEmpty": { + "description": "Error message when the rich content to be inserted is empty or cannot be accessed." + }, + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "listMessage": { + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB", + "type": "String" + }, + "maxFileUploadSizeMib": { + "example": "15", + "type": "int" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "error": { + "example": "Unexpected null value", + "type": "String" + }, + "event": { + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')", + "type": "String" + }, + "serverUrl": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + } + }, + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "details": { + "example": "type 'Null' is not a subtype of type 'String' in type cast", + "type": "String" + }, + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "example": "500", + "type": "int" + } + } + }, + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "example": "Invalid format", + "type": "String" + } + } + }, + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "minSupportedZulipVersion": { + "example": "4.0", + "type": "String" + }, + "url": { + "example": "http://chat.example.com/", + "type": "String" + }, + "zulipVersion": { + "example": "3.2", + "type": "String" + } + } + }, + "@errorSharingAccountNotLoggedIn": { + "description": "Error title when sharing content received from other apps fails, when there is no account logged in" + }, + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "@errorSharingTitle": { + "description": "Error title when sharing content received from other apps fails" + }, + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "example": "foo.txt", + "type": "String" + }, + "size": { + "example": "20.2", + "type": "String" + } + } + }, + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "@loginPageTitle": { + "description": "Title for login page." + }, + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "@noStatusText": { + "description": "The text part of the status button sub-label in self-user profile page when status text is not set." + }, + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "numOthers": { + "example": "4", + "type": "int" + }, + "senderFullName": { + "example": "Alice", + "type": "String" + } + } + }, + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "@pollVoterNames": { + "description": "The list of people who voted for a poll option, wrapped in parentheses.", + "placeholders": { + "voterNames": { + "example": "Alice, Bob, Chad", + "type": "String" + } + } + }, + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "@reactionChipLabel": { + "description": "Text describing a reaction chip, with the emoji name and a list or number of votes. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "votes": { + "example": "You, Chris, Greg", + "type": "String" + } + } + }, + "@reactionChipVotesYouAndOthers": { + "description": "The number of votes on a reaction chip, where the self-user and at least one other user has voted. (An accessibility label for assistive technology.)", + "placeholders": { + "otherUsersCount": { + "example": "4", + "type": "int" + } + } + }, + "@reactionChipsLabel": { + "description": "Text identifying the container of reaction chips on a message. (An accessibility label for assistive technology.)" + }, + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." + }, + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "@seeWhoReactedSheetEmojiNameWithVoteCount": { + "description": "In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetHeaderLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.)", + "placeholders": { + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetNoReactions": { + "description": "Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened)." + }, + "@seeWhoReactedSheetUserListLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "@setStatusPageTitle": { + "description": "Title for the 'Set status' page." + }, + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "@sharePageTitle": { + "description": "Title for the page about sharing content received from other apps." + }, + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "example": "Google", + "type": "String" + } + } + }, + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "@statusButtonLabelStatusSet": { + "description": "The status button label in self-user profile page when status is set." + }, + "@statusButtonLabelStatusUnset": { + "description": "The status button label in self-user profile page when status is not set." + }, + "@statusClearButtonLabel": { + "description": "Label for the button that clears the user status, in 'Set status' page." + }, + "@statusSaveButtonLabel": { + "description": "Label for the button that saves the user status, in 'Set status' page." + }, + "@statusTextHint": { + "description": "Hint text for the status text input field in 'Set status' page." + }, + "@subscribeFailedTitle": { + "description": "Error title when subscribing to a channel failed." + }, + "@successChannelLinkCopied": { + "description": "Message when link of a channel was copied to the user's system clipboard." + }, + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "@successTopicLinkCopied": { + "description": "Message when link of a topic was copied to the user's system clipboard." + }, + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@today": { + "description": "Term to use to reference the current day." + }, + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + }, + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "otherTypist": { + "example": "Bob", + "type": "String" + }, + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "@unsubscribeConfirmationDialogConfirmButton": { + "description": "Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel." + }, + "@unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": { + "description": "Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe." + }, + "@unsubscribeConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for unsubscribing from a channel.", + "placeholders": { + "channelName": { + "example": "mobile", + "type": "String" + } + } + }, + "@unsubscribeFailedTitle": { + "description": "Error title when unsubscribing from a channel failed." + }, + "@updateStatusErrorTitle": { + "description": "Error title when updating user status failed." + }, + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "@userActiveDate": { + "description": "Indicates the date when a user was last active on Zulip (who is currently offline).\n\nThe date might be day and month if recent, or day, month, and year if less recent.", + "placeholders": { + "date": { + "example": "Aug 1, 2024", + "type": "String" + } + } + }, + "@userActiveDaysAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "days": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveHoursAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "hours": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveMinutesAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "minutes": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveNow": { + "description": "Indicates a user is currently active on Zulip (not idle or offline)" + }, + "@userActiveYesterday": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userIdle": { + "description": "Indicates a user is currently idle on Zulip (not active, but not offline)" + }, + "@userNotActiveInYear": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "@userStatusAtTheOffice": { + "description": "A suggested user status text, 'At the office'." + }, + "@userStatusBusy": { + "description": "A suggested user status text, 'Busy'." + }, + "@userStatusCommuting": { + "description": "A suggested user status text, 'Commuting'." + }, + "@userStatusInAMeeting": { + "description": "A suggested user status text, 'In a meeting'." + }, + "@userStatusOutSick": { + "description": "A suggested user status text, 'Out sick'." + }, + "@userStatusVacationing": { + "description": "A suggested user status text, 'Vacationing'." + }, + "@userStatusWorkingRemotely": { + "description": "A suggested user status text, 'Working remotely'." + }, + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "aboutPageAppVersion": "Версія додатку", + "aboutPageOpenSourceLicenses": "Ліцензії з відкритим кодом", + "aboutPageTapToView": "Натисніть, щоб переглянути", + "aboutPageTitle": "Про Zulip", + "actionSheetOptionChannelFeed": "Стрічка каналу", + "actionSheetOptionCopyChannelLink": "Копіювати посилання на канал", + "actionSheetOptionCopyMessageLink": "Копіювати посилання на повідомлення", + "actionSheetOptionCopyMessageText": "Копіювати текст повідомлення", + "actionSheetOptionCopyTopicLink": "Копіювати посилання на тему", + "actionSheetOptionEditMessage": "Редагувати повідомлення", + "actionSheetOptionFollowTopic": "Підписатися на тему", + "actionSheetOptionHideMutedMessage": "Сховати заглушене повідомлення", + "actionSheetOptionListOfTopics": "Список тем", + "actionSheetOptionMarkAsUnread": "Позначити як непрочитане звідси", + "actionSheetOptionMarkChannelAsRead": "Позначити канал як прочитаний", + "actionSheetOptionMarkTopicAsRead": "Позначити тему як прочитану", + "actionSheetOptionMuteTopic": "Заглушити тему", + "actionSheetOptionQuoteMessage": "Цитувати повідомлення", + "actionSheetOptionResolveTopic": "Позначити як вирішене", + "actionSheetOptionSeeWhoReacted": "Дивіться, хто відреагував", + "actionSheetOptionShare": "Поширити", + "actionSheetOptionStarMessage": "Вибрати повідомлення", + "actionSheetOptionSubscribe": "Підписатися", + "actionSheetOptionUnfollowTopic": "Відписатися від теми", + "actionSheetOptionUnmuteTopic": "Увімкнути тему", + "actionSheetOptionUnresolveTopic": "Позначити як невирішене", + "actionSheetOptionUnstarMessage": "Зняти позначку зірки з повідомлення", + "actionSheetOptionUnsubscribe": "Скасувати підписку", + "actionSheetOptionViewReadReceipts": "Переглянути сповіщення про прочитання", + "actionSheetReadReceipts": "Квитанції про прочитання", + "actionSheetReadReceiptsErrorReadCount": "Не вдалося завантажити сповіщення про прочитання.", + "actionSheetReadReceiptsReadCount": "{count, plural, =1{Це повідомлення було прочитано {count} особою:} other{Це повідомлення було прочитано {count} людьми:}}", + "actionSheetReadReceiptsZeroReadCount": "Ніхто ще не прочитав цього повідомлення.", + "appVersionUnknownPlaceholder": "(…)", + "channelFeedButtonTooltip": "Стрічка каналу", + "channelsEmptyPlaceholder": "Ви ще не підписані на жодний канал.", + "channelsPageTitle": "Канали", + "chooseAccountButtonAddAnAccount": "Додати обліковий запис", + "chooseAccountPageLogOutButton": "Вийти", + "chooseAccountPageTitle": "Обрати обліковий запис", + "combinedFeedPageTitle": "Об'єднана стрічка", + "composeBoxAttachFilesTooltip": "Прикріпити файли", + "composeBoxAttachFromCameraTooltip": "Зробити фото", + "composeBoxAttachMediaTooltip": "Додати зображення або відео", + "composeBoxBannerButtonCancel": "Відміна", + "composeBoxBannerButtonSave": "Зберегти", + "composeBoxBannerLabelEditMessage": "Редагування повідомлення", + "composeBoxChannelContentHint": "Надіслати повідомлення {destination}", + "composeBoxDmContentHint": "Повідомлення @{user}", + "composeBoxEnterTopicOrSkipHintText": "Вкажіть тему (або залиште “{defaultTopicName}”)", + "composeBoxGenericContentHint": "Ввести повідомлення", + "composeBoxGroupDmContentHint": "Написати групі", + "composeBoxLoadingMessage": "(завантаження повідомлення {messageId})", + "composeBoxSelfDmContentHint": "Занотувати щось", + "composeBoxSendTooltip": "Надіслати", + "composeBoxTopicHintText": "Тема", + "composeBoxUploadingFilename": "Завантаження {filename}…", + "contentValidationErrorEmpty": "Вам нема чого надсилати!", + "contentValidationErrorQuoteAndReplyInProgress": "Будь ласка, дочекайтеся завершення цитування.", + "contentValidationErrorTooLong": "Довжина повідомлення не повинна перевищувати 10000 символів.", + "contentValidationErrorUploadInProgress": "Дочекайтеся завершення завантаження.", + "dialogCancel": "Відміна", + "dialogClose": "Закрити", + "dialogContinue": "Продовжити", + "discardDraftConfirmationDialogConfirmButton": "Скинути", + "discardDraftConfirmationDialogTitle": "Відмовитися від написаного повідомлення?", + "discardDraftForEditConfirmationDialogMessage": "При редагуванні повідомлення, текст з поля для редагування видаляється.", + "discardDraftForOutboxConfirmationDialogMessage": "При відновленні невідправленого повідомлення, вміст поля редагування очищається.", + "dmsWithOthersPageTitle": "Особисті повідомлення з {others}", + "dmsWithYourselfPageTitle": "Особисті повідомлення із собою", + "editAlreadyInProgressMessage": "Редагування уже виконується. Дочекайтеся його завершення.", + "editAlreadyInProgressTitle": "Неможливо редагувати повідомлення", + "emojiPickerSearchEmoji": "Пошук емодзі", + "emojiReactionsMore": "більше", + "emptyMessageList": "Тут немає повідомлень.", + "emptyMessageListSearch": "Немає результатів пошуку.", + "errorAccountLoggedIn": "Обліковий запис {email} на {server} уже є у вашому списку облікових записів.", + "errorAccountLoggedInTitle": "В обліковий запис уже ввійшли", + "errorBannerCannotPostInChannelLabel": "Ви не маєте дозволу на публікацію в цьому каналі.", + "errorBannerDeactivatedDmLabel": "Ви не можете надсилати повідомлення деактивованим користувачам.", + "errorConnectingToServerDetails": "Помилка підключення до Zulip на {serverUrl}. Буде повторена спроба:\n\n{error}", + "errorConnectingToServerShort": "Помилка підключення до Zulip. Повторна спроба…", + "errorContentNotInsertedTitle": "Вміст не вставлено", + "errorContentToInsertIsEmpty": "Файл, який потрібно вставити, порожній або до нього немає доступу.", + "errorCopyingFailed": "Помилка копіювання", + "errorCouldNotConnectTitle": "Не вдалося підключитися", + "errorCouldNotEditMessageTitle": "Не вдалося редагувати повідомлення", + "errorCouldNotFetchMessageSource": "Не вдалося отримати джерело повідомлення.", + "errorCouldNotOpenLink": "Не вдалося відкрити посилання: {url}", + "errorCouldNotOpenLinkTitle": "Неможливо відкрити посилання", + "errorCouldNotShowUserProfile": "Не вдалося показати профіль користувача.", + "errorDialogContinue": "ОК", + "errorDialogLearnMore": "Дізнайтися більше", + "errorDialogTitle": "Помилка", + "errorFailedToUploadFileTitle": "Не вдалося завантажити файл: {filename}", + "errorFilesTooLarge": "{num, plural, =1{Файл} other{{num} файли}} перевищують ліміт сервера в {maxFileUploadSizeMib} MiB і не будуть завантажені:\n\n{listMessage}", + "errorFilesTooLargeTitle": "{num, plural, =1{Файл} other{Файли}} занадто великий", + "errorFollowTopicFailed": "Не вдалося підписатися на тему", + "errorHandlingEventDetails": "Помилка обробки події Zulip із {serverUrl}; буде повторювати спробу.\n\nПомилка: {error}\n\nПодія: {event}", + "errorHandlingEventTitle": "Помилка обробки події Zulip. Повторна спроба підключення…", + "errorInvalidApiKeyMessage": "Ваш обліковий запис на {url} не вдалося автентифікувати. Спробуйте увійти ще раз або скористайтеся іншим обліковим записом.", + "errorInvalidResponse": "Сервер надіслав недійсну відповідь.", + "errorLoginCouldNotConnect": "Не вдалося підключитися до сервера:\n{url}", + "errorLoginFailedTitle": "Помилка входу", + "errorLoginInvalidInputTitle": "Невірний вхід", + "errorMalformedResponse": "Сервер дав неправильну відповідь; Статус HTTP {httpStatus}", + "errorMalformedResponseWithCause": "Сервер дав неправильну відповідь; Статус HTTP {httpStatus}; {details}", + "errorMarkAsReadFailedTitle": "Не вдалося позначити як прочитане", + "errorMarkAsUnreadFailedTitle": "Не вдалося позначити як непрочитане", + "errorMessageDoesNotSeemToExist": "Здається, цього повідомлення не існує.", + "errorMessageEditNotSaved": "Повідомлення не збережено", + "errorMessageNotSent": "Повідомлення не надіслано", + "errorMuteTopicFailed": "Не вдалося заглушити тему", + "errorNetworkRequestFailed": "Помилка запиту мережі", + "errorNotificationOpenAccountNotFound": "Обліковий запис, звʼязаний з цим сповіщенням, не знайдений.", + "errorNotificationOpenTitle": "Не вдалося відкрити сповіщення", + "errorQuotationFailed": "Помилка цитування", + "errorReactionAddingFailedTitle": "Не вдалося додати реакцію", + "errorReactionRemovingFailedTitle": "Не вдалося видалити реакцію", + "errorRequestFailed": "Помилка мережевого запиту: статус HTTP {httpStatus}", + "errorResolveTopicFailedTitle": "Не вдалося позначити тему як вирішену", + "errorServerMessage": "Сервер сказав:\n\n{message}", + "errorServerVersionUnsupportedMessage": "{url} використовує Zulip Server {zulipVersion}, який не підтримується. Мінімальною підтримуваною версією є Zulip Server {minSupportedZulipVersion}.", + "errorSharingAccountNotLoggedIn": "Немає облікового запису, в який ви ввійшли. Будь ласка, увійдіть в обліковий запис і спробуйте ще раз..", + "errorSharingFailed": "Поширення не вдалося", + "errorSharingTitle": "Не вдалося поділитися контентом", + "errorStarMessageFailedTitle": "Не вдалося позначити повідомлення зіркою", + "errorUnfollowTopicFailed": "Не вдалося відписатися від теми", + "errorUnmuteTopicFailed": "Не вдалося увімкнути тему", + "errorUnresolveTopicFailedTitle": "Не вдалося позначити тему як невирішену", + "errorUnstarMessageFailedTitle": "Не вдалося зняти позначку зірки з повідомлення", + "errorVideoPlayerFailed": "Неможливо відтворити відео.", + "errorWebAuthOperationalError": "Сталася неочікувана помилка.", + "errorWebAuthOperationalErrorTitle": "Щось пішло не так", + "experimentalFeatureSettingsPageTitle": "Експериментальні функції", + "experimentalFeatureSettingsWarning": "Ці опції вмикають функції, які ще розробляються та не готові. Вони можуть не працювати та викликати проблеми в інших місцях додатку.\n\nМетою цих налаштувань є експериментування людьми, що працюють над розробкою Zulip.", + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "inboxEmptyPlaceholder": "Немає непрочитаних вхідних повідомлень. Використовуйте кнопки знизу для перегляду обʼєднаної стрічки або списку каналів.", + "inboxPageTitle": "Вхідні", + "initialAnchorSettingDescription": "Можна відкривати стрічку повідомлень на першому непрочитаному повідомленні або на найновішому.", + "initialAnchorSettingFirstUnreadAlways": "Перше непрочитане повідомлення", + "initialAnchorSettingFirstUnreadConversations": "Перше непрочитане повідомлення при перегляді бесід, найновіше у інших місцях", + "initialAnchorSettingNewestAlways": "Найновіше повідомлення", + "initialAnchorSettingTitle": "Де відкривати стрічку повідомлень", + "invisibleMode": "Невидимий режим", + "lightboxCopyLinkTooltip": "Копіювати посилання", + "lightboxVideoCurrentPosition": "Поточна позиція", + "lightboxVideoDuration": "Довжина відео", + "logOutConfirmationDialogConfirmButton": "Вийти", + "logOutConfirmationDialogMessage": "Щоб використовувати цей обліковий запис у майбутньому, вам доведеться повторно ввести його дані та URL-адресу вашої організації.", + "logOutConfirmationDialogTitle": "Вийти?", + "loginAddAnAccountPageTitle": "Додати обліковий запис", + "loginEmailLabel": "Адреса електронної пошти", + "loginErrorMissingEmail": "Будь ласка, введіть свою електронну адресу.", + "loginErrorMissingPassword": "Будь ласка, введіть свій пароль.", + "loginErrorMissingUsername": "Введіть своє ім'я користувача.", + "loginFormSubmitLabel": "Увійти", + "loginHidePassword": "Приховати пароль", + "loginMethodDivider": "АБО", + "loginPageTitle": "Увійти", + "loginPasswordLabel": "Пароль", + "loginServerUrlLabel": "URL-адреса вашого сервера Zulip", + "loginUsernameLabel": "Ім'я користувача", + "mainMenuMyProfile": "Мій профіль", + "manyPeopleTyping": "Кілька людей друкують…", + "markAllAsReadLabel": "Позначити всі повідомлення як прочитані", + "markAsReadComplete": "Позначено як прочитані {num, plural, =1{1 повідомлення} other{{num} повідомлення}}.", + "markAsReadInProgress": "Позначення повідомлень як прочитаних…", + "markAsUnreadComplete": "Позначено як непрочитані {num, plural, =1{1 повідомлення} other{{num} повідомлення}}.", + "markAsUnreadInProgress": "Позначення повідомлень як непрочитаних…", + "markReadOnScrollSettingAlways": "Завжди", + "markReadOnScrollSettingConversations": "Тільки при перегляді бесід", + "markReadOnScrollSettingConversationsDescription": "Повідомлення будуть автоматично помічатися як прочитані тільки при перегляді окремої теми або особистої бесіди.", + "markReadOnScrollSettingDescription": "При прокручуванні повідомлень автоматично відмічати їх як прочитані?", + "markReadOnScrollSettingNever": "Ніколи", + "markReadOnScrollSettingTitle": "Відмічати повідомлення як прочитані при прокручуванні", + "mentionsPageTitle": "Згадки", + "messageIsEditedLabel": "РЕДАГОВАНО", + "messageIsMovedLabel": "ПЕРЕМІЩЕНО", + "messageListGroupYouAndOthers": "Ви та {others}", + "messageListGroupYouWithYourself": "Повідомлення з собою", + "messageNotSentLabel": "ПОВІДОМЛЕННЯ НЕ ВІДПРАВЛЕНО", + "mutedUser": "Заглушений користувач", + "newDmFabButtonLabel": "Нове особисте повідомлення", + "newDmSheetComposeButtonLabel": "Написати", + "newDmSheetNoUsersFound": "Користувачі не знайдені", + "newDmSheetScreenTitle": "Нове особисте повідомлення", + "newDmSheetSearchHintEmpty": "Додати користувачів", + "newDmSheetSearchHintSomeSelected": "Додати ще…", + "noEarlierMessages": "Немає попередніх повідомлень", + "noStatusText": "Немає тексту статусу", + "notifGroupDmConversationLabel": "{senderFullName} вам і {numOthers, plural, =1{1 іншому} other{{numOthers} іншим}}", + "notifSelfUser": "Ви", + "onePersonTyping": "{typist} друкує…", + "openLinksWithInAppBrowser": "Відкривати посилання за допомогою браузера додатку", + "permissionsDeniedCameraAccess": "Щоб завантажити зображення, надайте Zulip додаткові дозволи в налаштуваннях.", + "permissionsDeniedReadExternalStorage": "Щоб завантажувати файли, надайте Zulip додаткові дозволи в налаштуваннях.", + "permissionsNeededOpenSettings": "Відкрити налаштування", + "permissionsNeededTitle": "Потрібні дозволи", + "pinnedSubscriptionsLabel": "Закріплені", + "pollVoterNames": "({voterNames})", + "pollWidgetOptionsMissing": "У цьому опитуванні ще немає варіантів.", + "pollWidgetQuestionMissing": "Немає питання.", + "preparingEditMessageContentInput": "Підготовка…", + "profileButtonSendDirectMessage": "Надіслати особисте повідомлення", + "reactedEmojiSelfUser": "Ви", + "reactionChipLabel": "{emojiName}: {votes}", + "reactionChipVotesYouAndOthers": "{otherUsersCount, plural, =1{Ви та ще 1 особа} other{Ви і {otherUsersCount} інші}}", + "reactionChipsLabel": "Реакції", + "recentDmConversationsEmptyPlaceholder": "У вас поки що немає особистих повідомлень! Чому б не розпочати бесіду?", + "recentDmConversationsPageTitle": "Особисті повідомлення", + "recentDmConversationsSectionHeader": "Особисті повідомлення", + "revealButtonLabel": "Показати повідомлення", + "savingMessageEditFailedLabel": "ПРАВКИ НЕ ЗБЕРЕЖЕНІ", + "savingMessageEditLabel": "ЗБЕРЕЖЕННЯ ПРАВОК…", + "scrollToBottomTooltip": "Прокрутити вниз", + "searchMessagesClearButtonTooltip": "Очистити", + "searchMessagesHintText": "Пошук", + "searchMessagesPageTitle": "Пошук", + "seeWhoReactedSheetEmojiNameWithVoteCount": "{emojiName}: {num, plural, =1{1 голосу} other{{num} голоси}}", + "seeWhoReactedSheetHeaderLabel": "Реакції емодзі (загалом {num})", + "seeWhoReactedSheetNoReactions": "На це повідомлення немає реакцій.", + "seeWhoReactedSheetUserListLabel": "Голоси за {emojiName} ({num})", + "serverUrlValidationErrorEmpty": "Будь ласка, введіть URL.", + "serverUrlValidationErrorInvalidUrl": "Введіть дійсну URL-адресу.", + "serverUrlValidationErrorNoUseEmail": "Введіть URL-адресу сервера, а не свою електронну адресу.", + "serverUrlValidationErrorUnsupportedScheme": "URL-адреса сервера має починатися з http:// або https://.", + "setStatusPageTitle": "Встановити статус", + "settingsPageTitle": "Налаштування", + "sharePageTitle": "Поділитися", + "signInWithFoo": "Увійти з {method}", + "snackBarDetails": "Деталі", + "spoilerDefaultHeaderText": "Спойлер", + "starredMessagesPageTitle": "Вибрані повідомлення", + "statusButtonLabelStatusSet": "Статус", + "statusButtonLabelStatusUnset": "Встановити статус", + "statusClearButtonLabel": "Очистити", + "statusSaveButtonLabel": "Зберегти", + "statusTextHint": "Ваш статус", + "subscribeFailedTitle": "Не вдалося підписатися", + "successChannelLinkCopied": "Посилання на канал скопійовано", + "successLinkCopied": "Посилання скопійовано", + "successMessageLinkCopied": "Посилання на повідомлення скопійовано", + "successMessageTextCopied": "Текст повідомлення скопійовано", + "successTopicLinkCopied": "Посилання на тему скопійовано", + "switchAccountButton": "Змінити обліковий запис", + "themeSettingDark": "Темна", + "themeSettingLight": "Світла", + "themeSettingSystem": "Системна", + "themeSettingTitle": "ТЕМА", + "today": "Сьогодні", + "topicValidationErrorMandatoryButEmpty": "Теми обовʼязкові в цій організації.", + "topicValidationErrorTooLong": "Довжина теми не повинна перевищувати 60 символів.", + "topicsButtonTooltip": "Теми", + "tryAnotherAccountButton": "Спробуйте інший обліковий запис", + "tryAnotherAccountMessage": "Ваш обліковий запис на {url} завантажується деякий час.", + "turnOffInvisibleModeErrorTitle": "Помилка вимкнення режиму невидимості. Спробуйте ще раз.", + "turnOnInvisibleModeErrorTitle": "Помилка ввімкнення режиму невидимості. Спробуйте ще раз.", + "twoPeopleTyping": "{typist} і {otherTypist} друкують…", + "unknownChannelName": "(невідомий канал)", + "unknownUserName": "(невідомий користувач)", + "unpinnedSubscriptionsLabel": "Відкріплені", + "unsubscribeConfirmationDialogConfirmButton": "Скасувати підписку", + "unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": "Після того, як ви залишите цей канал, ви, можливо, не зможете приєднатися знову.", + "unsubscribeConfirmationDialogTitle": "Відписатися від {channelName}?", + "unsubscribeFailedTitle": "Не вдалося скасувати підписку", + "updateStatusErrorTitle": "Помилка оновлення статусу користувача. Спробуйте ще раз.", + "upgradeWelcomeDialogDismiss": "Ходімо", + "upgradeWelcomeDialogLinkText": "Ознайомтесь з анонсом у блозі!", + "upgradeWelcomeDialogMessage": "Ви знайдете звичні можливості у більш швидкому і легкому додатку.", + "upgradeWelcomeDialogTitle": "Ласкаво просимо у новий додаток Zulip!", + "userActiveDate": "Активний {date}", + "userActiveDaysAgo": "Активний {days, plural, =1{1 день} other{{days} дні}} тому", + "userActiveHoursAgo": "Активний {hours, plural, =1{1 година} other{{hours} години}} тому", + "userActiveMinutesAgo": "Активний {minutes, plural, =1{1 хвилина} other{{minutes} хвилин}} тому", + "userActiveNow": "Активний зараз", + "userActiveYesterday": "Активний учора", + "userIdle": "Холостий хід", + "userNotActiveInYear": "Неактивний протягом останнього року", + "userRoleAdministrator": "Адміністратор", + "userRoleGuest": "Гість", + "userRoleMember": "Учасник", + "userRoleModerator": "Модератор", + "userRoleOwner": "Власник", + "userRoleUnknown": "Невідомо", + "userStatusAtTheOffice": "В офісі", + "userStatusBusy": "Зайнятий", + "userStatusCommuting": "Поїздки на роботу", + "userStatusInAMeeting": "На зустрічі", + "userStatusOutSick": "Хворий", + "userStatusVacationing": "Відпустка", + "userStatusWorkingRemotely": "Працюємо віддалено", + "wildcardMentionAll": "усі", + "wildcardMentionAllDmDescription": "Повідомити одержувачів", + "wildcardMentionChannel": "канал", + "wildcardMentionChannelDescription": "Повідомити канал", + "wildcardMentionEveryone": "усі", + "wildcardMentionStream": "канал", + "wildcardMentionStreamDescription": "Повідомити канал", + "wildcardMentionTopic": "тема", + "wildcardMentionTopicDescription": "Повідомити канал", + "yesterday": "Учора", + "zulipAppTitle": "Zulip" +} diff --git a/assets/l10n/app_zh.arb b/assets/l10n/app_zh.arb new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/assets/l10n/app_zh.arb @@ -0,0 +1 @@ +{} diff --git a/assets/l10n/app_zh_Hans_CN.arb b/assets/l10n/app_zh_Hans_CN.arb new file mode 100644 index 0000000000..52ce2b70c4 --- /dev/null +++ b/assets/l10n/app_zh_Hans_CN.arb @@ -0,0 +1,1512 @@ +{ + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "@actionSheetOptionCopyChannelLink": { + "description": "Label for copy channel link button on action sheet." + }, + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "@actionSheetOptionCopyTopicLink": { + "description": "Label for copy topic link button in action sheet." + }, + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "@actionSheetOptionSeeWhoReacted": { + "description": "Label for the 'See who reacted' button in the message action sheet." + }, + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "@actionSheetOptionSubscribe": { + "description": "Label in the channel action sheet for subscribing to the channel." + }, + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "@actionSheetOptionUnsubscribe": { + "description": "Label in the channel action sheet for unsubscribing from the channel." + }, + "@actionSheetOptionViewReadReceipts": { + "description": "Label for the 'View read receipts' button in the message action sheet." + }, + "@actionSheetReadReceipts": { + "description": "Title for the \"Read receipts\" bottom sheet." + }, + "@actionSheetReadReceiptsErrorReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when loading read receipts failed." + }, + "@actionSheetReadReceiptsReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when one or more people have read the message.", + "placeholders": { + "count": { + "example": "1", + "type": "int" + } + } + }, + "@actionSheetReadReceiptsZeroReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when no one has read the message." + }, + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "example": "#channel name > topic name", + "type": "String" + } + } + }, + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "example": "channel name", + "type": "String" + } + } + }, + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "example": "general chat", + "type": "String" + } + } + }, + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "example": "1234", + "type": "int" + } + } + }, + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "example": "user@example.com", + "type": "String" + }, + "server": { + "example": "https://example.com", + "type": "String" + } + } + }, + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "error": { + "example": "Invalid format", + "type": "String" + }, + "serverUrl": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "@errorContentNotInsertedTitle": { + "description": "Title for error dialog when an attempt to insert rich content failed." + }, + "@errorContentToInsertIsEmpty": { + "description": "Error message when the rich content to be inserted is empty or cannot be accessed." + }, + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "listMessage": { + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB", + "type": "String" + }, + "maxFileUploadSizeMib": { + "example": "15", + "type": "int" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "error": { + "example": "Unexpected null value", + "type": "String" + }, + "event": { + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')", + "type": "String" + }, + "serverUrl": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + } + }, + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "details": { + "example": "type 'Null' is not a subtype of type 'String' in type cast", + "type": "String" + }, + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "example": "500", + "type": "int" + } + } + }, + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "example": "Invalid format", + "type": "String" + } + } + }, + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "minSupportedZulipVersion": { + "example": "4.0", + "type": "String" + }, + "url": { + "example": "http://chat.example.com/", + "type": "String" + }, + "zulipVersion": { + "example": "3.2", + "type": "String" + } + } + }, + "@errorSharingAccountNotLoggedIn": { + "description": "Error title when sharing content received from other apps fails, when there is no account logged in" + }, + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "@errorSharingTitle": { + "description": "Error title when sharing content received from other apps fails" + }, + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "example": "foo.txt", + "type": "String" + }, + "size": { + "example": "20.2", + "type": "String" + } + } + }, + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "@loginPageTitle": { + "description": "Title for login page." + }, + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "@noStatusText": { + "description": "The text part of the status button sub-label in self-user profile page when status text is not set." + }, + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "numOthers": { + "example": "4", + "type": "int" + }, + "senderFullName": { + "example": "Alice", + "type": "String" + } + } + }, + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "@pollVoterNames": { + "description": "The list of people who voted for a poll option, wrapped in parentheses.", + "placeholders": { + "voterNames": { + "example": "Alice, Bob, Chad", + "type": "String" + } + } + }, + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "@reactionChipLabel": { + "description": "Text describing a reaction chip, with the emoji name and a list or number of votes. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "votes": { + "example": "You, Chris, Greg", + "type": "String" + } + } + }, + "@reactionChipVotesYouAndOthers": { + "description": "The number of votes on a reaction chip, where the self-user and at least one other user has voted. (An accessibility label for assistive technology.)", + "placeholders": { + "otherUsersCount": { + "example": "4", + "type": "int" + } + } + }, + "@reactionChipsLabel": { + "description": "Text identifying the container of reaction chips on a message. (An accessibility label for assistive technology.)" + }, + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." + }, + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "@seeWhoReactedSheetEmojiNameWithVoteCount": { + "description": "In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetHeaderLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.)", + "placeholders": { + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetNoReactions": { + "description": "Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened)." + }, + "@seeWhoReactedSheetUserListLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "@setStatusPageTitle": { + "description": "Title for the 'Set status' page." + }, + "@settingsPageTitle": {}, + "@sharePageTitle": { + "description": "Title for the page about sharing content received from other apps." + }, + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "example": "Google", + "type": "String" + } + } + }, + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "@statusButtonLabelStatusSet": { + "description": "The status button label in self-user profile page when status is set." + }, + "@statusButtonLabelStatusUnset": { + "description": "The status button label in self-user profile page when status is not set." + }, + "@statusClearButtonLabel": { + "description": "Label for the button that clears the user status, in 'Set status' page." + }, + "@statusSaveButtonLabel": { + "description": "Label for the button that saves the user status, in 'Set status' page." + }, + "@statusTextHint": { + "description": "Hint text for the status text input field in 'Set status' page." + }, + "@subscribeFailedTitle": { + "description": "Error title when subscribing to a channel failed." + }, + "@successChannelLinkCopied": { + "description": "Message when link of a channel was copied to the user's system clipboard." + }, + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "@successTopicLinkCopied": { + "description": "Message when link of a topic was copied to the user's system clipboard." + }, + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@today": { + "description": "Term to use to reference the current day." + }, + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + }, + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "otherTypist": { + "example": "Bob", + "type": "String" + }, + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "@unsubscribeConfirmationDialogConfirmButton": { + "description": "Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel." + }, + "@unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": { + "description": "Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe." + }, + "@unsubscribeConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for unsubscribing from a channel.", + "placeholders": { + "channelName": { + "example": "mobile", + "type": "String" + } + } + }, + "@unsubscribeFailedTitle": { + "description": "Error title when unsubscribing from a channel failed." + }, + "@updateStatusErrorTitle": { + "description": "Error title when updating user status failed." + }, + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "@userActiveDate": { + "description": "Indicates the date when a user was last active on Zulip (who is currently offline).\n\nThe date might be day and month if recent, or day, month, and year if less recent.", + "placeholders": { + "date": { + "example": "Aug 1, 2024", + "type": "String" + } + } + }, + "@userActiveDaysAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "days": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveHoursAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "hours": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveMinutesAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "minutes": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveNow": { + "description": "Indicates a user is currently active on Zulip (not idle or offline)" + }, + "@userActiveYesterday": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userIdle": { + "description": "Indicates a user is currently idle on Zulip (not active, but not offline)" + }, + "@userNotActiveInYear": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "@userStatusAtTheOffice": { + "description": "A suggested user status text, 'At the office'." + }, + "@userStatusBusy": { + "description": "A suggested user status text, 'Busy'." + }, + "@userStatusCommuting": { + "description": "A suggested user status text, 'Commuting'." + }, + "@userStatusInAMeeting": { + "description": "A suggested user status text, 'In a meeting'." + }, + "@userStatusOutSick": { + "description": "A suggested user status text, 'Out sick'." + }, + "@userStatusVacationing": { + "description": "A suggested user status text, 'Vacationing'." + }, + "@userStatusWorkingRemotely": { + "description": "A suggested user status text, 'Working remotely'." + }, + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "aboutPageAppVersion": "应用程序版本", + "aboutPageOpenSourceLicenses": "开源许可", + "aboutPageTapToView": "查看更多", + "aboutPageTitle": "关于 Zulip", + "actionSheetOptionCopyChannelLink": "复制频道链接", + "actionSheetOptionCopyMessageLink": "复制消息链接", + "actionSheetOptionCopyMessageText": "复制消息文本", + "actionSheetOptionCopyTopicLink": "复制话题链接", + "actionSheetOptionEditMessage": "编辑消息", + "actionSheetOptionFollowTopic": "关注话题", + "actionSheetOptionHideMutedMessage": "再次隐藏静音消息", + "actionSheetOptionListOfTopics": "话题列表", + "actionSheetOptionMarkAsUnread": "从这里开始标为未读", + "actionSheetOptionMarkChannelAsRead": "标记频道为已读", + "actionSheetOptionMarkTopicAsRead": "将话题标为已读", + "actionSheetOptionMuteTopic": "静音话题", + "actionSheetOptionQuoteMessage": "引用消息", + "actionSheetOptionResolveTopic": "标记为已解决", + "actionSheetOptionSeeWhoReacted": "查看谁做出了表情符号回应", + "actionSheetOptionShare": "分享", + "actionSheetOptionStarMessage": "添加星标消息标记", + "actionSheetOptionSubscribe": "订阅", + "actionSheetOptionUnfollowTopic": "取消关注话题", + "actionSheetOptionUnmuteTopic": "取消静音话题", + "actionSheetOptionUnresolveTopic": "标记为未解决", + "actionSheetOptionUnstarMessage": "取消星标消息标记", + "actionSheetOptionUnsubscribe": "取消订阅", + "actionSheetOptionViewReadReceipts": "查看已读回执", + "actionSheetReadReceipts": "已读回执", + "actionSheetReadReceiptsErrorReadCount": "加载已读回执失败。", + "actionSheetReadReceiptsReadCount": "{count, plural, =1{此消息已被阅读,共有 {count} 人:} other{此消息已被阅读,共有 {count} 人:}}", + "actionSheetReadReceiptsZeroReadCount": "尚无人阅读此消息。", + "appVersionUnknownPlaceholder": "(…)", + "channelFeedButtonTooltip": "频道订阅", + "channelsEmptyPlaceholder": "您还没有订阅任何频道。", + "channelsPageTitle": "频道", + "chooseAccountButtonAddAnAccount": "添加一个账号", + "chooseAccountPageLogOutButton": "登出", + "chooseAccountPageTitle": "选择账号", + "combinedFeedPageTitle": "综合消息", + "composeBoxAttachFilesTooltip": "上传文件", + "composeBoxAttachFromCameraTooltip": "拍摄照片", + "composeBoxAttachMediaTooltip": "上传图片或视频", + "composeBoxBannerButtonCancel": "取消", + "composeBoxBannerButtonSave": "保存", + "composeBoxBannerLabelEditMessage": "编辑消息", + "composeBoxChannelContentHint": "发送消息到 {destination}", + "composeBoxDmContentHint": "发送私信给 @{user}", + "composeBoxEnterTopicOrSkipHintText": "输入话题(默认为“{defaultTopicName}”)", + "composeBoxGenericContentHint": "撰写消息", + "composeBoxGroupDmContentHint": "发送私信到群组", + "composeBoxLoadingMessage": "(加载消息 {messageId})", + "composeBoxSelfDmContentHint": "向自己撰写消息", + "composeBoxSendTooltip": "发送", + "composeBoxTopicHintText": "话题", + "composeBoxUploadingFilename": "正在上传 {filename}…", + "contentValidationErrorEmpty": "发送的消息不能为空!", + "contentValidationErrorQuoteAndReplyInProgress": "请等待引用消息完成。", + "contentValidationErrorTooLong": "消息的长度不能超过10000个字符。", + "contentValidationErrorUploadInProgress": "请等待上传完成。", + "dialogCancel": "取消", + "dialogClose": "关闭", + "dialogContinue": "继续", + "discardDraftConfirmationDialogConfirmButton": "清空", + "discardDraftConfirmationDialogTitle": "放弃您正在撰写的消息?", + "discardDraftForEditConfirmationDialogMessage": "当您编辑消息时,文本框中已有的内容将会被清空。", + "discardDraftForOutboxConfirmationDialogMessage": "当您恢复未能发送的消息时,文本框已有的内容将会被清空。", + "dmsWithOthersPageTitle": "与{others}的私信", + "dmsWithYourselfPageTitle": "与自己的私信", + "editAlreadyInProgressMessage": "已有正在被编辑的消息。请在其完成后重试。", + "editAlreadyInProgressTitle": "未能编辑消息", + "emojiPickerSearchEmoji": "搜索表情符号", + "emojiReactionsMore": "更多", + "emptyMessageList": "这里没有消息。", + "emptyMessageListSearch": "没有搜索结果。", + "errorAccountLoggedIn": "在 {server} 的账号 {email} 已经在您的账号列表了。", + "errorAccountLoggedInTitle": "已经登入该账号", + "errorBannerCannotPostInChannelLabel": "您没有足够的权限在此频道发送消息。", + "errorBannerDeactivatedDmLabel": "您不能向被停用的用户发送消息。", + "errorConnectingToServerDetails": "未能连接到在 {serverUrl} 的 Zulip 服务器。即将重连:\n\n{error}", + "errorConnectingToServerShort": "未能连接到 Zulip. 重试中…", + "errorContentNotInsertedTitle": "未插入内容", + "errorContentToInsertIsEmpty": "要插入的文件为空或无法访问。", + "errorCopyingFailed": "未能复制消息文本", + "errorCouldNotConnectTitle": "未能连接", + "errorCouldNotEditMessageTitle": "未能编辑消息", + "errorCouldNotFetchMessageSource": "未能获取原始消息。", + "errorCouldNotOpenLink": "未能打开此链接:{url}", + "errorCouldNotOpenLinkTitle": "未能打开链接", + "errorCouldNotShowUserProfile": "无法显示用户个人资料。", + "errorDialogContinue": "好的", + "errorDialogLearnMore": "更多信息", + "errorDialogTitle": "错误", + "errorFailedToUploadFileTitle": "未能上传文件:{filename}", + "errorFilesTooLarge": "{num, plural, other{{num} 个您上传的文件}}大小超过了该组织 {maxFileUploadSizeMib} MiB 的限制:\n\n{listMessage}", + "errorFilesTooLargeTitle": "{num, plural, =1{文件} other{文件}}太大", + "errorFollowTopicFailed": "未能关注话题", + "errorHandlingEventDetails": "处理来自 {serverUrl} 的 Zulip 事件时发生了一些问题。即将重连。\n\n错误:{error}\n\n事件:{event}", + "errorHandlingEventTitle": "处理 Zulip 事件时发生了一些问题。即将重连…", + "errorInvalidApiKeyMessage": "您在 {url} 的账号无法被登入。请重试或者使用另外的账号。", + "errorInvalidResponse": "服务器的回复不合法。", + "errorLoginCouldNotConnect": "未能连接到服务器:\n{url}", + "errorLoginFailedTitle": "未能登入", + "errorLoginInvalidInputTitle": "输入的信息不正确", + "errorMalformedResponse": "服务器的回复不合法;HTTP 状态码 {httpStatus}", + "errorMalformedResponseWithCause": "服务器的回复不合法;HTTP 状态码 {httpStatus}; {details}", + "errorMarkAsReadFailedTitle": "未能将消息标为已读", + "errorMarkAsUnreadFailedTitle": "未能将消息标为未读", + "errorMessageDoesNotSeemToExist": "找不到此消息。", + "errorMessageEditNotSaved": "未能保存消息编辑", + "errorMessageNotSent": "未能发送消息", + "errorMuteTopicFailed": "未能静音话题", + "errorNetworkRequestFailed": "网络请求失败", + "errorNotificationOpenAccountNotFound": "未能找到关联该消息提醒的账号。", + "errorNotificationOpenTitle": "未能打开消息提醒", + "errorQuotationFailed": "未能引用消息", + "errorReactionAddingFailedTitle": "未能添加表情符号", + "errorReactionRemovingFailedTitle": "未能移除表情符号", + "errorRequestFailed": "网络请求失败;HTTP 状态码 {httpStatus}", + "errorResolveTopicFailedTitle": "未能将话题标记为解决", + "errorServerMessage": "服务器:\n\n{message}", + "errorServerVersionUnsupportedMessage": "{url} 运行的 Zulip 服务器版本 {zulipVersion} 过低。该客户端只支持 {minSupportedZulipVersion} 及以后的服务器版本。", + "errorSharingAccountNotLoggedIn": "尚未登录任何账号。请登录账号后再次尝试。", + "errorSharingFailed": "分享失败", + "errorSharingTitle": "分享内容失败", + "errorStarMessageFailedTitle": "未能添加星标消息标记", + "errorUnfollowTopicFailed": "未能取消关注话题", + "errorUnmuteTopicFailed": "未能取消静音话题", + "errorUnresolveTopicFailedTitle": "未能将话题标记为未解决", + "errorUnstarMessageFailedTitle": "未能取消星标消息标记", + "errorVideoPlayerFailed": "未能播放视频。", + "errorWebAuthOperationalError": "发生了未知的错误。", + "errorWebAuthOperationalErrorTitle": "出现了一些问题", + "experimentalFeatureSettingsPageTitle": "实验功能", + "experimentalFeatureSettingsWarning": "以下选项能够启用开发中的功能。它们暂不完善,并可能造成其他的一些问题。\n\n这些选项的目的是为了帮助开发者进行实验。", + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "inboxEmptyPlaceholder": "您的收件箱中没有未读消息。您可以通过底部导航栏访问综合消息或者频道列表。", + "inboxPageTitle": "收件箱", + "initialAnchorSettingDescription": "您可以将消息的起始位置设置为第一条未读消息或者最新消息。", + "initialAnchorSettingFirstUnreadAlways": "第一条未读消息", + "initialAnchorSettingFirstUnreadConversations": "在单个话题或私信的第一条未读消息;在其他情况下的最新消息", + "initialAnchorSettingNewestAlways": "最新消息", + "initialAnchorSettingTitle": "设置消息起始位置于", + "invisibleMode": "隐身模式", + "lightboxCopyLinkTooltip": "复制链接", + "lightboxVideoCurrentPosition": "当前进度", + "lightboxVideoDuration": "视频时长", + "logOutConfirmationDialogConfirmButton": "登出", + "logOutConfirmationDialogMessage": "下次登入此账号时,您将需要重新输入组织网址和账号信息。", + "logOutConfirmationDialogTitle": "登出?", + "loginAddAnAccountPageTitle": "添加账号", + "loginEmailLabel": "电子邮箱地址", + "loginErrorMissingEmail": "请输入电子邮箱地址。", + "loginErrorMissingPassword": "请输入密码。", + "loginErrorMissingUsername": "请输入用户名。", + "loginFormSubmitLabel": "登入", + "loginHidePassword": "隐藏密码", + "loginMethodDivider": "或", + "loginPageTitle": "登入", + "loginPasswordLabel": "密码", + "loginServerUrlLabel": "Zulip 服务器网址", + "loginUsernameLabel": "用户名", + "mainMenuMyProfile": "个人资料", + "manyPeopleTyping": "多个用户正在输入…", + "markAllAsReadLabel": "将所有消息标为已读", + "markAsReadComplete": "已将 {num, plural, other{{num} 条消息}}标为已读。", + "markAsReadInProgress": "正在将消息标为已读…", + "markAsUnreadComplete": "已将 {num, plural, other{{num} 条消息}}标为未读。", + "markAsUnreadInProgress": "正在将消息标为未读…", + "markReadOnScrollSettingAlways": "总是", + "markReadOnScrollSettingConversations": "只在对话视图", + "markReadOnScrollSettingConversationsDescription": "只将在同一个话题或私聊中的消息自动标记为已读。", + "markReadOnScrollSettingDescription": "在滑动浏览消息时,是否自动将它们标记为已读?", + "markReadOnScrollSettingNever": "从不", + "markReadOnScrollSettingTitle": "滑动时将消息标为已读", + "mentionsPageTitle": "被提及消息", + "messageIsEditedLabel": "已编辑", + "messageIsMovedLabel": "已移动", + "messageListGroupYouAndOthers": "您和{others}", + "messageListGroupYouWithYourself": "与自己的私信", + "messageNotSentLabel": "消息未发送", + "mutedUser": "静音用户", + "newDmFabButtonLabel": "发起私信", + "newDmSheetComposeButtonLabel": "撰写消息", + "newDmSheetNoUsersFound": "没有用户", + "newDmSheetScreenTitle": "发起私信", + "newDmSheetSearchHintEmpty": "添加一个或多个用户", + "newDmSheetSearchHintSomeSelected": "添加更多用户…", + "noEarlierMessages": "没有更早的消息了", + "noStatusText": "无状态文字", + "notifGroupDmConversationLabel": "{senderFullName}向您和其他 {numOthers, plural, other{{numOthers} 个用户}}", + "notifSelfUser": "您", + "onePersonTyping": "{typist}正在输入…", + "openLinksWithInAppBrowser": "使用内置浏览器打开链接", + "permissionsDeniedCameraAccess": "上传图片前,请在设置中授予 Zulip 相应的权限。", + "permissionsDeniedReadExternalStorage": "上传文件前,请在设置中授予 Zulip 相应的权限。", + "permissionsNeededOpenSettings": "打开设置", + "permissionsNeededTitle": "需要额外权限", + "pinnedSubscriptionsLabel": "置顶", + "pollVoterNames": "({voterNames})", + "pollWidgetOptionsMissing": "该投票还没有任何选项。", + "pollWidgetQuestionMissing": "无问题。", + "preparingEditMessageContentInput": "准备编辑消息…", + "profileButtonSendDirectMessage": "发送私信", + "reactedEmojiSelfUser": "您", + "reactionChipLabel": "{emojiName}: {votes}", + "reactionChipVotesYouAndOthers": "{otherUsersCount, plural, =1 {你与其他 1 人} other {你与其他 {otherUsersCount} 人}}", + "reactionChipsLabel": "表情符号回应", + "recentDmConversationsEmptyPlaceholder": "您还没有任何私信消息!何不开启一个新对话?", + "recentDmConversationsPageTitle": "私信", + "recentDmConversationsSectionHeader": "私信", + "revealButtonLabel": "显示消息", + "savingMessageEditFailedLabel": "编辑失败", + "savingMessageEditLabel": "保存中…", + "scrollToBottomTooltip": "拖动到最底", + "searchMessagesClearButtonTooltip": "清除", + "searchMessagesHintText": "搜索", + "searchMessagesPageTitle": "搜索", + "seeWhoReactedSheetEmojiNameWithVoteCount": "{emojiName}:{num, plural, =1 {1 票} other {{num} 票}}", + "seeWhoReactedSheetHeaderLabel": "表情符号回应(共{num}个)", + "seeWhoReactedSheetNoReactions": "此消息尚无表情符号回应。", + "seeWhoReactedSheetUserListLabel": "{emojiName} 的投票数({num})", + "serverUrlValidationErrorEmpty": "请输入网址。", + "serverUrlValidationErrorInvalidUrl": "请输入正确的网址。", + "serverUrlValidationErrorNoUseEmail": "请输入服务器网址,而不是您的电子邮件。", + "serverUrlValidationErrorUnsupportedScheme": "服务器网址必须以 http:// 或 https:// 开头。", + "setStatusPageTitle": "设定状态", + "settingsPageTitle": "设置", + "sharePageTitle": "分享", + "signInWithFoo": "使用{method}登入", + "snackBarDetails": "详情", + "spoilerDefaultHeaderText": "剧透", + "starredMessagesPageTitle": "星标消息", + "statusButtonLabelStatusSet": "状态", + "statusButtonLabelStatusUnset": "设定状态", + "statusClearButtonLabel": "清除", + "statusSaveButtonLabel": "保存", + "statusTextHint": "您的状态", + "subscribeFailedTitle": "订阅失败", + "successChannelLinkCopied": "频道链接已复制", + "successLinkCopied": "已复制链接", + "successMessageLinkCopied": "已复制消息链接", + "successMessageTextCopied": "已复制消息文本", + "successTopicLinkCopied": "话题链接已复制", + "switchAccountButton": "切换账号", + "themeSettingDark": "暗色模式", + "themeSettingLight": "浅色模式", + "themeSettingSystem": "跟随系统", + "themeSettingTitle": "主题", + "today": "今天", + "topicValidationErrorMandatoryButEmpty": "话题在该组织为必填项。", + "topicValidationErrorTooLong": "话题长度不应该超过 60 个字符。", + "topicsButtonTooltip": "话题", + "tryAnotherAccountButton": "尝试另一个账号", + "tryAnotherAccountMessage": "您在 {url} 的账号加载时间过长。", + "turnOffInvisibleModeErrorTitle": "关闭隐身模式时发生错误。请再尝试一次。", + "turnOnInvisibleModeErrorTitle": "启用隐身模式时发生错误。请再尝试一次。", + "twoPeopleTyping": "{typist}和{otherTypist}正在输入…", + "unknownChannelName": "(未知频道)", + "unknownUserName": "(未知用户)", + "unpinnedSubscriptionsLabel": "未置顶", + "unsubscribeConfirmationDialogConfirmButton": "取消订阅", + "unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": "一旦退出该频道,您可能无法重新加入。", + "unsubscribeConfirmationDialogTitle": "确定取消订阅{channelName}么?", + "unsubscribeFailedTitle": "取消订阅失败", + "updateStatusErrorTitle": "更新用户状态时发生错误。请再试一次。", + "upgradeWelcomeDialogDismiss": "开始吧", + "upgradeWelcomeDialogLinkText": "来看看最新的公告博客吧!", + "upgradeWelcomeDialogMessage": "您将在更快、更流畅的版本中享受熟悉的体验。", + "upgradeWelcomeDialogTitle": "欢迎来到新的 Zulip 应用程序!", + "userActiveDate": "上次活跃于 {date}", + "userActiveDaysAgo": "上次活跃于 {days, plural, =1{1 天前} other{{days} 天前}}", + "userActiveHoursAgo": "上次活跃于 {hours, plural, =1{1 小时前} other{{hours} 小时前}}", + "userActiveMinutesAgo": "上次活跃于 {minutes, plural, =1{1 分钟前} other{{minutes} 分钟前}}", + "userActiveNow": "当前活跃", + "userActiveYesterday": "昨天活跃", + "userIdle": "空闲", + "userNotActiveInYear": "去年未活跃", + "userRoleAdministrator": "管理员", + "userRoleGuest": "访客", + "userRoleMember": "成员", + "userRoleModerator": "版主", + "userRoleOwner": "所有者", + "userRoleUnknown": "未知", + "userStatusAtTheOffice": "在办公室", + "userStatusBusy": "忙碌", + "userStatusCommuting": "通勤中", + "userStatusInAMeeting": "会议中", + "userStatusOutSick": "病假中", + "userStatusVacationing": "休假中", + "userStatusWorkingRemotely": "远程工作中", + "wildcardMentionAll": "所有人", + "wildcardMentionAllDmDescription": "通知收件人", + "wildcardMentionChannel": "频道", + "wildcardMentionChannelDescription": "通知频道", + "wildcardMentionEveryone": "所有人", + "wildcardMentionStream": "频道", + "wildcardMentionStreamDescription": "通知频道", + "wildcardMentionTopic": "话题", + "wildcardMentionTopicDescription": "通知话题", + "yesterday": "昨天", + "zulipAppTitle": "Zulip" +} diff --git a/assets/l10n/app_zh_Hant_TW.arb b/assets/l10n/app_zh_Hant_TW.arb new file mode 100644 index 0000000000..4c67a9bd94 --- /dev/null +++ b/assets/l10n/app_zh_Hant_TW.arb @@ -0,0 +1,1516 @@ +{ + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "@actionSheetOptionChannelFeed": { + "description": "Label for navigating to a channel's channel-feed page." + }, + "@actionSheetOptionCopyChannelLink": { + "description": "Label for copy channel link button on action sheet." + }, + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "@actionSheetOptionCopyTopicLink": { + "description": "Label for copy topic link button in action sheet." + }, + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "@actionSheetOptionSeeWhoReacted": { + "description": "Label for the 'See who reacted' button in the message action sheet." + }, + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "@actionSheetOptionSubscribe": { + "description": "Label in the channel action sheet for subscribing to the channel." + }, + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "@actionSheetOptionUnsubscribe": { + "description": "Label in the channel action sheet for unsubscribing from the channel." + }, + "@actionSheetOptionViewReadReceipts": { + "description": "Label for the 'View read receipts' button in the message action sheet." + }, + "@actionSheetReadReceipts": { + "description": "Title for the \"Read receipts\" bottom sheet." + }, + "@actionSheetReadReceiptsErrorReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when loading read receipts failed." + }, + "@actionSheetReadReceiptsReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when one or more people have read the message.", + "placeholders": { + "count": { + "example": "1", + "type": "int" + } + } + }, + "@actionSheetReadReceiptsZeroReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when no one has read the message." + }, + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "example": "#channel name > topic name", + "type": "String" + } + } + }, + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "example": "channel name", + "type": "String" + } + } + }, + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "example": "general chat", + "type": "String" + } + } + }, + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "example": "1234", + "type": "int" + } + } + }, + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "example": "user@example.com", + "type": "String" + }, + "server": { + "example": "https://example.com", + "type": "String" + } + } + }, + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "error": { + "example": "Invalid format", + "type": "String" + }, + "serverUrl": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "@errorContentNotInsertedTitle": { + "description": "Title for error dialog when an attempt to insert rich content failed." + }, + "@errorContentToInsertIsEmpty": { + "description": "Error message when the rich content to be inserted is empty or cannot be accessed." + }, + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "example": "file.txt", + "type": "String" + } + } + }, + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "listMessage": { + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB", + "type": "String" + }, + "maxFileUploadSizeMib": { + "example": "15", + "type": "int" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "error": { + "example": "Unexpected null value", + "type": "String" + }, + "event": { + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')", + "type": "String" + }, + "serverUrl": { + "example": "https://chat.example.com", + "type": "String" + } + } + }, + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + } + }, + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "example": "http://example.com/", + "type": "String" + } + } + }, + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "details": { + "example": "type 'Null' is not a subtype of type 'String' in type cast", + "type": "String" + }, + "httpStatus": { + "example": "200", + "type": "int" + } + } + }, + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "example": "500", + "type": "int" + } + } + }, + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "example": "Invalid format", + "type": "String" + } + } + }, + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "minSupportedZulipVersion": { + "example": "4.0", + "type": "String" + }, + "url": { + "example": "http://chat.example.com/", + "type": "String" + }, + "zulipVersion": { + "example": "3.2", + "type": "String" + } + } + }, + "@errorSharingAccountNotLoggedIn": { + "description": "Error title when sharing content received from other apps fails, when there is no account logged in" + }, + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "@errorSharingTitle": { + "description": "Error title when sharing content received from other apps fails" + }, + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "example": "foo.txt", + "type": "String" + }, + "size": { + "example": "20.2", + "type": "String" + } + } + }, + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "@loginPageTitle": { + "description": "Title for login page." + }, + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "example": "4", + "type": "int" + } + } + }, + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "example": "Alice, Bob", + "type": "String" + } + } + }, + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "@noStatusText": { + "description": "The text part of the status button sub-label in self-user profile page when status text is not set." + }, + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "numOthers": { + "example": "4", + "type": "int" + }, + "senderFullName": { + "example": "Alice", + "type": "String" + } + } + }, + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "@pollVoterNames": { + "description": "The list of people who voted for a poll option, wrapped in parentheses.", + "placeholders": { + "voterNames": { + "example": "Alice, Bob, Chad", + "type": "String" + } + } + }, + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "@reactionChipLabel": { + "description": "Text describing a reaction chip, with the emoji name and a list or number of votes. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "votes": { + "example": "You, Chris, Greg", + "type": "String" + } + } + }, + "@reactionChipVotesYouAndOthers": { + "description": "The number of votes on a reaction chip, where the self-user and at least one other user has voted. (An accessibility label for assistive technology.)", + "placeholders": { + "otherUsersCount": { + "example": "4", + "type": "int" + } + } + }, + "@reactionChipsLabel": { + "description": "Text identifying the container of reaction chips on a message. (An accessibility label for assistive technology.)" + }, + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." + }, + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "@seeWhoReactedSheetEmojiNameWithVoteCount": { + "description": "In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetHeaderLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.)", + "placeholders": { + "num": { + "example": "2", + "type": "int" + } + } + }, + "@seeWhoReactedSheetNoReactions": { + "description": "Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened)." + }, + "@seeWhoReactedSheetUserListLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": { + "example": "working_on_it", + "type": "String" + }, + "num": { + "example": "2", + "type": "int" + } + } + }, + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "@setStatusPageTitle": { + "description": "Title for the 'Set status' page." + }, + "@settingsPageTitle": {}, + "@sharePageTitle": { + "description": "Title for the page about sharing content received from other apps." + }, + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "example": "Google", + "type": "String" + } + } + }, + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "@statusButtonLabelStatusSet": { + "description": "The status button label in self-user profile page when status is set." + }, + "@statusButtonLabelStatusUnset": { + "description": "The status button label in self-user profile page when status is not set." + }, + "@statusClearButtonLabel": { + "description": "Label for the button that clears the user status, in 'Set status' page." + }, + "@statusSaveButtonLabel": { + "description": "Label for the button that saves the user status, in 'Set status' page." + }, + "@statusTextHint": { + "description": "Hint text for the status text input field in 'Set status' page." + }, + "@subscribeFailedTitle": { + "description": "Error title when subscribing to a channel failed." + }, + "@successChannelLinkCopied": { + "description": "Message when link of a channel was copied to the user's system clipboard." + }, + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "@successTopicLinkCopied": { + "description": "Message when link of a topic was copied to the user's system clipboard." + }, + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "@today": { + "description": "Term to use to reference the current day." + }, + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "@topicsButtonTooltip": { + "description": "Tooltip for button to navigate to topic-list page." + }, + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "example": "http://chat.example.com/", + "type": "String" + } + }, + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "otherTypist": { + "example": "Bob", + "type": "String" + }, + "typist": { + "example": "Alice", + "type": "String" + } + } + }, + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "@unsubscribeConfirmationDialogConfirmButton": { + "description": "Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel." + }, + "@unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": { + "description": "Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe." + }, + "@unsubscribeConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for unsubscribing from a channel.", + "placeholders": { + "channelName": { + "example": "mobile", + "type": "String" + } + } + }, + "@unsubscribeFailedTitle": { + "description": "Error title when unsubscribing from a channel failed." + }, + "@updateStatusErrorTitle": { + "description": "Error title when updating user status failed." + }, + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "@userActiveDate": { + "description": "Indicates the date when a user was last active on Zulip (who is currently offline).\n\nThe date might be day and month if recent, or day, month, and year if less recent.", + "placeholders": { + "date": { + "example": "Aug 1, 2024", + "type": "String" + } + } + }, + "@userActiveDaysAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "days": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveHoursAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "hours": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveMinutesAgo": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)", + "placeholders": { + "minutes": { + "example": "5", + "type": "int" + } + } + }, + "@userActiveNow": { + "description": "Indicates a user is currently active on Zulip (not idle or offline)" + }, + "@userActiveYesterday": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userIdle": { + "description": "Indicates a user is currently idle on Zulip (not active, but not offline)" + }, + "@userNotActiveInYear": { + "description": "Indicates when a user was last active on Zulip (who is currently offline)" + }, + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "@userStatusAtTheOffice": { + "description": "A suggested user status text, 'At the office'." + }, + "@userStatusBusy": { + "description": "A suggested user status text, 'Busy'." + }, + "@userStatusCommuting": { + "description": "A suggested user status text, 'Commuting'." + }, + "@userStatusInAMeeting": { + "description": "A suggested user status text, 'In a meeting'." + }, + "@userStatusOutSick": { + "description": "A suggested user status text, 'Out sick'." + }, + "@userStatusVacationing": { + "description": "A suggested user status text, 'Vacationing'." + }, + "@userStatusWorkingRemotely": { + "description": "A suggested user status text, 'Working remotely'." + }, + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "aboutPageAppVersion": "App 版本", + "aboutPageOpenSourceLicenses": "開源授權條款", + "aboutPageTapToView": "點選查看", + "aboutPageTitle": "關於 Zulip", + "actionSheetOptionChannelFeed": "頻道動態", + "actionSheetOptionCopyChannelLink": "複製頻道連結", + "actionSheetOptionCopyMessageLink": "複製訊息連結", + "actionSheetOptionCopyMessageText": "複製訊息文字", + "actionSheetOptionCopyTopicLink": "複製議題的連結", + "actionSheetOptionEditMessage": "編輯訊息", + "actionSheetOptionFollowTopic": "跟隨話題", + "actionSheetOptionHideMutedMessage": "再次隱藏已靜音的話題", + "actionSheetOptionListOfTopics": "議題列表", + "actionSheetOptionMarkAsUnread": "從這裡開始標註為未讀", + "actionSheetOptionMarkChannelAsRead": "標註頻道為已讀", + "actionSheetOptionMarkTopicAsRead": "標註話題為已讀", + "actionSheetOptionMuteTopic": "靜音話題", + "actionSheetOptionQuoteMessage": "引述訊息", + "actionSheetOptionResolveTopic": "標註為已解決", + "actionSheetOptionSeeWhoReacted": "查看誰有回應", + "actionSheetOptionShare": "分享", + "actionSheetOptionStarMessage": "收藏訊息", + "actionSheetOptionSubscribe": "訂閱", + "actionSheetOptionUnfollowTopic": "取消跟隨話題", + "actionSheetOptionUnmuteTopic": "取消靜音話題", + "actionSheetOptionUnresolveTopic": "標註為未解決", + "actionSheetOptionUnstarMessage": "取消收藏訊息", + "actionSheetOptionUnsubscribe": "取消訂閱", + "actionSheetOptionViewReadReceipts": "查看已讀回條", + "actionSheetReadReceipts": "已讀回條", + "actionSheetReadReceiptsErrorReadCount": "載入已讀回條失敗。", + "actionSheetReadReceiptsReadCount": "{count, plural, =1{此訊息已被閱讀,共有 {count} 人:} other{此訊息已被閱讀,共有 {count} 人:}}", + "actionSheetReadReceiptsZeroReadCount": "尚無人閱讀此訊息。", + "appVersionUnknownPlaceholder": "(…)", + "channelFeedButtonTooltip": "頻道饋給", + "channelsEmptyPlaceholder": "您尚未訂閱任何頻道。", + "channelsPageTitle": "頻道", + "chooseAccountButtonAddAnAccount": "增添帳號", + "chooseAccountPageLogOutButton": "登出", + "chooseAccountPageTitle": "選取帳號", + "combinedFeedPageTitle": "綜合饋給", + "composeBoxAttachFilesTooltip": "附加檔案", + "composeBoxAttachFromCameraTooltip": "拍照", + "composeBoxAttachMediaTooltip": "附加圖片或影片", + "composeBoxBannerButtonCancel": "取消", + "composeBoxBannerButtonSave": "儲存", + "composeBoxBannerLabelEditMessage": "編輯訊息", + "composeBoxChannelContentHint": "訊息 {destination}", + "composeBoxDmContentHint": "訊息 @{user}", + "composeBoxEnterTopicOrSkipHintText": "輸入議題(留空則使用「{defaultTopicName}」)", + "composeBoxGenericContentHint": "輸入訊息", + "composeBoxGroupDmContentHint": "訊息群組", + "composeBoxLoadingMessage": "(載入訊息 {messageId} 中)", + "composeBoxSelfDmContentHint": "記下些什麼", + "composeBoxSendTooltip": "發送", + "composeBoxTopicHintText": "議題", + "composeBoxUploadingFilename": "正在上傳 {filename}…", + "contentValidationErrorEmpty": "您沒有要發送的內容!", + "contentValidationErrorQuoteAndReplyInProgress": "請等待引述完成。", + "contentValidationErrorTooLong": "訊息長度不應超過 10000 個字元。", + "contentValidationErrorUploadInProgress": "請等待上傳完成。", + "dialogCancel": "取消", + "dialogClose": "關閉", + "dialogContinue": "繼續", + "discardDraftConfirmationDialogConfirmButton": "捨棄", + "discardDraftConfirmationDialogTitle": "要捨棄您正在編寫的訊息嗎?", + "discardDraftForEditConfirmationDialogMessage": "當您編輯訊息時,編輯框中原有的內容將被捨棄。", + "discardDraftForOutboxConfirmationDialogMessage": "當您還原未發送的訊息時,編輯框中原有的內容將被捨棄。", + "dmsWithOthersPageTitle": "與 {others} 的私訊", + "dmsWithYourselfPageTitle": "私訊給自己", + "editAlreadyInProgressMessage": "編輯已在進行中。請等待其完成。", + "editAlreadyInProgressTitle": "無法編輯訊息", + "emojiPickerSearchEmoji": "搜尋表情符號", + "emojiReactionsMore": "更多", + "emptyMessageList": "這裡沒有訊息。", + "emptyMessageListSearch": "沒有搜尋結果。", + "errorAccountLoggedIn": "在 {server} 的帳號 {email} 已經存在帳號清單中。", + "errorAccountLoggedInTitle": "帳號已經登入了", + "errorBannerCannotPostInChannelLabel": "您沒有權限在此頻道發佈訊息。", + "errorBannerDeactivatedDmLabel": "您無法向已停用的使用者發送訊息。", + "errorConnectingToServerDetails": "連接 Zulip {serverUrl} 時發生錯誤。將重試:\n\n{error}", + "errorConnectingToServerShort": "連接 Zulip 時發生錯誤。重試中…", + "errorContentNotInsertedTitle": "未插入內容", + "errorContentToInsertIsEmpty": "要插入的檔案為空或無法存取。", + "errorCopyingFailed": "複製失敗", + "errorCouldNotConnectTitle": "無法連線", + "errorCouldNotEditMessageTitle": "無法編輯訊息", + "errorCouldNotFetchMessageSource": "無法取得訊息來源。", + "errorCouldNotOpenLink": "無法開啟連結: {url}", + "errorCouldNotOpenLinkTitle": "無法開啟連結", + "errorCouldNotShowUserProfile": "無法顯示使用者設定檔。", + "errorDialogContinue": "OK", + "errorDialogLearnMore": "了解更多", + "errorDialogTitle": "錯誤", + "errorFailedToUploadFileTitle": "上傳檔案失敗:{filename}", + "errorFilesTooLarge": "{num, plural, =1{檔案} other{{num} 個檔案}}超過伺服器 {maxFileUploadSizeMib} MiB 的限制,將不會上傳:\n\n{listMessage}", + "errorFilesTooLargeTitle": "{num, plural, =1{檔案} other{檔案}}太大", + "errorFollowTopicFailed": "無法跟隨話題", + "errorHandlingEventDetails": "處理來自 {serverUrl} 的 Zulip 事件時發生錯誤;將重試。\n\n錯誤:{error}\n\n事件:{event}", + "errorHandlingEventTitle": "處理 Zulip 事件時發生錯誤。重新連線中…", + "errorInvalidApiKeyMessage": "您在 {url} 的帳號無法通過驗證。請重新登入或使用其他帳號。", + "errorInvalidResponse": "伺服器傳送了無效的請求。", + "errorLoginCouldNotConnect": "無法連線到伺服器:\n{url}", + "errorLoginFailedTitle": "登入失敗", + "errorLoginInvalidInputTitle": "無效的輸入", + "errorMalformedResponse": "伺服器回傳了格式錯誤的回應;HTTP 狀態碼為 {httpStatus}", + "errorMalformedResponseWithCause": "伺服器回傳了格式錯誤的回應;HTTP 狀態碼為 {httpStatus};{details}", + "errorMarkAsReadFailedTitle": "標記為已讀失敗", + "errorMarkAsUnreadFailedTitle": "標記為未讀失敗", + "errorMessageDoesNotSeemToExist": "該訊息似乎不存在。", + "errorMessageEditNotSaved": "訊息沒有儲存", + "errorMessageNotSent": "訊息沒有送出", + "errorMuteTopicFailed": "無法靜音話題", + "errorNetworkRequestFailed": "網路請求失敗", + "errorNotificationOpenAccountNotFound": "找不到與此通知相關聯的帳號。", + "errorNotificationOpenTitle": "無法開啟通知", + "errorQuotationFailed": "引述失敗", + "errorReactionAddingFailedTitle": "新增表情反應失敗", + "errorReactionRemovingFailedTitle": "移除表情反應失敗", + "errorRequestFailed": "網路請求失敗:HTTP 狀態碼為 {httpStatus}", + "errorResolveTopicFailedTitle": "無法標註話題為已解決", + "errorServerMessage": "伺服器回應:\n\n{message}", + "errorServerVersionUnsupportedMessage": "{url} 執行的 Zulip Server 為 {zulipVersion},此版本已不受支援。最低支援版本為 Zulip Server {minSupportedZulipVersion}。", + "errorSharingAccountNotLoggedIn": "尚未登入任何帳號。請登入帳號後再試一次。", + "errorSharingFailed": "分享失敗", + "errorSharingTitle": "分享內容失敗", + "errorStarMessageFailedTitle": "無法收藏訊息", + "errorUnfollowTopicFailed": "無法取消跟隨話題", + "errorUnmuteTopicFailed": "無法取消靜音話題", + "errorUnresolveTopicFailedTitle": "無法標註話題為未解決", + "errorUnstarMessageFailedTitle": "無法取消收藏訊息", + "errorVideoPlayerFailed": "無法播放影片。", + "errorWebAuthOperationalError": "出現了意外的錯誤。", + "errorWebAuthOperationalErrorTitle": "出錯了", + "experimentalFeatureSettingsPageTitle": "實驗性功能", + "experimentalFeatureSettingsWarning": "這些選項啟用的功能仍在開發中,尚未完善。它們可能無法正常運作,且可能導致應用程式其他部分出現問題。\n\n這些設定的目的是供參與 Zulip 開發的人員進行試驗使用。", + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "inboxEmptyPlaceholder": "您的收件匣中沒有未讀訊息。請使用下方按鈕查看整合訊息流或頻道清單。", + "inboxPageTitle": "收件匣", + "initialAnchorSettingDescription": "您可以選擇將訊息串開啟在第一則未讀訊息,或是最新的訊息。", + "initialAnchorSettingFirstUnreadAlways": "第一則未讀訊息", + "initialAnchorSettingFirstUnreadConversations": "在對話檢視中開啟第一則未讀訊息,其餘情況則開啟最新訊息", + "initialAnchorSettingNewestAlways": "最新訊息", + "initialAnchorSettingTitle": "開啟訊息串於", + "invisibleMode": "隱身模式", + "lightboxCopyLinkTooltip": "複製連結", + "lightboxVideoCurrentPosition": "目前位置", + "lightboxVideoDuration": "影片長度", + "logOutConfirmationDialogConfirmButton": "登出", + "logOutConfirmationDialogMessage": "要在未來使用此帳號,您將需要重新輸入您組織的網址和您的帳號資訊。", + "logOutConfirmationDialogTitle": "登出?", + "loginAddAnAccountPageTitle": "增添帳號", + "loginEmailLabel": "電子郵件地址", + "loginErrorMissingEmail": "請輸入您的電子郵件地址。", + "loginErrorMissingPassword": "請輸入您的密碼。", + "loginErrorMissingUsername": "請輸入您的使用者名稱。", + "loginFormSubmitLabel": "登入", + "loginHidePassword": "隱藏密碼", + "loginMethodDivider": "或", + "loginPageTitle": "登入", + "loginPasswordLabel": "密碼", + "loginServerUrlLabel": "您的 Zulip 伺服器網址", + "loginUsernameLabel": "使用者名稱", + "mainMenuMyProfile": "我的設定檔", + "manyPeopleTyping": "有些人正在輸入…", + "markAllAsReadLabel": "標註所有訊息為已讀", + "markAsReadComplete": "已標為已讀:{num, plural, =1{1 則訊息} other{{num} 則訊息}}。", + "markAsReadInProgress": "正在標記訊息為已讀…", + "markAsUnreadComplete": "已標為未讀:{num, plural, =1{1 則訊息} other{{num} 則訊息}}。", + "markAsUnreadInProgress": "正在標註訊息為未讀…", + "markReadOnScrollSettingAlways": "總是", + "markReadOnScrollSettingConversations": "僅在對話檢視中", + "markReadOnScrollSettingConversationsDescription": "只有在查看單一議題或私人訊息對話時,訊息才會自動標記為已讀。", + "markReadOnScrollSettingDescription": "在捲動瀏覽訊息時,是否要自動將其標記為已讀?", + "markReadOnScrollSettingNever": "從不", + "markReadOnScrollSettingTitle": "捲動時將訊息標記為已讀", + "mentionsPageTitle": "提及", + "messageIsEditedLabel": "已編輯", + "messageIsMovedLabel": "已移動", + "messageListGroupYouAndOthers": "您與 {others}", + "messageListGroupYouWithYourself": "與自己的訊息", + "messageNotSentLabel": "訊息未送出", + "mutedUser": "已靜音的使用者", + "newDmFabButtonLabel": "新增私訊", + "newDmSheetComposeButtonLabel": "編寫", + "newDmSheetNoUsersFound": "找不到使用者", + "newDmSheetScreenTitle": "新增私訊", + "newDmSheetSearchHintEmpty": "增添一個或多個使用者", + "newDmSheetSearchHintSomeSelected": "增添其他使用者…", + "noEarlierMessages": "沒有更早的訊息", + "noStatusText": "無狀態文字", + "notifGroupDmConversationLabel": "{senderFullName} 傳送給您和 {numOthers, plural, =1{1 位其他對象、} other{{numOthers} 位其他對象}}", + "notifSelfUser": "您", + "onePersonTyping": "{typist} 正在輸入…", + "openLinksWithInAppBrowser": "使用應用程式內建瀏覽器開啟連結", + "permissionsDeniedCameraAccess": "要上傳圖片,請在設定中授予 Zulip 額外權限。", + "permissionsDeniedReadExternalStorage": "要上傳檔案,請在設定中授予 Zulip 額外權限。", + "permissionsNeededOpenSettings": "開啟設定", + "permissionsNeededTitle": "需要的權限", + "pinnedSubscriptionsLabel": "已釘選", + "pollVoterNames": "({voterNames})", + "pollWidgetOptionsMissing": "此投票尚未有任何選項。", + "pollWidgetQuestionMissing": "沒有問題。", + "preparingEditMessageContentInput": "準備中…", + "profileButtonSendDirectMessage": "發送私訊", + "reactedEmojiSelfUser": "您", + "reactionChipLabel": "{emojiName}: {votes}", + "reactionChipVotesYouAndOthers": "{otherUsersCount, plural, =1 {你與其他 1 人} other {你與其他 {otherUsersCount} 人}}", + "reactionChipsLabel": "反應", + "recentDmConversationsEmptyPlaceholder": "您尚未有任何私人訊息!不如開始一段對話吧?", + "recentDmConversationsPageTitle": "私人訊息", + "recentDmConversationsSectionHeader": "私人訊息", + "revealButtonLabel": "顯示訊息", + "savingMessageEditFailedLabel": "編輯未儲存", + "savingMessageEditLabel": "儲存編輯中…", + "scrollToBottomTooltip": "捲動至底部", + "searchMessagesClearButtonTooltip": "清除", + "searchMessagesHintText": "搜尋", + "searchMessagesPageTitle": "搜尋", + "seeWhoReactedSheetEmojiNameWithVoteCount": "{emojiName}:{num, plural, =1 {1 票} other {{num} 票}}", + "seeWhoReactedSheetHeaderLabel": "表情符號回應 (共 {num} 個)", + "seeWhoReactedSheetNoReactions": "此訊息尚無任何回應。", + "seeWhoReactedSheetUserListLabel": "{emojiName} 的投票數({num})", + "serverUrlValidationErrorEmpty": "請輸入網址。", + "serverUrlValidationErrorInvalidUrl": "請輸入有效的網址。", + "serverUrlValidationErrorNoUseEmail": "請輸入伺服器網址,而非您的電子郵件。", + "serverUrlValidationErrorUnsupportedScheme": "伺服器 URL 必須以 http:// 或 https:// 開頭。", + "setStatusPageTitle": "設定狀態", + "settingsPageTitle": "設定", + "sharePageTitle": "分享", + "signInWithFoo": "使用 {method} 登入", + "snackBarDetails": "詳細資訊", + "spoilerDefaultHeaderText": "劇透", + "starredMessagesPageTitle": "已加星號的訊息", + "statusButtonLabelStatusSet": "狀態", + "statusButtonLabelStatusUnset": "設定狀態", + "statusClearButtonLabel": "清除", + "statusSaveButtonLabel": "儲存", + "statusTextHint": "您的狀態", + "subscribeFailedTitle": "訂閱失敗", + "successChannelLinkCopied": "頻道連結已複製", + "successLinkCopied": "已複製連結", + "successMessageLinkCopied": "已複製訊息連結", + "successMessageTextCopied": "已複製訊息文字", + "successTopicLinkCopied": "議題連結已複製", + "switchAccountButton": "切換帳號", + "themeSettingDark": "深色主題", + "themeSettingLight": "淺色主題", + "themeSettingSystem": "系統主題", + "themeSettingTitle": "主題", + "today": "今天", + "topicValidationErrorMandatoryButEmpty": "此組織要求必須填寫議題。", + "topicValidationErrorTooLong": "議題長度不得超過 60 個字元。", + "topicsButtonTooltip": "話題", + "tryAnotherAccountButton": "請嘗試別的帳號", + "tryAnotherAccountMessage": "您在 {url} 的帳號載入的比較久。", + "turnOffInvisibleModeErrorTitle": "關閉隱身模式時發生錯誤。請再試一次。", + "turnOnInvisibleModeErrorTitle": "啟用隱身模式時發生錯誤。請再試一次。", + "twoPeopleTyping": "{typist} 和 {otherTypist} 正在輸入…", + "unknownChannelName": "(未知頻道)", + "unknownUserName": "(未知使用者)", + "unpinnedSubscriptionsLabel": "未釘選", + "unsubscribeConfirmationDialogConfirmButton": "取消訂閱", + "unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": "一旦您離開此頻道,可能無法重新加入。", + "unsubscribeConfirmationDialogTitle": "確定要取消訂閱 {channelName} 嗎?", + "unsubscribeFailedTitle": "取消訂閱失敗", + "updateStatusErrorTitle": "更新使用者狀態時發生錯誤。請再試一次。", + "upgradeWelcomeDialogDismiss": "開始吧", + "upgradeWelcomeDialogLinkText": "查看公告部落格文章!", + "upgradeWelcomeDialogMessage": "您將在更快、更流暢的版本中享受熟悉的體驗。", + "upgradeWelcomeDialogTitle": "歡迎使用新 Zulip 應用程式!", + "userActiveDate": "上次活躍於 {date}", + "userActiveDaysAgo": "上次活躍於 {days, plural, =1{1 天前} other{{days} 天前}}", + "userActiveHoursAgo": "上次活躍於 {hours, plural, =1{1 小時前} other{{hours} 小時前}}", + "userActiveMinutesAgo": "上次活躍於 {minutes, plural, =1{1 分鐘前} other{{minutes} 分鐘前}}", + "userActiveNow": "目前活躍", + "userActiveYesterday": "昨天活躍", + "userIdle": "閒置", + "userNotActiveInYear": "去年未活躍", + "userRoleAdministrator": "管理員", + "userRoleGuest": "訪客", + "userRoleMember": "成員", + "userRoleModerator": "版主", + "userRoleOwner": "擁有者", + "userRoleUnknown": "未知", + "userStatusAtTheOffice": "在辦公室", + "userStatusBusy": "忙碌", + "userStatusCommuting": "通勤中", + "userStatusInAMeeting": "會議中", + "userStatusOutSick": "請病假", + "userStatusVacationing": "休假中", + "userStatusWorkingRemotely": "遠端工作中", + "wildcardMentionAll": "全部", + "wildcardMentionAllDmDescription": "通知收件人", + "wildcardMentionChannel": "頻道", + "wildcardMentionChannelDescription": "通知頻道", + "wildcardMentionEveryone": "所有人", + "wildcardMentionStream": "串流", + "wildcardMentionStreamDescription": "通知串流", + "wildcardMentionTopic": "議題", + "wildcardMentionTopicDescription": "通知話題", + "yesterday": "昨天", + "zulipAppTitle": "Zulip" +} diff --git a/docs/changelog.md b/docs/changelog.md index fcbded741d..33d68b25eb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,654 @@ ## Unreleased +## 30.0.264 (2025-08-20) + +### Highlights for users (since last mainline release, v30.0.263) + +* (Android) Paste an image into a message, or insert a sticker from + your keyboard. (#1173, #419) +* Autocomplete matches people's names regardless of diacritics. (#237) +* Show message separated from last after a 10-minute gap. (#1773) +* Link to channel feed in channel action sheet. (#1705) +* Too many other improvements and fixes to describe them all here. + + +### Highlights for developers (since last mainline release, v30.0.263) + +* User-visible changes not described above: + * upgrade Flutter (PR #1791) + * show error dialog on edit-message request error (PR #1792) + * generate narrow links with "channel", vs "stream" (#633) + * TeX: big operators, null delimiters (#1671, #1677) + * several tweaks to set-status page (#1769, #1770, #1771) + * hide topic-list button in channel action sheet when redundant + (in PR #1794, for #1705) + * wildcard-mention autocomplete case-insensitive + (in PR #1806, for #237) + * emoji autocomplete insensitive to diacritics (#1067) + * new-DM search insensitive to diacritics + (in PR #1806, for #237) + * who-reacted and read-receipts sheets now draggable-scrollable + (PR #1802) + * translations (PR #1809) + * propagate nested text styles in several cases (#1818, #1817, #806, + #1812) + +* In tests, the user list always includes the self-user. (PR #1814) + +* Resolved in the beta-prelaunch branch (and v0.0.34): #1603 + +* Resolved in main: #268, PR #1791, #1647, PR #1792, #633, #419, + #1173, #1677, #1671, #1769, #1770, #1771, PR #1814, #1705, #237, + #1067, PR #1802, PR #1809, #1818, #1817, #806, #1812, #1773 + + +## 0.0.34 (2025-08-18) + +This is a release from the "beta-prelaunch" branch, with selected +changes atop the previous pre-launch beta release 0.0.33. + + +### Highlights for users + +Thanks for being a beta tester of the new Zulip app! + +This app became the main Zulip mobile app in June 2025, and this +beta version is no longer maintained. We recommend uninstalling +this beta after switching to the main Zulip app, in order to get +the latest features and bug fixes. + +Changes in this version from the previous beta: +* Give a notice on startup that this beta version is no longer + maintained, with links to switch to the main Zulip app. (#1603) + + +### Highlights for developers + +* Resolved in this beta branch: #1603 + + +## 30.0.263 (2025-08-12) + +### Highlights for users + +* (Android) Share to Zulip from other apps. (#53) +* See read receipts. (#667) +* Autocomplete mentioning a group. (#233) +* Fix bug when uploading a file with a non-ASCII name. (#1709) +* Copy link to a channel or topic. (#1227, #792) +* Zoom in farther in lightbox. (#1091) +* Subscribe or unsubscribe to a channel. (#1224) + + +### Highlights for developers + +* User-visible changes not described above: + * upgrade Flutter (PR #1763) + * drop "always scrollable" on list of suggested statuses + (in caf1ddb7b; revision to PR #1701, for #198) + * handle colored text in KaTeX content (#1679) + * user autocomplete matches on email (#236) + * semantics on reaction chips; no tooltip; "You" first + (41e3d57f2, b2321839f, 025b0cee8; revision to PR #1700, for #740) + * various changes to who-reacted feature + (in e2c10ae21; revision to PR #1700, for #740): + * show who-reacted button only when there was a reaction + * fix an edge case in who-reacted sheet: don't re-apply + initialReactionType on new store + * dispose in _ViewReactionsState, fixing potential get-stuck bug + * align emoji in center of who-reacted header, not start + * semantics in who-reacted sheet + * adjust scroll-into-view behavior in who-reacted header + (98b94bd2a; revision to PR #1700, for #740) + * Cupertino dialogs (#996, PR #1782) + * mark-channel-read button at top of action sheet (PR #1789) + * reject login sooner when server too old (PR #1783) + * translations (PR #1757) + +* Set visualDensity to mobile value on desktop, fixing assert in + buttons (PR #1781) + +* Resolved in main: #332, PR #1763, #1227, #792, #198, #1679, #1709, + #1091, #236, #233, #740, #996, PR #1781, PR #1782, PR #1789, + PR #1783, #1224, PR #1757, #667, #53 + + +## 30.0.262 (2025-07-24) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +* Fix "general chat" to show new messages as normal + after opening via a notification. (#1717) +* Set your status emoji and status message. (#198) +* Fix deactivated users appearing in "New DM" screen. (#1743) +* Follow your personal setting for 24-hour or 12-hour time + format. (#1015) +* Translation updates. (PR #1726, PR #1750) + + +### Highlights for developers + +* User-visible changes not described above: + * Avoid showing potentially wrong result if encountering + a KaTeX vlist with unexpected inline style properties. + (c4503b492; revision to PR #1698, for #46) + * Fix double-application of negative margin on KaTeX vlist items. + (64956b8f0; revision to PR #1559, for #46) + * Better semantics on settings radio buttons, for a11y. (#1545) + +* Store and substore refactors: RealmStore; proxy mixins; + move more methods to individual substores. (PR #1736) + +* Resolved in main: #1710, #1712, PR #1698, #1717, PR #1559, #46, + PR #1719, PR #1726, #197, #1545, PR #1736, #1743, #1015, PR #1750 + +* Resolved in the experimental branch: + * #740 via PR #1700 + * #198 via PR #1701 + + +## 30.0.261 (2025-07-09) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +* See who reacted to a message. (#740) +* Turn invisible mode on and off. (#1578) +* Less empty space at end of message feed. (PR #1628) +* After you return to the app, it resumes its connection + more quickly. (#979) +* The message long-press menu shows the message and + when it was sent. (#217) +* (iOS) Fixed white flash on opening app in dark mode. (#1149) + + +### Highlights for developers + +* User-visible changes not described above: + * Upgraded Flutter and other dependencies. (#1684) + * Case-insensitive topics in unreads and other data + structures. (#980) + * Icon for topic-list button, rather than "TOPICS". (#1532) + * Status emoji properly follow system text-scale setting. + (revision to PR #1629, for #197) + * Status text's font size increased. + (revision to PR #1629, for #197) + * Fixed scroll behavior of math blocks in RTL locales. + (revision to PR #1452, at 5677317bc, for #46) + * Fixed vertical alignment within TeX math expressions. + (e8e8f4105; revision to PR #1452, for #46) + * Adjusted color of icons in action sheets. + (included in PR #1631, for #1578) + * Removed blank space for absent status emoji. + (revision to PR #1629, for #197) + * Adjusted choice of "Close" vs "Cancel" in action sheets. + (included in PR #1700, for #740) + * Translation updates. (PR #1682) + +* Workarounds in our CI for a Flutter infra issue with the + "main" branch. (PR #1690, PR #1691; flutter/flutter#171833) + +* Resolved in main: #296, PR #1684, PR #1628, #980, #1532, #662, + #217, #1578, #1149, PR #1629, #979, PR #1682, PR #1452 + +* Resolved in the experimental branch: + * more toward #46 via PR #1698 + * further toward #46 via PR #1559 + * #197 via PR #1702 + * #740 via PR #1700 + + +## 30.0.260 (2025-07-03) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +* (iOS) Fixed a bug causing duplicate notifications. (#1617) +* The app offers a search view. (#252) +* See the status emoji and status messages of other users. (#197) +* Initial support for showing audio files in messages, + an upcoming Zulip feature. (#1665) +* Translation updates. (PR #1642) + + +### Highlights for developers + +* User-visible changes not described above: + * More recipient headers in mentions/starred. (#1637) + * Tap message in starred/mentions to open conversation. (#1621) + * Clearer placeholder text when no messages. (#1555) + * Correctly apply font-size to "em" on the same KaTeX span + (if that situation is possible). (f003f58ed, in PR #1609) + +* Resolved by server-side changes: #1617 + +* Resolved in main: #1637, #1621, PR #1560 (toward #296), #1555, + PR #1609 (toward #46), PR #1601 (toward #46), + PR #1600 (toward #46), PR #1658, #1665, #252, PR #1642 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + * #197 via PR #1629 + + +## 30.0.259 (2025-06-23) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +New since last week's release: +* The app shows others' availability. (#196) +* When you're using the app, you'll appear to others + as online, according to your settings. (#1607) +* Much broader TeX math support. (PR #1601) +* More translation updates. (PR #1615) + +Welcome to the new Zulip mobile app! You'll find +a familiar experience in a faster, sleeker package. + +For more information or to send us feedback, +see the announcement blog post: +https://blog.zulip.com/flutter-mobile-app-launch + + +### Highlights for developers + +* Resolved in main: PR #1598, PR #1599, #196, #1607, PR #1615 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * yet further toward #46 via PR #1601 (cherry-picked) + * #296 via PR #1561 + + +## 30.0.258 (2025-06-16) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users (vs legacy app) + +Welcome to the new Zulip mobile app! You'll find +a familiar experience in a faster, sleeker package. + +For more information or to send us feedback, +see the announcement blog post: +https://blog.zulip.com/flutter-mobile-app-launch + + +### Highlights for users (vs previous beta, v30.0.257) + +* More translation updates. (PR #1596) +* Handle additional error cases in migrating data from + legacy app. (PR #1595) + + +### Highlights for developers + +* User-visible changes not described above: + * Tweak wording of first-unread setting. (PR #1597) + +* Resolved in main: #1070, #1580, PR #1595, PR #1596, PR #1597 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + + +## 30.0.257 (2025-06-15) + +This was a beta-only release. + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users (vs legacy app) + +Welcome to the new Zulip mobile app! You'll find +a familiar experience in a faster, sleeker package. + +For more information or to send us feedback, +see the announcement blog post: +https://blog.zulip.com/flutter-mobile-app-launch + + +### Highlights for users (vs previous alpha, v30.0.256) + +* Translation updates, including near-complete translations + for German (de) and Italian (it). + + +### Highlights for developers + +* User-visible changes not described above: + * Updated link in welcome dialog. (part of #1580) + * Skip ackedPushToken in migrated account data. + (part of #1070) + +* Resolved in main: #1537, #1582 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + * #1070 via PR #1588 + * #1580 via PR #1590 + + +## 30.0.256 (2025-06-15) + +With this release, this new app takes on the identity +of the main Zulip app! + +This was an alpha-only release. + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users (vs legacy app) + +Welcome to the new Zulip mobile app! You'll find +a familiar experience in a faster, sleeker package. + +For more information or to send us feedback, +see the announcement blog post: +https://blog.zulip.com/flutter-mobile-app-launch + + +### Highlights for users (vs last beta, v0.0.33) + +* This app now uses the app ID of the main Zulip mobile app, + formerly used by the legacy app. It therefore installs over + any previous install of the legacy app, rather than of the + Flutter beta app. (#1582) +* The app's icon and name no longer say "beta". (#1537) +* Migrate accounts and settings from the legacy app's data. (#1070) +* Show welcome dialog on upgrading from legacy app. (#1580) + + +### Highlights for developers + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + * #1537 via PR #1577 + * #1582 via PR #1586 + * #1070 via PR #1588 + * #1580 via PR #1590 + + +## 0.0.33 (2025-06-13) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +This app is nearing ready to replace the legacy Zulip mobile app, +planned for next week. + +In addition to all the features in the last beta: +* Messages are automatically marked read as you scroll through + a conversation. (#81) +* More translations. + + +### Highlights for developers + +* User-visible changes not described above: + * "Quote message" button label rather than "Quote and reply" + (PR #1575) + +* Resolved in main: PR #1575, #81 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + + +## 0.0.32 (2025-06-12) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +This app is nearing ready to replace the legacy Zulip mobile app, +planned for next week. + +In addition to all the features in the last beta: +* The keyboard opens immediately when you start a + new conversation. (#1543) +* Translation updates, including new near-complete translations + for Slovenian (sl) and Chinese (Simplified, China) (zh_Hans_CN). +* Several small improvements to the newest features: + muted users (#296), message links going directly to message (#82). + + +### Highlights for developers + +* User-visible changes not described above: + * upgraded Flutter and deps (PR #1568) + * suppress long-press on muted-sender message, + and hide muted users in new-DM list (part of #296) + * reject internal links with malformed /near/ operands + (part of #82) + +* Resolved in main: #276 (though external to the tree), + #1543, #82, #80, #1147, #1441 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + + +## 0.0.31 (2025-06-11) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +This app is nearing ready to replace the legacy Zulip mobile app, +planned for next week. + +In addition to all the features in the last beta: +* Conversations open at your first unread message. (#80) +* TeX support now enabled by default, and covers a larger + set of expressions. More to come later. (#46) +* Numerous small improvements to the newest features: + muted users (#296), start a DM thread (#127), + recover failed send (#1441), open mid-history (#82). + + +### Highlights for developers + +* Resolved in main: #1540, #385, #386, #127 + +* Resolved in the experimental branch: + * #82 via PR #1566 + * #80 via PR #1517 + * #1441 via PR #1453 + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #1147 via PR #1379 + * #296 via PR #1561 + + +## 0.0.30 (2025-05-28) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +We're nearing ready to have this new app replace the legacy +Zulip mobile app, a few weeks from now. + +In addition to all the features in the last beta: +* Muted users are now muted. (#296) +* Improved logic to recover from failed send. (#1441) +* Numerous small improvements to the newest features. + + +### Highlights for developers + +* Resolved in main: #83, #1495, #1456, #1158 + +* Resolved in the experimental branch: + * #82, and #80 behind a flag, via PR #1517 + * #1441 via PR #1453 + * #127 via PR #1322 + * more toward #46 via PR #1452 + * #1147 via PR #1379 + * #296 via PR #1429 + + +## 0.0.29 (2025-05-19) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +This is a feature-packed release, as this new app gets near ready to +replace the legacy Zulip mobile app a few weeks from now. +Please try out the new features, and as always report anything broken. + +* Initial support for TeX math! Try enabling the + experimental flag, in settings. (#46) +* Edit a message. (#126) +* Initial support to open at first unread message; + try enabling in settings. (#80) +* List of topics in channel. (#1158) +* (iOS) Go to conversation on opening notification. (#1147) + + +### Highlights for developers + +* Further user highlights that didn't fit in 500 characters: + * #1441 simplified local echo, enabling recovery from failed send + * #82 on following a message link, go to specific message + in middle of history + * #930 no more images moving around when you navigate from + one message list to another + * #1250 general chat + * #1470 when you re-open the app after a while and start typing + a message, your draft is preserved across the app's reloading + its data from the server + +* Resolved in main: #1470, #407, #1485, #930, #44, #1250, #126 + +* Resolved in the experimental branch: + * #82, and #80 behind a flag, via PR #1517 + * #1441 via PR #1453 + * #1158 via PR #1500 + * #1495 via PR #1506 + * #127 via PR #1322 + * more toward #46 via PR #1452 + * #1147 via PR #1379 + + +## 0.0.28 (2025-04-21) + +### Highlights for users + +* (Android) If you log out of a Zulip account, the app clears that + account's notifications. (#1264) +* Connecting to a very old, unsupported server (Zulip Server 3.x and + older) produces a clear error message. (#267) +* Translation updates, and a new translation: Ukrainian. + + +### Highlights for developers + +* The app now gives an error for servers older than 4.0. (#267) + +* If you switch from a later version of the app to a commit in a + certain range (2365bb3f2..f03630805^, 23 commits), then the + database schema downgrade will fail. See issue for workaround. + (#1427) + +* Resolved: #1427, #1409, #267, #1264 + + +## 0.0.27 (2025-03-17) + +### Highlights for users + +* Handle website previews in messages. (#1016) +* Settings for dark vs. light theme, or opening links + in a browser within or outside the app. (#1216, #1228) +* Better handle moved or resolved topics: follow topic permalinks + (#1028), and update inbox (#901). +* Design updates including dark-theme contrast (#973), autocomplete + results (#913), and app icons (#1254, #415, (Android: #1402, #1401)). +* Too many other improvements and fixes to describe them all here. + + +### Highlights for developers + +* We now have a GitHub action making a weekly PR to update + translations from Weblate. (#276) + +* All user-facing strings are now wired up for translation, or else + have a comment saying why translation wouldn't be appropriate or + is currently difficult. (#277) + +* Resolved: #277, part of #1210, #1319, #1358, #1130, #1247, #737, + #1246, #1172, #1028, #1016, PR #1380, #1178, #59, #1356, #973, + PR #1315, #913, #1225, #1357, #1226, #1216, #1354, #1254, #415, + #1402, #1401, #1228, #901 + + +## 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 diff --git a/docs/howto/push-notifications-ios-simulator.md b/docs/howto/push-notifications-ios-simulator.md new file mode 100644 index 0000000000..4ec1d6090a --- /dev/null +++ b/docs/howto/push-notifications-ios-simulator.md @@ -0,0 +1,300 @@ +# Testing Push Notifications on iOS Simulator + +For documentation on testing push notifications on Android or a real +iOS device, see https://github.com/zulip/zulip-mobile/blob/main/docs/howto/push-notifications.md + +This doc describes how to test client-side changes on iOS Simulator. +It will demonstrate how to use APNs payloads the server sends to +the Apple Push Notification service to show notifications on iOS +Simulator. + + +### Contents + +* [Trigger a notification on the iOS Simulator](#trigger-notification) +* [Canned APNs payloads](#canned-payloads) +* [Produce sample APNs payloads](#produce-payload) + + +
+ +## Trigger a notification on the iOS Simulator + +The iOS Simulator permits delivering a notification payload +artificially, as if APNs had delivered it to the device, +but without actually involving APNs or any other server. + +As input for this operation, you'll need an APNs payload, +i.e. a JSON blob representing what APNs might deliver to the app +for a notification. + +To get an APNs payload, you can generate one from a Zulip dev server +by following the [instructions in a section below](#produce-payload), +or you can use one of the payloads included +in this document [below](#canned-payloads). + + +### 1. Determine the device ID of the iOS Simulator + +To receive a notification on the iOS Simulator, we need to first +determine the device ID of the iOS Simulator, to specify which +Simulator instance we want to push the payload to. + +```shell-session +$ xcrun simctl list devices booted +``` + +
+Example output: + +```shell-session +$ xcrun simctl list devices booted +== Devices == +-- iOS 18.3 -- + iPhone 16 Pro (90CC33B2-679B-4053-B380-7B986A29F28C) (Booted) +``` + +
+ + +### 2. Trigger a notification by pushing the payload to the iOS Simulator + +By running the following command with a valid APNs payload, you should +receive a notification on the iOS Simulator for the zulip-flutter app. +Tapping on the notification should route to the respective conversation. + +```shell-session +$ xcrun simctl push [device-id] org.zulip.Zulip [payload json path] +``` + +
+Example output: + +```shell-session +$ xcrun simctl push 90CC33B2-679B-4053-B380-7B986A29F28C org.zulip.Zulip ./dm.json +Notification sent to 'org.zulip.Zulip' +``` + +
+ + +
+ +## Canned APNs payloads + +The following pre-canned APNs payloads can be used in case you don't +have one. + +These canned payloads were generated from +Zulip Server 11.0-dev+git 8fd04b0f0, API Feature Level 377, +in April 2025. +The `user_id` is that of `iago@zulip.com` in the Zulip dev environment. + +These canned payloads assume that EXTERNAL_HOST has its default value +for the dev server. If you've +[set EXTERNAL_HOST to use an IP address](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md#4-set-external_host) +in order to enable your device to connect to the dev server, you'll +need to adjust the `realm_url` fields. You can do this by a +find-and-replace for `localhost`; for example, +`perl -i -0pe s/localhost/10.0.2.2/g tmp/*.json` after saving the +canned payloads to files `tmp/*.json`. + +
+Payload: dm.json + +```json +{ + "aps": { + "alert": { + "title": "Zoe", + "subtitle": "", + "body": "But wouldn't that show you contextually who is in the audience before you have to open the compose box?" + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 7, + "sender_email": "user7@zulipdev.com", + "time": 1740890583, + "recipient_type": "private", + "message_ids": [ + 87 + ] + } +} +``` + +
+ +
+Payload: group_dm.json + +```json +{ + "aps": { + "alert": { + "title": "Othello, the Moor of Venice, Polonius (guest), Iago", + "subtitle": "Othello, the Moor of Venice:", + "body": "Sit down awhile; And let us once again assail your ears, That are so fortified against our story What we have two nights seen." + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 12, + "sender_email": "user12@zulipdev.com", + "time": 1740533641, + "recipient_type": "private", + "pm_users": "11,12,13", + "message_ids": [ + 17 + ] + } +} +``` + +
+ +
+Payload: stream.json + +```json +{ + "aps": { + "alert": { + "title": "#devel > plotter", + "subtitle": "Desdemona:", + "body": "Despite the fact that such a claim at first glance seems counterintuitive, it is derived from known results. Electrical engineering follows a cycle of four phases: location, refinement, visualization, and evaluation." + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 9, + "sender_email": "user9@zulipdev.com", + "time": 1740558997, + "recipient_type": "stream", + "stream": "devel", + "stream_id": 11, + "topic": "plotter", + "message_ids": [ + 40 + ] + } +} +``` + +
+ + +
+ +## Produce sample APNs payloads + +### 1. Set up dev server + +To set up and run the dev server on the same Mac machine that hosts +the iOS Simulator, follow Zulip's +[standard instructions](https://zulip.readthedocs.io/en/latest/development/setup-recommended.html) +for setting up a dev server. + +If you want to run the dev server on a different machine than the Mac +host, you'll need to follow extra steps +[documented here](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md) +to make it possible for the app running on the iOS Simulator to +connect to the dev server. + + +### 2. Set up the dev user to receive mobile notifications. + +We'll use the devlogin user `iago@zulip.com` to test notifications. +Log in to that user by going to `/devlogin` on that server on Web. + +Then follow the steps [here](https://zulip.com/help/mobile-notifications) +to enable Mobile Notifications for "Channels". + + +### 3. Log in as the dev user on zulip-flutter. + + + +To log in as this user in the Flutter app, you'll need the password +that was generated by the development server. You can print the +password by running this command inside your `vagrant ssh` shell: +``` +$ ./manage.py print_initial_password iago@zulip.com +``` + +Then run the app on the iOS Simulator, accept the permission to +receive push notifications, and then log in as the dev user +(`iago@zulip.com`). + + +### 4. Edit the server code to log the notification payload. + +We need to retrieve the APNs payload the server generates and sends +to the bouncer. To do that we can add a log statement after the +server completes generating the payload in `zerver/lib/push_notifications.py`: + +```diff + apns_payload = get_message_payload_apns( + user_profile, + message, + trigger, + mentioned_user_group_id, + mentioned_user_group_name, + can_access_sender, + ) + gcm_payload, gcm_options = get_message_payload_gcm( + user_profile, message, mentioned_user_group_id, mentioned_user_group_name, can_access_sender + ) + logger.info("Sending push notifications to mobile clients for user %s", user_profile_id) ++ logger.info("APNS payload %s", orjson.dumps(apns_payload).decode()) + + android_devices = list( + PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.FCM).order_by("id") +``` + + +### 5. Send messages to the dev user + +To generate notifications to the dev user `iago@zulip.com` we need to +send messages from another user. For a variety of different types of +payloads try sending a message in a topic, a message in a group DM, +and one in one-one DM. Then look for the payloads in the server logs +by searching for "APNS payload". + + +### 6. Transform and save the payload to a file + +The payload JSON recorded in the steps above is in the form the +Zulip server sends to the bouncer. The bouncer restructures this +slightly to produce the actual payload which it sends to APNs, +and which APNs delivers to the app on the device. +To apply the same restructuring, run the payload through +the following `jq` command: + +```shell-session +$ echo '{"alert":{"title": ...' \ + | jq '{aps: {alert, sound, badge}, zulip: .custom.zulip}' \ + > payload.json +``` diff --git a/docs/release.md b/docs/release.md index cbf0020223..2d1b40e641 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,17 +1,38 @@ # Making releases +## NOTE: This document is out of date. + +Now that this is the main Zulip mobile app, the actual release process +is roughly a hybrid of the steps below for building the app, +then the steps from the legacy app's release instructions for +distributing the app. + +Revising this into a single coherent set of instructions +is an open TODO. + + ## Prepare source tree * If we haven't recently (like in the last week) upgraded our Flutter and packages dependencies, do that first. For details of how, see our README. +* Update translations from Weblate: + * Run the [GitHub action][weblate-github-action] to create a PR + (or update an existing bot PR) with translation updates. + * CI doesn't run on the bot's PRs. So if you suspect the PR might + break anything (e.g. if this is the first sync since changing + something in our Weblate setup), run `tools/check` on it yourself. + * Merge the PR. + * Write an entry in `docs/changelog.md`, under "Unreleased". Commit that change. * Run `tools/bump-version` to update the version number. Inspect the resulting commit and tag, and push. +[weblate-github-action]: https://github.com/zulip/zulip-flutter/actions/workflows/update-translations.yml + ## Build and upload alpha: Android @@ -143,6 +164,38 @@ "f123". This efficiently finds any threads that mentioned "#F123". +## Preview releases + +Sometimes we make a release that includes some experimental changes +not yet merged to the `main` branch, i.e. a "preview release". + +Steps specific to this type of release are: + +* To prepare the tree, start from main and use commands like + `git merge --no-ff pr/123456` to merge together the desired PRs. + + The use of `--no-ff` ensures that each such step creates an actual + merge commit. This is helpful because it means that a command like + `git log --first-parent --oneline origin..` + can print a list of exactly which PRs were included, by number. + That record is useful for understanding the relationship between + releases, and for re-creating a similar branch with updated versions + of the same PRs. + +* The changelog should distinguish, outside the "for users" section, + between changes in main and changes not yet in main. + See past examples; search for "experimental". + +* After the new release is uploaded, the changelog and version number + in main should be updated to match the new release. + + Try `git checkout -p v12.34.567 docs/changelog.md pubspec.yaml`. + Use the `-p` prompt to skip any other pubspec updates, such as + dependencies. Then + `git commit -am "version: Sync version and changelog from v12.34.567 release"` + (with the correct version number), and push. + + ## One-time or annual setup * You'll need the Google Play upload key. The setup is similar to diff --git a/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/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 7c56964006..0d14080090 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 15.0 diff --git a/ios/Flutter/Zulip.xcconfig b/ios/Flutter/Zulip.xcconfig index 983df5ef51..551f0909db 100644 --- a/ios/Flutter/Zulip.xcconfig +++ b/ios/Flutter/Zulip.xcconfig @@ -1,10 +1,2 @@ // Configuration settings file format documentation can be found at: // https://help.apple.com/xcode/#/dev745c5c974 - -// TODO(firebase/flutterfire#13323): remove this flag -// -// This flag is added to work around the iOS build failing -// on Xcode 16. Remove it when `package:firebase_messaging` -// is patched with a fix to build successfully on Xcode 16: -// https://github.com/firebase/flutterfire/issues/13323 -CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES=YES diff --git a/ios/Podfile b/ios/Podfile index 1fe9fe3125..836219f1e5 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,6 +1,7 @@ # This should match the iOS Deployment Target # (in Xcode, that's in project > Runner > Info) -platform :ios, '14.0' +# and MinimumOSVersion in ios/Flutter/AppFrameworkInfo.plist. +platform :ios, '15.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 27e8691603..b40b13c037 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -37,64 +37,64 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter - - Firebase/CoreOnly (11.6.0): - - FirebaseCore (~> 11.6.0) - - Firebase/Messaging (11.6.0): + - Firebase/CoreOnly (12.0.0): + - FirebaseCore (~> 12.0.0) + - Firebase/Messaging (12.0.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 11.6.0) - - firebase_core (3.10.0): - - Firebase/CoreOnly (= 11.6.0) + - FirebaseMessaging (~> 12.0.0) + - firebase_core (4.0.0): + - Firebase/CoreOnly (= 12.0.0) - Flutter - - firebase_messaging (15.2.0): - - Firebase/Messaging (= 11.6.0) + - firebase_messaging (16.0.0): + - Firebase/Messaging (= 12.0.0) - firebase_core - Flutter - - 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.6.0): - - FirebaseCore (~> 11.6.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseCore (12.0.0): + - FirebaseCoreInternal (~> 12.0.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (12.0.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (12.0.0): + - FirebaseCore (~> 12.0.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (11.6.0): - - FirebaseCore (~> 11.6.0) - - FirebaseInstallations (~> 11.0) - - GoogleDataTransport (~> 10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/Reachability (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseMessaging (12.0.0): + - FirebaseCore (~> 12.0.0) + - FirebaseInstallations (~> 12.0.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - nanopb (~> 3.30910.0) - Flutter (1.0.0) - GoogleDataTransport (10.1.0): - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - GoogleUtilities/AppDelegateSwizzler (8.0.2): + - GoogleUtilities/AppDelegateSwizzler (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (8.0.2): + - GoogleUtilities/Environment (8.1.0): - GoogleUtilities/Privacy - - GoogleUtilities/Logger (8.0.2): + - GoogleUtilities/Logger (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/Network (8.0.2): + - GoogleUtilities/Network (8.1.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (8.0.2)": + - "GoogleUtilities/NSData+zlib (8.1.0)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (8.0.2) - - GoogleUtilities/Reachability (8.0.2): + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (8.0.2): + - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - image_picker_ios (0.0.1): @@ -112,30 +112,36 @@ PODS: - Flutter - FlutterMacOS - PromisesObjC (2.4.0) - - SDWebImage (5.20.0): - - SDWebImage/Core (= 5.20.0) - - SDWebImage/Core (5.20.0) + - SDWebImage (5.21.1): + - SDWebImage/Core (= 5.21.1) + - SDWebImage/Core (5.21.1) - share_plus (0.0.1): - Flutter - - sqlite3 (3.47.2): - - sqlite3/common (= 3.47.2) - - sqlite3/common (3.47.2) - - sqlite3/dbstatvtab (3.47.2): + - sqlite3 (3.50.4): + - sqlite3/common (= 3.50.4) + - sqlite3/common (3.50.4) + - sqlite3/dbstatvtab (3.50.4): - sqlite3/common - - sqlite3/fts5 (3.47.2): + - sqlite3/fts5 (3.50.4): - sqlite3/common - - sqlite3/perf-threadsafe (3.47.2): + - sqlite3/math (3.50.4): - sqlite3/common - - sqlite3/rtree (3.47.2): + - sqlite3/perf-threadsafe (3.50.4): + - sqlite3/common + - sqlite3/rtree (3.50.4): + - sqlite3/common + - sqlite3/session (3.50.4): - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (~> 3.47.2) + - sqlite3 (~> 3.50.3) - sqlite3/dbstatvtab - sqlite3/fts5 + - sqlite3/math - sqlite3/perf-threadsafe - sqlite3/rtree + - sqlite3/session - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter @@ -212,36 +218,36 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - app_settings: 017320c6a680cdc94c799949d95b84cb69389ebc - device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 + app_settings: 5127ae0678de1dcc19f2293271c51d37c89428b2 + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 - Firebase: 374a441a91ead896215703a674d58cdb3e9d772b - firebase_core: feb37e79f775c2bd08dd35e02d83678291317e10 - firebase_messaging: e2f0ba891b1509668c07f5099761518a5af8fe3c - FirebaseCore: 48b0dd707581cf9c1a1220da68223fb0a562afaa - FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2 - FirebaseInstallations: efc0946fc756e4d22d8113f7c761948120322e8c - FirebaseMessaging: e1aca1fcc23e8b9eddb0e33f375ff90944623021 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + Firebase: 800d487043c0557d9faed71477a38d9aafb08a41 + firebase_core: 633e1851ffe1b9ab875f6467a4f574c79cef02e4 + firebase_messaging: d17feef781edc84ebefe62624fb384358ad96361 + FirebaseCore: 055f4ab117d5964158c833f3d5e7ec6d91648d4a + FirebaseCoreInternal: dedc28e569a4be85f38f3d6af1070a2e12018d55 + FirebaseInstallations: d4c7c958f99c8860d7fcece786314ae790e2f988 + FirebaseMessaging: af49f8d7c0a3d2a017d9302c80946f45a7777dde + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 - GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d - image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 - integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8 - share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f - sqlite3: 7559e33dae4c78538df563795af3a86fc887ee71 - sqlite3_flutter_libs: 58ae36c0dd086395d066b4fe4de9cdca83e717b3 + SDWebImage: f29024626962457f3470184232766516dee8dfea + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b + sqlite3_flutter_libs: 616267f2fca40e9c6af8c5d82324e05667040b6e SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe - video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 - wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 -PODFILE CHECKSUM: 7ed5116924b3be7e8fb75f7aada61e057028f5c7 +PODFILE CHECKSUM: f23347f4ef610d6b07bcd5d9dc9e3ed75fb84721 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b4928e2220..5b85ab272a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -12,7 +12,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34E9F082D776BEB0009AED2 /* Notifications.g.swift */; }; F311C174AF9C005CE4AADD72 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3EAE3F3F518B95B7BFEB4FE7 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -46,8 +46,8 @@ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B34E9F082D776BEB0009AED2 /* Notifications.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.g.swift; sourceTree = ""; }; B3AF53A72CA20BD10039801D /* Zulip.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Zulip.xcconfig; path = Flutter/Zulip.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ @@ -110,11 +110,11 @@ 3752899A2AF472D400475D9C /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + B34E9F082D776BEB0009AED2 /* Notifications.g.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -192,7 +192,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, @@ -297,6 +296,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -312,14 +312,6 @@ name = Main.storyboard; sourceTree = ""; }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ @@ -364,7 +356,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -388,7 +380,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.zulip.flutter; + PRODUCT_BUNDLE_IDENTIFIER = org.zulip.Zulip; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -443,7 +435,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -492,7 +484,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -518,7 +510,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.zulip.flutter; + PRODUCT_BUNDLE_IDENTIFIER = org.zulip.Zulip; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -542,7 +534,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.zulip.flutter; + PRODUCT_BUNDLE_IDENTIFIER = org.zulip.Zulip; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c53e2b314e..9c12df59c6 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> Bool { GeneratedPluginRegistrant.register(with: self) + + // Use `DesignVariables.mainBackground` color as the background color + // of the default UIView. + window?.backgroundColor = UIColor(named: "LaunchBackground"); + + let controller = window?.rootViewController as! FlutterViewController + + // Retrieve the remote notification payload from launch options; + // this will be null if the launch wasn't triggered by a notification. + let notificationPayload = launchOptions?[.remoteNotification] as? [AnyHashable : Any] + let api = NotificationHostApiImpl(notificationPayload.map { NotificationDataFromLaunch(payload: $0) }) + NotificationHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: api) + + notificationTapEventListener = NotificationTapEventListener() + NotificationTapEventsStreamHandler.register(with: controller.binaryMessenger, streamHandler: notificationTapEventListener!) + + UNUserNotificationCenter.current().delegate = self + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if response.actionIdentifier == UNNotificationDefaultActionIdentifier { + let userInfo = response.notification.request.content.userInfo + notificationTapEventListener!.onNotificationTapEvent(payload: userInfo) + } + completionHandler() + } +} + +private class NotificationHostApiImpl: NotificationHostApi { + private let maybeDataFromLaunch: NotificationDataFromLaunch? + + init(_ maybeDataFromLaunch: NotificationDataFromLaunch?) { + self.maybeDataFromLaunch = maybeDataFromLaunch + } + + func getNotificationDataFromLaunch() -> NotificationDataFromLaunch? { + maybeDataFromLaunch + } +} + +// Adapted from Pigeon's Swift example for @EventChannelApi: +// https://github.com/flutter/packages/blob/2dff6213a/packages/pigeon/example/app/ios/Runner/AppDelegate.swift#L49-L74 +class NotificationTapEventListener: NotificationTapEventsStreamHandler { + var eventSink: PigeonEventSink? + + override func onListen(withArguments arguments: Any?, sink: PigeonEventSink) { + eventSink = sink + } + + func onNotificationTapEvent(payload: [AnyHashable : Any]) { + eventSink?.success(NotificationTapEvent(payload: payload)) + } } diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024@1x.png index 3160075564..4d6b2fe976 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@2x.png index 924bd1d4d5..aefc1dbb51 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@3x.png index 8e1c3b4de6..d25093ebd7 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png index b6ca9f566a..c9cb394ddd 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png index 81f81d6a9a..fe7eb99eed 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png index a78fe76e08..07c53507e2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@3x.png index a95369e891..da0fba9728 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@2x.png index a95369e891..da0fba9728 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@3x.png index 00bdde6181..6dbeee644f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76x76@2x.png index 5de8d4f1e3..8694e5ff34 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-83.5x83.5@2x.png index 99793492e0..a347e86acc 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000000..43a02dcfa1 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF0", + "green" : "0xF0", + "red" : "0xF0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x1D", + "green" : "0x1D", + "red" : "0x1D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2fd4..0000000000 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19eacad..0000000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19eacad..0000000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19eacad..0000000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b70..0000000000 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c7c9..0000000000 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5f5d9c5ea2..489fb6d350 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Zulip beta + Zulip CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Zulip beta + Zulip CFBundlePackageType APPL CFBundleShortVersionString @@ -26,7 +26,7 @@ CFBundleURLName - com.zulip.flutter + org.zulip.Zulip CFBundleURLSchemes zulip @@ -52,8 +52,11 @@ fetch remote-notification - UILaunchStoryboardName - LaunchScreen + UILaunchScreen + + UIColorName + LaunchBackground + UIMainStoryboardFile Main UISupportedInterfaceOrientations diff --git a/ios/Runner/Notifications.g.swift b/ios/Runner/Notifications.g.swift new file mode 100644 index 0000000000..01e48f5a62 --- /dev/null +++ b/ios/Runner/Notifications.g.swift @@ -0,0 +1,335 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +func deepEqualsNotifications(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case is (Void, Void): + return true + + case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): + return cleanLhsHashable == cleanRhsHashable + + case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): + guard cleanLhsArray.count == cleanRhsArray.count else { return false } + for (index, element) in cleanLhsArray.enumerated() { + if !deepEqualsNotifications(element, cleanRhsArray[index]) { + return false + } + } + return true + + case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } + for (key, cleanLhsValue) in cleanLhsDictionary { + guard cleanRhsDictionary.index(forKey: key) != nil else { return false } + if !deepEqualsNotifications(cleanLhsValue, cleanRhsDictionary[key]!) { + return false + } + } + return true + + default: + // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. + return false + } +} + +func deepHashNotifications(value: Any?, hasher: inout Hasher) { + if let valueList = value as? [AnyHashable] { + for item in valueList { deepHashNotifications(value: item, hasher: &hasher) } + return + } + + if let valueDict = value as? [AnyHashable: AnyHashable] { + for key in valueDict.keys { + hasher.combine(key) + deepHashNotifications(value: valueDict[key]!, hasher: &hasher) + } + return + } + + if let hashableValue = value as? AnyHashable { + hasher.combine(hashableValue.hashValue) + } + + return hasher.combine(String(describing: value)) +} + + + +/// Generated class from Pigeon that represents data sent in messages. +struct NotificationDataFromLaunch: Hashable { + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + var payload: [AnyHashable?: Any?] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NotificationDataFromLaunch? { + let payload = pigeonVar_list[0] as! [AnyHashable?: Any?] + + return NotificationDataFromLaunch( + payload: payload + ) + } + func toList() -> [Any?] { + return [ + payload + ] + } + static func == (lhs: NotificationDataFromLaunch, rhs: NotificationDataFromLaunch) -> Bool { + return deepEqualsNotifications(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashNotifications(value: toList(), hasher: &hasher) + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct NotificationTapEvent: Hashable { + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [notificationTapEvents]. + var payload: [AnyHashable?: Any?] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NotificationTapEvent? { + let payload = pigeonVar_list[0] as! [AnyHashable?: Any?] + + return NotificationTapEvent( + payload: payload + ) + } + func toList() -> [Any?] { + return [ + payload + ] + } + static func == (lhs: NotificationTapEvent, rhs: NotificationTapEvent) -> Bool { + return deepEqualsNotifications(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashNotifications(value: toList(), hasher: &hasher) + } +} + +private class NotificationsPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return NotificationDataFromLaunch.fromList(self.readValue() as! [Any?]) + case 130: + return NotificationTapEvent.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class NotificationsPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? NotificationDataFromLaunch { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? NotificationTapEvent { + super.writeByte(130) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class NotificationsPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return NotificationsPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return NotificationsPigeonCodecWriter(data: data) + } +} + +class NotificationsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = NotificationsPigeonCodec(readerWriter: NotificationsPigeonCodecReaderWriter()) +} + +var notificationsPigeonMethodCodec = FlutterStandardMethodCodec(readerWriter: NotificationsPigeonCodecReaderWriter()); + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol NotificationHostApi { + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// Returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + func getNotificationDataFromLaunch() throws -> NotificationDataFromLaunch? +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class NotificationHostApiSetup { + static var codec: FlutterStandardMessageCodec { NotificationsPigeonCodec.shared } + /// Sets up an instance of `NotificationHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NotificationHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// Returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + let getNotificationDataFromLaunchChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getNotificationDataFromLaunchChannel.setMessageHandler { _, reply in + do { + let result = try api.getNotificationDataFromLaunch() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getNotificationDataFromLaunchChannel.setMessageHandler(nil) + } + } +} + +private class PigeonStreamHandler: NSObject, FlutterStreamHandler { + private let wrapper: PigeonEventChannelWrapper + private var pigeonSink: PigeonEventSink? = nil + + init(wrapper: PigeonEventChannelWrapper) { + self.wrapper = wrapper + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + pigeonSink = PigeonEventSink(events) + wrapper.onListen(withArguments: arguments, sink: pigeonSink!) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + pigeonSink = nil + wrapper.onCancel(withArguments: arguments) + return nil + } +} + +class PigeonEventChannelWrapper { + func onListen(withArguments arguments: Any?, sink: PigeonEventSink) {} + func onCancel(withArguments arguments: Any?) {} +} + +class PigeonEventSink { + private let sink: FlutterEventSink + + init(_ sink: @escaping FlutterEventSink) { + self.sink = sink + } + + func success(_ value: ReturnType) { + sink(value) + } + + func error(code: String, message: String?, details: Any?) { + sink(FlutterError(code: code, message: message, details: details)) + } + + func endOfStream() { + sink(FlutterEndOfEventStream) + } + +} + +class NotificationTapEventsStreamHandler: PigeonEventChannelWrapper { + static func register(with messenger: FlutterBinaryMessenger, + instanceName: String = "", + streamHandler: NotificationTapEventsStreamHandler) { + var channelName = "dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents" + if !instanceName.isEmpty { + channelName += ".\(instanceName)" + } + let internalStreamHandler = PigeonStreamHandler(wrapper: streamHandler) + let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: notificationsPigeonMethodCodec) + channel.setStreamHandler(internalStreamHandler) + } +} + diff --git a/l10n.yaml b/l10n.yaml index 6d15a20096..563219f948 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,7 +1,6 @@ # Docs on this config file: # https://docs.flutter.dev/ui/accessibility-and-localization/internationalization#configuring-the-l10nyaml-file -synthetic-package: false arb-dir: assets/l10n output-dir: lib/generated/l10n template-arb-file: app_en.arb diff --git a/lib/api/core.dart b/lib/api/core.dart index 96f9e2db11..b4d05b5b34 100644 --- a/lib/api/core.dart +++ b/lib/api/core.dart @@ -10,6 +10,25 @@ import '../model/binding.dart'; import '../model/localizations.dart'; import 'exception.dart'; +/// The Zulip Server version below which we should refuse to connect. +/// +/// When updating this, also update [kMinSupportedZulipFeatureLevel] +/// and the README. +// TODO(#992) address all TODO(server-6) and TODO(server-7) +const kMinSupportedZulipVersion = '7.0'; + +/// The Zulip feature level reserved for the [kMinSupportedZulipVersion] release. +/// +/// For this value, see the API changelog: +/// https://zulip.com/api/changelog +const kMinSupportedZulipFeatureLevel = 185; + +/// The doc stating our oldest supported server version. +// TODO: Instead, link to new Help Center doc once we have it: +// https://github.com/zulip/zulip/issues/23842 +final kServerSupportDocUrl = Uri.parse( + 'https://zulip.readthedocs.io/en/latest/overview/release-lifecycle.html#client-apps'); + /// A fused JSON + UTF-8 decoder. /// /// This object is an instance of [`_JsonUtf8Decoder`][1] which is diff --git a/lib/api/exception.dart b/lib/api/exception.dart index d4bebeeb62..d495ec9ff8 100644 --- a/lib/api/exception.dart +++ b/lib/api/exception.dart @@ -81,7 +81,10 @@ class ZulipApiException extends HttpException { required this.code, required this.data, required super.message, - }) : assert(400 <= httpStatus && httpStatus <= 499); + }) : assert(400 <= httpStatus && httpStatus <= 499), + assert(!data.containsKey('result') + && !data.containsKey('code') + && !data.containsKey('msg')); @override String toString() { diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 9faa3d367e..94bf662e30 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -30,6 +30,17 @@ sealed class Event { default: return UnexpectedEvent.fromJson(json); } case 'custom_profile_fields': return CustomProfileFieldsEvent.fromJson(json); + case 'user_group': + switch (json['op'] as String) { + case 'add': return UserGroupAddEvent.fromJson(json); + case 'update': return UserGroupUpdateEvent.fromJson(json); + case 'add_members': return UserGroupAddMembersEvent.fromJson(json); + case 'remove_members': return UserGroupRemoveMembersEvent.fromJson(json); + case 'add_subgroups': return UserGroupAddSubgroupsEvent.fromJson(json); + case 'remove_subgroups': return UserGroupRemoveSubgroupsEvent.fromJson(json); + case 'remove': return UserGroupRemoveEvent.fromJson(json); + default: return UnexpectedEvent.fromJson(json); + } case 'realm_user': switch (json['op'] as String) { case 'add': return RealmUserAddEvent.fromJson(json); @@ -37,6 +48,13 @@ sealed class Event { case 'update': return RealmUserUpdateEvent.fromJson(json); default: return UnexpectedEvent.fromJson(json); } + case 'saved_snippets': + switch (json['op'] as String) { + case 'add': return SavedSnippetsAddEvent.fromJson(json); + case 'update': return SavedSnippetsUpdateEvent.fromJson(json); + case 'remove': return SavedSnippetsRemoveEvent.fromJson(json); + default: return UnexpectedEvent.fromJson(json); + } case 'stream': switch (json['op'] as String) { case 'create': return ChannelCreateEvent.fromJson(json); @@ -54,7 +72,9 @@ sealed class Event { default: return UnexpectedEvent.fromJson(json); } // case 'muted_topics': … // TODO(#422) we ignore this feature on older servers + case 'user_status': return UserStatusEvent.fromJson(json); case 'user_topic': return UserTopicEvent.fromJson(json); + case 'muted_users': return MutedUsersEvent.fromJson(json); case 'message': return MessageEvent.fromJson(json); case 'update_message': return UpdateMessageEvent.fromJson(json); case 'delete_message': return DeleteMessageEvent.fromJson(json); @@ -66,6 +86,7 @@ sealed class Event { } case 'submessage': return SubmessageEvent.fromJson(json); case 'typing': return TypingEvent.fromJson(json); + case 'presence': return PresenceEvent.fromJson(json); case 'reaction': return ReactionEvent.fromJson(json); case 'heartbeat': return HeartbeatEvent.fromJson(json); // TODO add many more event types @@ -157,10 +178,13 @@ class UserSettingsUpdateEvent extends Event { final value = json['value']; switch (UserSettingName.fromRawString(json['property'] as String)) { case UserSettingName.twentyFourHourTime: + return TwentyFourHourTimeMode.fromApiValue(value as bool?); case UserSettingName.displayEmojiReactionUsers: return value as bool; case UserSettingName.emojiset: return Emojiset.fromRawString(value as String); + case UserSettingName.presenceEnabled: + return value as bool; case null: return null; } @@ -197,6 +221,157 @@ class CustomProfileFieldsEvent extends Event { Map toJson() => _$CustomProfileFieldsEventToJson(this); } +/// A Zulip event of type `user_group`. +/// +/// See API docs starting at: +/// https://zulip.com/api/get-events#user_group-add +sealed class UserGroupEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'user_group'; + + String get op; + + UserGroupEvent({required super.id}); +} + +/// A [UserGroupEvent] with op `add`: https://zulip.com/api/get-events#user_group-add +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroupAddEvent extends UserGroupEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'add'; + + final UserGroup group; + + UserGroupAddEvent({required super.id, required this.group}); + + factory UserGroupAddEvent.fromJson(Map json) => _$UserGroupAddEventFromJson(json); + + @override + Map toJson() => _$UserGroupAddEventToJson(this); +} + +/// A [UserGroupEvent] with op `update`: https://zulip.com/api/get-events#user_group-update +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroupUpdateEvent extends UserGroupEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'update'; + + final int groupId; + final UserGroupUpdateData data; + + UserGroupUpdateEvent({required super.id, required this.groupId, required this.data}); + + factory UserGroupUpdateEvent.fromJson(Map json) => _$UserGroupUpdateEventFromJson(json); + + @override + Map toJson() => _$UserGroupUpdateEventToJson(this); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroupUpdateData { + final String? name; + final String? description; + final bool? deactivated; + + UserGroupUpdateData({required this.name, required this.description, required this.deactivated}); + + factory UserGroupUpdateData.fromJson(Map json) => _$UserGroupUpdateDataFromJson(json); + + Map toJson() => _$UserGroupUpdateDataToJson(this); +} + +/// A [UserGroupEvent] with op `add_members`: https://zulip.com/api/get-events#user_group-add_members +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroupAddMembersEvent extends UserGroupEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'add_members'; + + final int groupId; + final List userIds; + + UserGroupAddMembersEvent({required super.id, required this.groupId, required this.userIds}); + + factory UserGroupAddMembersEvent.fromJson(Map json) => _$UserGroupAddMembersEventFromJson(json); + + @override + Map toJson() => _$UserGroupAddMembersEventToJson(this); +} + +/// A [UserGroupEvent] with op `remove_members`: https://zulip.com/api/get-events#user_group-remove_members +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroupRemoveMembersEvent extends UserGroupEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'remove_members'; + + final int groupId; + final List userIds; + + UserGroupRemoveMembersEvent({required super.id, required this.groupId, required this.userIds}); + + factory UserGroupRemoveMembersEvent.fromJson(Map json) => _$UserGroupRemoveMembersEventFromJson(json); + + @override + Map toJson() => _$UserGroupRemoveMembersEventToJson(this); +} + +/// A [UserGroupEvent] with op `add_subgroups`: https://zulip.com/api/get-events#user_group-add_subgroups +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroupAddSubgroupsEvent extends UserGroupEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'add_subgroups'; + + final int groupId; + final List directSubgroupIds; + + UserGroupAddSubgroupsEvent({required super.id, required this.groupId, required this.directSubgroupIds}); + + factory UserGroupAddSubgroupsEvent.fromJson(Map json) => _$UserGroupAddSubgroupsEventFromJson(json); + + @override + Map toJson() => _$UserGroupAddSubgroupsEventToJson(this); +} + +/// A [UserGroupEvent] with op `remove_subgroups`: https://zulip.com/api/get-events#user_group-remove_subgroups +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroupRemoveSubgroupsEvent extends UserGroupEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'remove_subgroups'; + + final int groupId; + final List directSubgroupIds; + + UserGroupRemoveSubgroupsEvent({required super.id, required this.groupId, required this.directSubgroupIds}); + + factory UserGroupRemoveSubgroupsEvent.fromJson(Map json) => _$UserGroupRemoveSubgroupsEventFromJson(json); + + @override + Map toJson() => _$UserGroupRemoveSubgroupsEventToJson(this); +} + +/// A [UserGroupEvent] with op `remove`: https://zulip.com/api/get-events#user_group-remove +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroupRemoveEvent extends UserGroupEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'remove'; + + final int groupId; + + UserGroupRemoveEvent({required super.id, required this.groupId}); + + factory UserGroupRemoveEvent.fromJson(Map json) => _$UserGroupRemoveEventFromJson(json); + + @override + Map toJson() => _$UserGroupRemoveEventToJson(this); +} + /// A Zulip event of type `realm_user`. /// /// The corresponding API docs are in several places for @@ -283,7 +458,6 @@ class RealmUserUpdateEvent extends RealmUserEvent { @JsonKey(readValue: _readFromPerson) final String? timezone; @JsonKey(readValue: _readFromPerson) final int? botOwnerId; @JsonKey(readValue: _readFromPerson, unknownEnumValue: UserRole.unknown) final UserRole? role; - @JsonKey(readValue: _readFromPerson) final bool? isBillingAdmin; @JsonKey(readValue: _readNullableStringFromPerson) @NullableStringJsonConverter() @@ -321,7 +495,6 @@ class RealmUserUpdateEvent extends RealmUserEvent { this.timezone, this.botOwnerId, this.role, - this.isBillingAdmin, this.deliveryEmail, this.customProfileField, this.newEmail, @@ -336,6 +509,68 @@ class RealmUserUpdateEvent extends RealmUserEvent { Map toJson() => _$RealmUserUpdateEventToJson(this); } +/// A Zulip event of type `saved_snippets`: https://zulip.com/api/get-events#saved_snippets-add +sealed class SavedSnippetsEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'saved_snippets'; + + String get op; + + SavedSnippetsEvent({required super.id}); +} + +/// A [SavedSnippetsEvent] with op `add`: https://zulip.com/api/get-events#saved_snippets-add +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsAddEvent extends SavedSnippetsEvent { + @override + String get op => 'add'; + + final SavedSnippet savedSnippet; + + SavedSnippetsAddEvent({required super.id, required this.savedSnippet}); + + factory SavedSnippetsAddEvent.fromJson(Map json) => + _$SavedSnippetsAddEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsAddEventToJson(this); +} + +/// A [SavedSnippetsEvent] with op `update`: https://zulip.com/api/get-events#saved_snippets-update +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsUpdateEvent extends SavedSnippetsEvent { + @override + String get op => 'update'; + + final SavedSnippet savedSnippet; + + SavedSnippetsUpdateEvent({required super.id, required this.savedSnippet}); + + factory SavedSnippetsUpdateEvent.fromJson(Map json) => + _$SavedSnippetsUpdateEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsUpdateEventToJson(this); +} + +/// A [SavedSnippetsEvent] with op `remove`: https://zulip.com/api/get-events#saved_snippets-remove +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsRemoveEvent extends SavedSnippetsEvent { + @override + String get op => 'remove'; + + final int savedSnippetId; + + SavedSnippetsRemoveEvent({required super.id, required this.savedSnippetId}); + + factory SavedSnippetsRemoveEvent.fromJson(Map json) => + _$SavedSnippetsRemoveEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsRemoveEventToJson(this); +} + /// A Zulip event of type `stream`. /// /// The corresponding API docs are in several places for @@ -429,6 +664,9 @@ class ChannelUpdateEvent extends ChannelEvent { final value = json['value']; switch (ChannelPropertyName.fromRawString(json['property'] as String)) { case ChannelPropertyName.name: + return value as String; + case ChannelPropertyName.isArchived: + return value as bool; case ChannelPropertyName.description: return value as String; case ChannelPropertyName.firstMessageId: @@ -439,6 +677,11 @@ class ChannelUpdateEvent extends ChannelEvent { return value as int?; case ChannelPropertyName.channelPostPolicy: return ChannelPostPolicy.fromApiValue(value as int); + case ChannelPropertyName.canAddSubscribersGroup: + case ChannelPropertyName.canDeleteAnyMessageGroup: + case ChannelPropertyName.canDeleteOwnMessageGroup: + case ChannelPropertyName.canSubscribeGroup: + return GroupSettingValue.fromJson(value); case ChannelPropertyName.streamWeeklyTraffic: return value as int?; case null: @@ -637,6 +880,41 @@ class SubscriptionPeerRemoveEvent extends SubscriptionEvent { Map toJson() => _$SubscriptionPeerRemoveEventToJson(this); } +/// A Zulip event of type `user_status`: https://zulip.com/api/get-events#user_status +@JsonSerializable(fieldRename: FieldRename.snake, createToJson: false) +class UserStatusEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'user_status'; + + final int userId; + + @JsonKey(readValue: _readChange) + final UserStatusChange change; + + static Object? _readChange(Map json, String key) { + assert(json is Map); // value came through `fromJson` with this type + return json; + } + + UserStatusEvent({ + required super.id, + required this.userId, + required this.change, + }); + + factory UserStatusEvent.fromJson(Map json) => + _$UserStatusEventFromJson(json); + + @override + Map toJson() => { + 'id': id, + 'type': type, + 'user_id': userId, + ...change.toJson(), + }; +} + /// A Zulip event of type `user_topic`: https://zulip.com/api/get-events#user_topic @JsonSerializable(fieldRename: FieldRename.snake) class UserTopicEvent extends Event { @@ -664,9 +942,26 @@ class UserTopicEvent extends Event { Map toJson() => _$UserTopicEventToJson(this); } +/// A Zulip event of type `muted_users`: https://zulip.com/api/get-events#muted_users +@JsonSerializable(fieldRename: FieldRename.snake) +class MutedUsersEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'muted_users'; + + final List mutedUsers; + + MutedUsersEvent({required super.id, required this.mutedUsers}); + + factory MutedUsersEvent.fromJson(Map json) => + _$MutedUsersEventFromJson(json); + + @override + Map toJson() => _$MutedUsersEventToJson(this); +} + /// A Zulip event of type `message`: https://zulip.com/api/get-events#message -// TODO use [JsonSerializable] here too, using its customization features, -// in order to skip the boilerplate in [fromJson] and [toJson]. +@JsonSerializable(fieldRename: FieldRename.snake) class MessageEvent extends Event { @override @JsonKey(includeToJson: true) @@ -680,24 +975,30 @@ class MessageEvent extends Event { // events and in the get-messages results is that `matchContent` and // `matchTopic` are absent here. Already [Message.matchContent] and // [Message.matchTopic] are optional, so no action is needed on that. + @JsonKey(readValue: _readMessageValue, fromJson: Message.fromJson, includeToJson: false) final Message message; - MessageEvent({required super.id, required this.message}); + // When present, this equals the "local_id" parameter + // from a previous [sendMessage] call by us. + // + // This is not yet fully documented. See CZO discussion for reference: + // https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/local_id.2C.20queue_id.2Fsender_queue_id/near/2135340 + final String? localMessageId; + + MessageEvent({required super.id, required this.message, required this.localMessageId}); - factory MessageEvent.fromJson(Map json) => MessageEvent( - id: json['id'] as int, - message: Message.fromJson({ - ...json['message'] as Map, - 'flags': (json['flags'] as List).map((e) => e as String).toList(), - }), - ); + static Map _readMessageValue(Map json, String key) => + {...json['message'] as Map, 'flags': json['flags']}; + + factory MessageEvent.fromJson(Map json) => + _$MessageEventFromJson(json); @override Map toJson() { final messageJson = message.toJson(); final flags = messageJson['flags']; messageJson.remove('flags'); - return {'id': id, 'type': type, 'message': messageJson, 'flags': flags}; + return {..._$MessageEventToJson(this), 'message': messageJson, 'flags': flags}; } } @@ -708,26 +1009,18 @@ class UpdateMessageEvent extends Event { @JsonKey(includeToJson: true) String get type => 'update_message'; - final int? userId; // TODO(server-5) - final bool? renderingOnly; // TODO(server-5) + final int? userId; + final bool renderingOnly; final int messageId; final List messageIds; final List flags; - final int? editTimestamp; // TODO(server-5) + final int editTimestamp; // final String? streamName; // ignore - @JsonKey(name: 'stream_id') - final int? origStreamId; - final int? newStreamId; - - final PropagateMode? propagateMode; - - @JsonKey(name: 'orig_subject') - final TopicName? origTopic; - @JsonKey(name: 'subject') - final TopicName? newTopic; + @JsonKey(readValue: _readMoveData, fromJson: UpdateMessageMoveData.tryParseFromJson, includeToJson: false) + final UpdateMessageMoveData? moveData; // final List topicLinks; // TODO handle @@ -747,11 +1040,7 @@ class UpdateMessageEvent extends Event { required this.messageIds, required this.flags, required this.editTimestamp, - required this.origStreamId, - required this.newStreamId, - required this.propagateMode, - required this.origTopic, - required this.newTopic, + required this.moveData, required this.origContent, required this.origRenderedContent, required this.content, @@ -759,6 +1048,12 @@ class UpdateMessageEvent extends Event { required this.isMeMessage, }); + static Map _readMoveData(Map json, String key) { + // Parsing [UpdateMessageMoveData] requires `json`, not the default `json[key]`. + assert(json is Map); // value came through `fromJson` with this type + return json as Map; + } + factory UpdateMessageEvent.fromJson(Map json) => _$UpdateMessageEventFromJson(json); @@ -766,6 +1061,72 @@ class UpdateMessageEvent extends Event { Map toJson() => _$UpdateMessageEventToJson(this); } +/// Data structure representing a message move. +class UpdateMessageMoveData { + final int origStreamId; + final int newStreamId; + final TopicName origTopic; + final TopicName newTopic; + final PropagateMode propagateMode; + + UpdateMessageMoveData({ + required this.origStreamId, + required this.newStreamId, + required this.origTopic, + required this.newTopic, + required this.propagateMode, + }) : assert(newStreamId != origStreamId || newTopic != origTopic); + + /// Try to extract [UpdateMessageMoveData] from the JSON object for an + /// [UpdateMessageEvent]. + /// + /// Returns `null` if there was no message move. + /// + /// Throws an error if the data is malformed. + // When parsing this, 'stream_id', which is also present when there was only + // a content edit, cannot be recovered if this ends up returning `null`. + // This may matter if we ever need 'stream_id' when no message move occurred. + static UpdateMessageMoveData? tryParseFromJson(Map json) { + final origStreamId = (json['stream_id'] as num?)?.toInt(); + final newStreamIdRaw = (json['new_stream_id'] as num?)?.toInt(); + final newStreamId = newStreamIdRaw ?? origStreamId; + + final origTopic = json['orig_subject'] == null ? null + : TopicName.fromJson(json['orig_subject'] as String); + final newTopicRaw = json['subject'] == null ? null + : TopicName.fromJson(json['subject'] as String); + final newTopic = newTopicRaw ?? origTopic; + + final propagateModeString = json['propagate_mode'] as String?; + final propagateMode = propagateModeString == null ? null + : PropagateMode.fromRawString(propagateModeString); + + if (newStreamId == origStreamId && newTopic == origTopic) { + if (propagateMode != null) { + throw FormatException( + 'Malformed UpdateMessageEvent: incoherent message-move fields; ' + 'propagate_mode present but no new channel or topic'); + } + return null; + } + + return UpdateMessageMoveData( + // The `stream_id` field (aka origStreamId) is documented to be present on moves; + // newStreamId should not be null either because it falls back to origStreamId. + origStreamId: origStreamId!, + newStreamId: newStreamId!, + + // The `orig_subject` field (aka origTopic) is documented to be present on moves; + // newTopic should not be null either because it falls back to origTopic. + origTopic: origTopic!, + newTopic: newTopic!, + + // The `propagate_mode` field (aka propagateMode) is documented to be present on moves. + propagateMode: propagateMode!, + ); + } +} + /// A Zulip event of type `delete_message`: https://zulip.com/api/get-events#delete_message @JsonSerializable(fieldRename: FieldRename.snake) class DeleteMessageEvent extends Event { @@ -893,10 +1254,7 @@ class UpdateMessageFlagsRemoveEvent extends UpdateMessageFlagsEvent { factory UpdateMessageFlagsRemoveEvent.fromJson(Map json) { final result = _$UpdateMessageFlagsRemoveEventFromJson(json); // Crunchy-shell validation - if ( - result.flag == MessageFlag.read - && true // (we assume `event_types` has `message` and `update_message_flags`) - ) { + if (result.flag == MessageFlag.read) { result.messageDetails as Map; } return result; @@ -1042,6 +1400,69 @@ enum TypingOp { String toJson() => _$TypingOpEnumMap[this]!; } +/// A Zulip event of type `presence`. +/// +/// See: +/// https://zulip.com/api/get-events#presence +@JsonSerializable(fieldRename: FieldRename.snake) +class PresenceEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'presence'; + + final int userId; + // final String email; // deprecated; ignore + final int serverTimestamp; + final Map presence; + + PresenceEvent({ + required super.id, + required this.userId, + required this.serverTimestamp, + required this.presence, + }); + + factory PresenceEvent.fromJson(Map json) => + _$PresenceEventFromJson(json); + + @override + Map toJson() => _$PresenceEventToJson(this); +} + +/// A value in [PresenceEvent.presence]. +/// +/// The "per client" name follows the event's structure, +/// but that structure is already an API wart; see the doc's "Changes" note +/// on [client] and on the `client_name` key of the map that holds these values: +/// +/// https://zulip.com/api/get-events#presence +/// > Starting with Zulip 7.0 (feature level 178), this will always be "website" +/// > as the server no longer stores which client submitted presence updates. +/// +/// This will probably be deprecated in favor of a form like [PerUserPresence]. +/// See #1611 and discussion: +/// https://chat.zulip.org/#narrow/channel/378-api-design/topic/presence.20rewrite/near/2200812 +// TODO(#1611) update comment about #1611 +@JsonSerializable(fieldRename: FieldRename.snake) +class PerClientPresence { + final String client; // always "website" (on 7.0+, so on all supported servers) + final PresenceStatus status; + final int timestamp; + final bool pushable; // always false (on 7.0+, so on all supported servers) + + PerClientPresence({ + required this.client, + required this.status, + required this.timestamp, + required this.pushable, + }); + + factory PerClientPresence.fromJson(Map json) => + _$PerClientPresenceFromJson(json); + + Map toJson() => _$PerClientPresenceToJson(this); +} + /// A Zulip event of type `reaction`, with op `add` or `remove`. /// /// See: diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 5d47444ecd..398802892a 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -9,23 +9,22 @@ part of 'events.dart'; // ************************************************************************** RealmEmojiUpdateEvent _$RealmEmojiUpdateEventFromJson( - Map json) => - RealmEmojiUpdateEvent( - id: (json['id'] as num).toInt(), - realmEmoji: (json['realm_emoji'] as Map).map( - (k, e) => - MapEntry(k, RealmEmojiItem.fromJson(e as Map)), - ), - ); + Map json, +) => RealmEmojiUpdateEvent( + id: (json['id'] as num).toInt(), + realmEmoji: (json['realm_emoji'] as Map).map( + (k, e) => MapEntry(k, RealmEmojiItem.fromJson(e as Map)), + ), +); Map _$RealmEmojiUpdateEventToJson( - RealmEmojiUpdateEvent instance) => - { - 'id': instance.id, - 'type': instance.type, - 'op': instance.op, - 'realm_emoji': instance.realmEmoji, - }; + RealmEmojiUpdateEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'realm_emoji': instance.realmEmoji, +}; AlertWordsEvent _$AlertWordsEventFromJson(Map json) => AlertWordsEvent( @@ -43,47 +42,194 @@ Map _$AlertWordsEventToJson(AlertWordsEvent instance) => }; UserSettingsUpdateEvent _$UserSettingsUpdateEventFromJson( - Map json) => - UserSettingsUpdateEvent( - id: (json['id'] as num).toInt(), - property: $enumDecodeNullable(_$UserSettingNameEnumMap, json['property'], - unknownValue: JsonKey.nullForUndefinedEnumValue), - value: UserSettingsUpdateEvent._readValue(json, 'value'), - ); + Map json, +) => UserSettingsUpdateEvent( + id: (json['id'] as num).toInt(), + property: $enumDecodeNullable( + _$UserSettingNameEnumMap, + json['property'], + unknownValue: JsonKey.nullForUndefinedEnumValue, + ), + value: UserSettingsUpdateEvent._readValue(json, 'value'), +); Map _$UserSettingsUpdateEventToJson( - UserSettingsUpdateEvent instance) => - { - 'id': instance.id, - 'type': instance.type, - 'op': instance.op, - 'property': _$UserSettingNameEnumMap[instance.property], - 'value': instance.value, - }; + UserSettingsUpdateEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'property': instance.property, + 'value': instance.value, +}; const _$UserSettingNameEnumMap = { UserSettingName.twentyFourHourTime: 'twenty_four_hour_time', UserSettingName.displayEmojiReactionUsers: 'display_emoji_reaction_users', UserSettingName.emojiset: 'emojiset', + UserSettingName.presenceEnabled: 'presence_enabled', }; CustomProfileFieldsEvent _$CustomProfileFieldsEventFromJson( - Map json) => - CustomProfileFieldsEvent( + Map json, +) => CustomProfileFieldsEvent( + id: (json['id'] as num).toInt(), + fields: (json['fields'] as List) + .map((e) => CustomProfileField.fromJson(e as Map)) + .toList(), +); + +Map _$CustomProfileFieldsEventToJson( + CustomProfileFieldsEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'fields': instance.fields, +}; + +UserGroupAddEvent _$UserGroupAddEventFromJson(Map json) => + UserGroupAddEvent( id: (json['id'] as num).toInt(), - fields: (json['fields'] as List) - .map((e) => CustomProfileField.fromJson(e as Map)) - .toList(), + group: UserGroup.fromJson(json['group'] as Map), ); -Map _$CustomProfileFieldsEventToJson( - CustomProfileFieldsEvent instance) => +Map _$UserGroupAddEventToJson(UserGroupAddEvent instance) => { 'id': instance.id, 'type': instance.type, - 'fields': instance.fields, + 'op': instance.op, + 'group': instance.group, }; +UserGroupUpdateEvent _$UserGroupUpdateEventFromJson( + Map json, +) => UserGroupUpdateEvent( + id: (json['id'] as num).toInt(), + groupId: (json['group_id'] as num).toInt(), + data: UserGroupUpdateData.fromJson(json['data'] as Map), +); + +Map _$UserGroupUpdateEventToJson( + UserGroupUpdateEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'group_id': instance.groupId, + 'data': instance.data, +}; + +UserGroupUpdateData _$UserGroupUpdateDataFromJson(Map json) => + UserGroupUpdateData( + name: json['name'] as String?, + description: json['description'] as String?, + deactivated: json['deactivated'] as bool?, + ); + +Map _$UserGroupUpdateDataToJson( + UserGroupUpdateData instance, +) => { + 'name': instance.name, + 'description': instance.description, + 'deactivated': instance.deactivated, +}; + +UserGroupAddMembersEvent _$UserGroupAddMembersEventFromJson( + Map json, +) => UserGroupAddMembersEvent( + id: (json['id'] as num).toInt(), + groupId: (json['group_id'] as num).toInt(), + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); + +Map _$UserGroupAddMembersEventToJson( + UserGroupAddMembersEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'group_id': instance.groupId, + 'user_ids': instance.userIds, +}; + +UserGroupRemoveMembersEvent _$UserGroupRemoveMembersEventFromJson( + Map json, +) => UserGroupRemoveMembersEvent( + id: (json['id'] as num).toInt(), + groupId: (json['group_id'] as num).toInt(), + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); + +Map _$UserGroupRemoveMembersEventToJson( + UserGroupRemoveMembersEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'group_id': instance.groupId, + 'user_ids': instance.userIds, +}; + +UserGroupAddSubgroupsEvent _$UserGroupAddSubgroupsEventFromJson( + Map json, +) => UserGroupAddSubgroupsEvent( + id: (json['id'] as num).toInt(), + groupId: (json['group_id'] as num).toInt(), + directSubgroupIds: (json['direct_subgroup_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); + +Map _$UserGroupAddSubgroupsEventToJson( + UserGroupAddSubgroupsEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'group_id': instance.groupId, + 'direct_subgroup_ids': instance.directSubgroupIds, +}; + +UserGroupRemoveSubgroupsEvent _$UserGroupRemoveSubgroupsEventFromJson( + Map json, +) => UserGroupRemoveSubgroupsEvent( + id: (json['id'] as num).toInt(), + groupId: (json['group_id'] as num).toInt(), + directSubgroupIds: (json['direct_subgroup_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); + +Map _$UserGroupRemoveSubgroupsEventToJson( + UserGroupRemoveSubgroupsEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'group_id': instance.groupId, + 'direct_subgroup_ids': instance.directSubgroupIds, +}; + +UserGroupRemoveEvent _$UserGroupRemoveEventFromJson( + Map json, +) => UserGroupRemoveEvent( + id: (json['id'] as num).toInt(), + groupId: (json['group_id'] as num).toInt(), +); + +Map _$UserGroupRemoveEventToJson( + UserGroupRemoveEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'group_id': instance.groupId, +}; + RealmUserAddEvent _$RealmUserAddEventFromJson(Map json) => RealmUserAddEvent( id: (json['id'] as num).toInt(), @@ -99,85 +245,83 @@ Map _$RealmUserAddEventToJson(RealmUserAddEvent instance) => }; RealmUserUpdateCustomProfileField _$RealmUserUpdateCustomProfileFieldFromJson( - Map json) => - RealmUserUpdateCustomProfileField( - id: (json['id'] as num).toInt(), - value: json['value'] as String?, - renderedValue: json['rendered_value'] as String?, - ); + Map json, +) => RealmUserUpdateCustomProfileField( + id: (json['id'] as num).toInt(), + value: json['value'] as String?, + renderedValue: json['rendered_value'] as String?, +); Map _$RealmUserUpdateCustomProfileFieldToJson( - RealmUserUpdateCustomProfileField instance) => - { - 'id': instance.id, - 'value': instance.value, - 'rendered_value': instance.renderedValue, - }; + RealmUserUpdateCustomProfileField instance, +) => { + 'id': instance.id, + 'value': instance.value, + 'rendered_value': instance.renderedValue, +}; RealmUserUpdateEvent _$RealmUserUpdateEventFromJson( - Map json) => - RealmUserUpdateEvent( - id: (json['id'] as num).toInt(), - userId: (RealmUserUpdateEvent._readFromPerson(json, 'user_id') as num) - .toInt(), - fullName: - RealmUserUpdateEvent._readFromPerson(json, 'full_name') as String?, - avatarUrl: - RealmUserUpdateEvent._readFromPerson(json, 'avatar_url') as String?, - avatarVersion: - (RealmUserUpdateEvent._readFromPerson(json, 'avatar_version') as num?) - ?.toInt(), - timezone: - RealmUserUpdateEvent._readFromPerson(json, 'timezone') as String?, - botOwnerId: - (RealmUserUpdateEvent._readFromPerson(json, 'bot_owner_id') as num?) - ?.toInt(), - role: $enumDecodeNullable( - _$UserRoleEnumMap, RealmUserUpdateEvent._readFromPerson(json, 'role'), - unknownValue: UserRole.unknown), - isBillingAdmin: - RealmUserUpdateEvent._readFromPerson(json, 'is_billing_admin') - as bool?, - deliveryEmail: - _$JsonConverterFromJson, JsonNullable>( - RealmUserUpdateEvent._readNullableStringFromPerson( - json, 'delivery_email'), - const NullableStringJsonConverter().fromJson), - customProfileField: RealmUserUpdateEvent._readFromPerson( - json, 'custom_profile_field') == - null - ? null - : RealmUserUpdateCustomProfileField.fromJson( - RealmUserUpdateEvent._readFromPerson(json, 'custom_profile_field') - as Map), - newEmail: - RealmUserUpdateEvent._readFromPerson(json, 'new_email') as String?, - isActive: - RealmUserUpdateEvent._readFromPerson(json, 'is_active') as bool?, - ); + Map json, +) => RealmUserUpdateEvent( + id: (json['id'] as num).toInt(), + userId: (RealmUserUpdateEvent._readFromPerson(json, 'user_id') as num) + .toInt(), + fullName: RealmUserUpdateEvent._readFromPerson(json, 'full_name') as String?, + avatarUrl: + RealmUserUpdateEvent._readFromPerson(json, 'avatar_url') as String?, + avatarVersion: + (RealmUserUpdateEvent._readFromPerson(json, 'avatar_version') as num?) + ?.toInt(), + timezone: RealmUserUpdateEvent._readFromPerson(json, 'timezone') as String?, + botOwnerId: + (RealmUserUpdateEvent._readFromPerson(json, 'bot_owner_id') as num?) + ?.toInt(), + role: $enumDecodeNullable( + _$UserRoleEnumMap, + RealmUserUpdateEvent._readFromPerson(json, 'role'), + unknownValue: UserRole.unknown, + ), + deliveryEmail: + _$JsonConverterFromJson, JsonNullable>( + RealmUserUpdateEvent._readNullableStringFromPerson( + json, + 'delivery_email', + ), + const NullableStringJsonConverter().fromJson, + ), + customProfileField: + RealmUserUpdateEvent._readFromPerson(json, 'custom_profile_field') == null + ? null + : RealmUserUpdateCustomProfileField.fromJson( + RealmUserUpdateEvent._readFromPerson(json, 'custom_profile_field') + as Map, + ), + newEmail: RealmUserUpdateEvent._readFromPerson(json, 'new_email') as String?, + isActive: RealmUserUpdateEvent._readFromPerson(json, 'is_active') as bool?, +); Map _$RealmUserUpdateEventToJson( - RealmUserUpdateEvent instance) => - { - 'id': instance.id, - 'type': instance.type, - 'op': instance.op, - 'user_id': instance.userId, - 'full_name': instance.fullName, - 'avatar_url': instance.avatarUrl, - 'avatar_version': instance.avatarVersion, - 'timezone': instance.timezone, - 'bot_owner_id': instance.botOwnerId, - 'role': instance.role, - 'is_billing_admin': instance.isBillingAdmin, - 'delivery_email': - _$JsonConverterToJson, JsonNullable>( - instance.deliveryEmail, - const NullableStringJsonConverter().toJson), - 'custom_profile_field': instance.customProfileField, - 'new_email': instance.newEmail, - 'is_active': instance.isActive, - }; + RealmUserUpdateEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'user_id': instance.userId, + 'full_name': instance.fullName, + 'avatar_url': instance.avatarUrl, + 'avatar_version': instance.avatarVersion, + 'timezone': instance.timezone, + 'bot_owner_id': instance.botOwnerId, + 'role': instance.role, + 'delivery_email': + _$JsonConverterToJson, JsonNullable>( + instance.deliveryEmail, + const NullableStringJsonConverter().toJson, + ), + 'custom_profile_field': instance.customProfileField, + 'new_email': instance.newEmail, + 'is_active': instance.isActive, +}; const _$UserRoleEnumMap = { UserRole.owner: 100, @@ -191,14 +335,61 @@ const _$UserRoleEnumMap = { Value? _$JsonConverterFromJson( Object? json, Value? Function(Json json) fromJson, -) => - json == null ? null : fromJson(json as Json); +) => json == null ? null : fromJson(json as Json); Json? _$JsonConverterToJson( Value? value, Json? Function(Value value) toJson, -) => - value == null ? null : toJson(value); +) => value == null ? null : toJson(value); + +SavedSnippetsAddEvent _$SavedSnippetsAddEventFromJson( + Map json, +) => SavedSnippetsAddEvent( + id: (json['id'] as num).toInt(), + savedSnippet: SavedSnippet.fromJson( + json['saved_snippet'] as Map, + ), +); + +Map _$SavedSnippetsAddEventToJson( + SavedSnippetsAddEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet': instance.savedSnippet, +}; + +SavedSnippetsUpdateEvent _$SavedSnippetsUpdateEventFromJson( + Map json, +) => SavedSnippetsUpdateEvent( + id: (json['id'] as num).toInt(), + savedSnippet: SavedSnippet.fromJson( + json['saved_snippet'] as Map, + ), +); + +Map _$SavedSnippetsUpdateEventToJson( + SavedSnippetsUpdateEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet': instance.savedSnippet, +}; + +SavedSnippetsRemoveEvent _$SavedSnippetsRemoveEventFromJson( + Map json, +) => SavedSnippetsRemoveEvent( + id: (json['id'] as num).toInt(), + savedSnippetId: (json['saved_snippet_id'] as num).toInt(), +); + +Map _$SavedSnippetsRemoveEventToJson( + SavedSnippetsRemoveEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet_id': instance.savedSnippetId, +}; ChannelCreateEvent _$ChannelCreateEventFromJson(Map json) => ChannelCreateEvent( @@ -238,8 +429,10 @@ ChannelUpdateEvent _$ChannelUpdateEventFromJson(Map json) => streamId: (json['stream_id'] as num).toInt(), name: json['name'] as String, property: $enumDecodeNullable( - _$ChannelPropertyNameEnumMap, json['property'], - unknownValue: JsonKey.nullForUndefinedEnumValue), + _$ChannelPropertyNameEnumMap, + json['property'], + unknownValue: JsonKey.nullForUndefinedEnumValue, + ), value: ChannelUpdateEvent._readValue(json, 'value'), renderedDescription: json['rendered_description'] as String?, historyPublicToSubscribers: @@ -263,70 +456,76 @@ Map _$ChannelUpdateEventToJson(ChannelUpdateEvent instance) => const _$ChannelPropertyNameEnumMap = { ChannelPropertyName.name: 'name', + ChannelPropertyName.isArchived: 'is_archived', ChannelPropertyName.description: 'description', ChannelPropertyName.firstMessageId: 'first_message_id', ChannelPropertyName.inviteOnly: 'invite_only', ChannelPropertyName.messageRetentionDays: 'message_retention_days', ChannelPropertyName.channelPostPolicy: 'stream_post_policy', + ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group', + ChannelPropertyName.canDeleteAnyMessageGroup: 'can_delete_any_message_group', + ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_message_group', + ChannelPropertyName.canSubscribeGroup: 'can_subscribe_group', ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic', }; SubscriptionAddEvent _$SubscriptionAddEventFromJson( - Map json) => - SubscriptionAddEvent( - id: (json['id'] as num).toInt(), - subscriptions: (json['subscriptions'] as List) - .map((e) => Subscription.fromJson(e as Map)) - .toList(), - ); + Map json, +) => SubscriptionAddEvent( + id: (json['id'] as num).toInt(), + subscriptions: (json['subscriptions'] as List) + .map((e) => Subscription.fromJson(e as Map)) + .toList(), +); Map _$SubscriptionAddEventToJson( - SubscriptionAddEvent instance) => - { - 'id': instance.id, - 'type': instance.type, - 'op': instance.op, - 'subscriptions': instance.subscriptions, - }; + SubscriptionAddEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'subscriptions': instance.subscriptions, +}; SubscriptionRemoveEvent _$SubscriptionRemoveEventFromJson( - Map json) => - SubscriptionRemoveEvent( - id: (json['id'] as num).toInt(), - streamIds: (SubscriptionRemoveEvent._readStreamIds(json, 'stream_ids') + Map json, +) => SubscriptionRemoveEvent( + id: (json['id'] as num).toInt(), + streamIds: + (SubscriptionRemoveEvent._readStreamIds(json, 'stream_ids') as List) .map((e) => (e as num).toInt()) .toList(), - ); +); Map _$SubscriptionRemoveEventToJson( - SubscriptionRemoveEvent instance) => - { - 'id': instance.id, - 'type': instance.type, - 'op': instance.op, - 'stream_ids': instance.streamIds, - }; + SubscriptionRemoveEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'stream_ids': instance.streamIds, +}; SubscriptionUpdateEvent _$SubscriptionUpdateEventFromJson( - Map json) => - SubscriptionUpdateEvent( - id: (json['id'] as num).toInt(), - streamId: (json['stream_id'] as num).toInt(), - property: $enumDecode(_$SubscriptionPropertyEnumMap, json['property']), - value: SubscriptionUpdateEvent._readValue(json, 'value'), - ); + Map json, +) => SubscriptionUpdateEvent( + id: (json['id'] as num).toInt(), + streamId: (json['stream_id'] as num).toInt(), + property: $enumDecode(_$SubscriptionPropertyEnumMap, json['property']), + value: SubscriptionUpdateEvent._readValue(json, 'value'), +); Map _$SubscriptionUpdateEventToJson( - SubscriptionUpdateEvent instance) => - { - 'id': instance.id, - 'type': instance.type, - 'op': instance.op, - 'stream_id': instance.streamId, - 'property': _$SubscriptionPropertyEnumMap[instance.property]!, - 'value': instance.value, - }; + SubscriptionUpdateEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'stream_id': instance.streamId, + 'property': _$SubscriptionPropertyEnumMap[instance.property]!, + 'value': instance.value, +}; const _$SubscriptionPropertyEnumMap = { SubscriptionProperty.color: 'color', @@ -342,48 +541,57 @@ const _$SubscriptionPropertyEnumMap = { }; SubscriptionPeerAddEvent _$SubscriptionPeerAddEventFromJson( - Map json) => - SubscriptionPeerAddEvent( - id: (json['id'] as num).toInt(), - streamIds: (json['stream_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), - userIds: (json['user_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), - ); + Map json, +) => SubscriptionPeerAddEvent( + id: (json['id'] as num).toInt(), + streamIds: (json['stream_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); Map _$SubscriptionPeerAddEventToJson( - SubscriptionPeerAddEvent instance) => - { - 'id': instance.id, - 'type': instance.type, - 'op': instance.op, - 'stream_ids': instance.streamIds, - 'user_ids': instance.userIds, - }; + SubscriptionPeerAddEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'stream_ids': instance.streamIds, + 'user_ids': instance.userIds, +}; SubscriptionPeerRemoveEvent _$SubscriptionPeerRemoveEventFromJson( - Map json) => - SubscriptionPeerRemoveEvent( - id: (json['id'] as num).toInt(), - streamIds: (json['stream_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), - userIds: (json['user_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), - ); + Map json, +) => SubscriptionPeerRemoveEvent( + id: (json['id'] as num).toInt(), + streamIds: (json['stream_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); Map _$SubscriptionPeerRemoveEventToJson( - SubscriptionPeerRemoveEvent instance) => - { - 'id': instance.id, - 'type': instance.type, - 'op': instance.op, - 'stream_ids': instance.streamIds, - 'user_ids': instance.userIds, - }; + SubscriptionPeerRemoveEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'stream_ids': instance.streamIds, + 'user_ids': instance.userIds, +}; + +UserStatusEvent _$UserStatusEventFromJson(Map json) => + UserStatusEvent( + id: (json['id'] as num).toInt(), + userId: (json['user_id'] as num).toInt(), + change: UserStatusChange.fromJson( + UserStatusEvent._readChange(json, 'change') as Map, + ), + ); UserTopicEvent _$UserTopicEventFromJson(Map json) => UserTopicEvent( @@ -392,7 +600,9 @@ UserTopicEvent _$UserTopicEventFromJson(Map json) => topicName: TopicName.fromJson(json['topic_name'] as String), lastUpdated: (json['last_updated'] as num).toInt(), visibilityPolicy: $enumDecode( - _$UserTopicVisibilityPolicyEnumMap, json['visibility_policy']), + _$UserTopicVisibilityPolicyEnumMap, + json['visibility_policy'], + ), ); Map _$UserTopicEventToJson(UserTopicEvent instance) => @@ -413,11 +623,41 @@ const _$UserTopicVisibilityPolicyEnumMap = { UserTopicVisibilityPolicy.unknown: null, }; +MutedUsersEvent _$MutedUsersEventFromJson(Map json) => + MutedUsersEvent( + id: (json['id'] as num).toInt(), + mutedUsers: (json['muted_users'] as List) + .map((e) => MutedUserItem.fromJson(e as Map)) + .toList(), + ); + +Map _$MutedUsersEventToJson(MutedUsersEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'muted_users': instance.mutedUsers, + }; + +MessageEvent _$MessageEventFromJson(Map json) => MessageEvent( + id: (json['id'] as num).toInt(), + message: Message.fromJson( + MessageEvent._readMessageValue(json, 'message') as Map, + ), + localMessageId: json['local_message_id'] as String?, +); + +Map _$MessageEventToJson(MessageEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'local_message_id': instance.localMessageId, + }; + UpdateMessageEvent _$UpdateMessageEventFromJson(Map json) => UpdateMessageEvent( id: (json['id'] as num).toInt(), userId: (json['user_id'] as num?)?.toInt(), - renderingOnly: json['rendering_only'] as bool?, + renderingOnly: json['rendering_only'] as bool, messageId: (json['message_id'] as num).toInt(), messageIds: (json['message_ids'] as List) .map((e) => (e as num).toInt()) @@ -425,17 +665,11 @@ UpdateMessageEvent _$UpdateMessageEventFromJson(Map json) => flags: (json['flags'] as List) .map((e) => $enumDecode(_$MessageFlagEnumMap, e)) .toList(), - editTimestamp: (json['edit_timestamp'] as num?)?.toInt(), - origStreamId: (json['stream_id'] as num?)?.toInt(), - newStreamId: (json['new_stream_id'] as num?)?.toInt(), - propagateMode: - $enumDecodeNullable(_$PropagateModeEnumMap, json['propagate_mode']), - origTopic: json['orig_subject'] == null - ? null - : TopicName.fromJson(json['orig_subject'] as String), - newTopic: json['subject'] == null - ? null - : TopicName.fromJson(json['subject'] as String), + editTimestamp: (json['edit_timestamp'] as num).toInt(), + moveData: UpdateMessageMoveData.tryParseFromJson( + UpdateMessageEvent._readMoveData(json, 'move_data') + as Map, + ), origContent: json['orig_content'] as String?, origRenderedContent: json['orig_rendered_content'] as String?, content: json['content'] as String?, @@ -453,11 +687,6 @@ Map _$UpdateMessageEventToJson(UpdateMessageEvent instance) => 'message_ids': instance.messageIds, 'flags': instance.flags, 'edit_timestamp': instance.editTimestamp, - 'stream_id': instance.origStreamId, - 'new_stream_id': instance.newStreamId, - 'propagate_mode': instance.propagateMode, - 'orig_subject': instance.origTopic, - 'subject': instance.newTopic, 'orig_content': instance.origContent, 'orig_rendered_content': instance.origRenderedContent, 'content': instance.content, @@ -476,20 +705,15 @@ const _$MessageFlagEnumMap = { MessageFlag.unknown: 'unknown', }; -const _$PropagateModeEnumMap = { - PropagateMode.changeOne: 'change_one', - PropagateMode.changeLater: 'change_later', - PropagateMode.changeAll: 'change_all', -}; - DeleteMessageEvent _$DeleteMessageEventFromJson(Map json) => DeleteMessageEvent( id: (json['id'] as num).toInt(), messageIds: (json['message_ids'] as List) .map((e) => (e as num).toInt()) .toList(), - messageType: - const MessageTypeConverter().fromJson(json['message_type'] as String), + messageType: const MessageTypeConverter().fromJson( + json['message_type'] as String, + ), streamId: (json['stream_id'] as num?)?.toInt(), topic: json['topic'] == null ? null @@ -507,86 +731,96 @@ Map _$DeleteMessageEventToJson(DeleteMessageEvent instance) => }; UpdateMessageFlagsAddEvent _$UpdateMessageFlagsAddEventFromJson( - Map json) => - UpdateMessageFlagsAddEvent( - id: (json['id'] as num).toInt(), - flag: $enumDecode(_$MessageFlagEnumMap, json['flag'], - unknownValue: MessageFlag.unknown), - messages: (json['messages'] as List) - .map((e) => (e as num).toInt()) - .toList(), - all: json['all'] as bool, - ); + Map json, +) => UpdateMessageFlagsAddEvent( + id: (json['id'] as num).toInt(), + flag: $enumDecode( + _$MessageFlagEnumMap, + json['flag'], + unknownValue: MessageFlag.unknown, + ), + messages: (json['messages'] as List) + .map((e) => (e as num).toInt()) + .toList(), + all: json['all'] as bool, +); Map _$UpdateMessageFlagsAddEventToJson( - UpdateMessageFlagsAddEvent instance) => - { - 'id': instance.id, - 'type': instance.type, - 'flag': instance.flag, - 'messages': instance.messages, - 'op': instance.op, - 'all': instance.all, - }; + UpdateMessageFlagsAddEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'flag': instance.flag, + 'messages': instance.messages, + 'op': instance.op, + 'all': instance.all, +}; UpdateMessageFlagsRemoveEvent _$UpdateMessageFlagsRemoveEventFromJson( - Map json) => - UpdateMessageFlagsRemoveEvent( - id: (json['id'] as num).toInt(), - flag: $enumDecode(_$MessageFlagEnumMap, json['flag'], - unknownValue: MessageFlag.unknown), - messages: (json['messages'] as List) - .map((e) => (e as num).toInt()) - .toList(), - messageDetails: (json['message_details'] as Map?)?.map( - (k, e) => MapEntry( - int.parse(k), - UpdateMessageFlagsMessageDetail.fromJson( - e as Map)), - ), - ); + Map json, +) => UpdateMessageFlagsRemoveEvent( + id: (json['id'] as num).toInt(), + flag: $enumDecode( + _$MessageFlagEnumMap, + json['flag'], + unknownValue: MessageFlag.unknown, + ), + messages: (json['messages'] as List) + .map((e) => (e as num).toInt()) + .toList(), + messageDetails: (json['message_details'] as Map?)?.map( + (k, e) => MapEntry( + int.parse(k), + UpdateMessageFlagsMessageDetail.fromJson(e as Map), + ), + ), +); Map _$UpdateMessageFlagsRemoveEventToJson( - UpdateMessageFlagsRemoveEvent instance) => - { - 'id': instance.id, - 'type': instance.type, - 'flag': instance.flag, - 'messages': instance.messages, - 'op': instance.op, - 'message_details': - instance.messageDetails?.map((k, e) => MapEntry(k.toString(), e)), - }; + UpdateMessageFlagsRemoveEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'flag': instance.flag, + 'messages': instance.messages, + 'op': instance.op, + 'message_details': instance.messageDetails?.map( + (k, e) => MapEntry(k.toString(), e), + ), +}; UpdateMessageFlagsMessageDetail _$UpdateMessageFlagsMessageDetailFromJson( - Map json) => - UpdateMessageFlagsMessageDetail( - type: const MessageTypeConverter().fromJson(json['type'] as String), - mentioned: json['mentioned'] as bool?, - userIds: (json['user_ids'] as List?) - ?.map((e) => (e as num).toInt()) - .toList(), - streamId: (json['stream_id'] as num?)?.toInt(), - topic: json['topic'] == null - ? null - : TopicName.fromJson(json['topic'] as String), - ); + Map json, +) => UpdateMessageFlagsMessageDetail( + type: const MessageTypeConverter().fromJson(json['type'] as String), + mentioned: json['mentioned'] as bool?, + userIds: (json['user_ids'] as List?) + ?.map((e) => (e as num).toInt()) + .toList(), + streamId: (json['stream_id'] as num?)?.toInt(), + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), +); Map _$UpdateMessageFlagsMessageDetailToJson( - UpdateMessageFlagsMessageDetail instance) => - { - 'type': const MessageTypeConverter().toJson(instance.type), - 'mentioned': instance.mentioned, - 'user_ids': instance.userIds, - 'stream_id': instance.streamId, - 'topic': instance.topic, - }; + UpdateMessageFlagsMessageDetail instance, +) => { + 'type': const MessageTypeConverter().toJson(instance.type), + 'mentioned': instance.mentioned, + 'user_ids': instance.userIds, + 'stream_id': instance.streamId, + 'topic': instance.topic, +}; SubmessageEvent _$SubmessageEventFromJson(Map json) => SubmessageEvent( id: (json['id'] as num).toInt(), - msgType: $enumDecode(_$SubmessageTypeEnumMap, json['msg_type'], - unknownValue: SubmessageType.unknown), + msgType: $enumDecode( + _$SubmessageTypeEnumMap, + json['msg_type'], + unknownValue: SubmessageType.unknown, + ), content: json['content'] as String, messageId: (json['message_id'] as num).toInt(), senderId: (json['sender_id'] as num).toInt(), @@ -610,17 +844,18 @@ const _$SubmessageTypeEnumMap = { }; TypingEvent _$TypingEventFromJson(Map json) => TypingEvent( - id: (json['id'] as num).toInt(), - op: $enumDecode(_$TypingOpEnumMap, json['op']), - messageType: - const MessageTypeConverter().fromJson(json['message_type'] as String), - senderId: (TypingEvent._readSenderId(json, 'sender_id') as num).toInt(), - recipientIds: TypingEvent._recipientIdsFromJson(json['recipients']), - streamId: (json['stream_id'] as num?)?.toInt(), - topic: json['topic'] == null - ? null - : TopicName.fromJson(json['topic'] as String), - ); + id: (json['id'] as num).toInt(), + op: $enumDecode(_$TypingOpEnumMap, json['op']), + messageType: const MessageTypeConverter().fromJson( + json['message_type'] as String, + ), + senderId: (TypingEvent._readSenderId(json, 'sender_id') as num).toInt(), + recipientIds: TypingEvent._recipientIdsFromJson(json['recipients']), + streamId: (json['stream_id'] as num?)?.toInt(), + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), +); Map _$TypingEventToJson(TypingEvent instance) => { @@ -634,9 +869,47 @@ Map _$TypingEventToJson(TypingEvent instance) => 'topic': instance.topic, }; -const _$TypingOpEnumMap = { - TypingOp.start: 'start', - TypingOp.stop: 'stop', +const _$TypingOpEnumMap = {TypingOp.start: 'start', TypingOp.stop: 'stop'}; + +PresenceEvent _$PresenceEventFromJson(Map json) => + PresenceEvent( + id: (json['id'] as num).toInt(), + userId: (json['user_id'] as num).toInt(), + serverTimestamp: (json['server_timestamp'] as num).toInt(), + presence: (json['presence'] as Map).map( + (k, e) => + MapEntry(k, PerClientPresence.fromJson(e as Map)), + ), + ); + +Map _$PresenceEventToJson(PresenceEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'user_id': instance.userId, + 'server_timestamp': instance.serverTimestamp, + 'presence': instance.presence, + }; + +PerClientPresence _$PerClientPresenceFromJson(Map json) => + PerClientPresence( + client: json['client'] as String, + status: $enumDecode(_$PresenceStatusEnumMap, json['status']), + timestamp: (json['timestamp'] as num).toInt(), + pushable: json['pushable'] as bool, + ); + +Map _$PerClientPresenceToJson(PerClientPresence instance) => + { + 'client': instance.client, + 'status': instance.status, + 'timestamp': instance.timestamp, + 'pushable': instance.pushable, + }; + +const _$PresenceStatusEnumMap = { + PresenceStatus.active: 'active', + PresenceStatus.idle: 'idle', }; ReactionEvent _$ReactionEventFromJson(Map json) => @@ -674,15 +947,10 @@ const _$ReactionTypeEnumMap = { }; HeartbeatEvent _$HeartbeatEventFromJson(Map json) => - HeartbeatEvent( - id: (json['id'] as num).toInt(), - ); + HeartbeatEvent(id: (json['id'] as num).toInt()); Map _$HeartbeatEventToJson(HeartbeatEvent instance) => - { - 'id': instance.id, - 'type': instance.type, - }; + {'id': instance.id, 'type': instance.type}; const _$MessageTypeEnumMap = { MessageType.stream: 'stream', diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 1adc44196f..a8e99dde28 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -18,21 +18,14 @@ class InitialSnapshot { final int lastEventId; final int zulipFeatureLevel; final String zulipVersion; - final String? zulipMergeBase; // TODO(server-5) + final String zulipMergeBase; final List alertWords; final List customProfileFields; - /// The realm-level policy, on pre-FL 163 servers, for visibility of real email addresses. - /// - /// Search for "email_address_visibility" in https://zulip.com/api/register-queue. - /// - /// This field is removed in Zulip 7.0 (FL 163) and replaced with a user-level - /// setting: - /// * https://zulip.com/api/update-settings#parameter-email_address_visibility - /// * https://zulip.com/api/update-realm-user-settings-defaults#parameter-email_address_visibility - final EmailAddressVisibility? emailAddressVisibility; // TODO(server-7): remove + final int serverPresencePingIntervalSeconds; + final int serverPresenceOfflineThresholdSeconds; // TODO(server-8): Remove the default values. @JsonKey(defaultValue: 15000) @@ -44,26 +37,52 @@ class InitialSnapshot { // final List<…> mutedTopics; // TODO(#422) we ignore this feature on older servers + final List mutedUsers; + + // In the modern format because we pass `slim_presence`. + // TODO(#1611) stop passing and mentioning the deprecated slim_presence; + // presence_last_update_id will be why we get the modern format. + final Map presences; + final Map realmEmoji; + final List realmUserGroups; + final List recentPrivateConversations; + final List? savedSnippets; // TODO(server-10) + final List subscriptions; final UnreadMessagesSnapshot unreadMsgs; final List streams; - // Servers pre-5.0 don't have `user_settings`, and instead provide whatever - // user settings they support at toplevel in the initial snapshot. Since we're - // likely to desupport pre-5.0 servers before wide release, we prefer to - // ignore the toplevel fields and use `user_settings` where present instead, - // even at the expense of functionality with pre-5.0 servers. - // TODO(server-5) remove pre-5.0 comment - final UserSettings? userSettings; // TODO(server-5) + // In register-queue, the name of this field is the singular "user_status", + // even though it actually contains user status information for all the users + // that the self-user has access to. Therefore, we prefer to use the plural form. + // + // The API expresses each status as a change from the "zero status" (see + // [UserStatus.zero]), with entries omitted for users whose status is the + // zero status. + @JsonKey(name: 'user_status') + final Map userStatuses; + + final UserSettings userSettings; final List? userTopics; // TODO(server-6) + final GroupSettingValue? realmCanDeleteAnyMessageGroup; // TODO(server-10) + + final GroupSettingValue? realmCanDeleteOwnMessageGroup; // TODO(server-10) + + /// The policy for who can delete their own messages, + /// on supported servers below version 10. + /// + /// Removed in FL 291, so absent in the current API doc; + /// see zulip/zulip@0cd51f2fe. + final RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy; // TODO(server-10) + /// The policy for who can use wildcard mentions in large channels. /// /// Search for "realm_wildcard_mention_policy" in https://zulip.com/api/register-queue. @@ -79,12 +98,23 @@ class InitialSnapshot { /// https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member final int realmWaitingPeriodThreshold; + final int? realmMessageContentDeleteLimitSeconds; + + final bool realmAllowMessageEditing; + final int? realmMessageContentEditLimitSeconds; + + final bool realmEnableReadReceipts; + + final bool realmPresenceDisabled; + final Map realmDefaultExternalAccounts; final int maxFileUploadSizeMib; final Uri? serverEmojiDataUrl; // TODO(server-6) + final String? realmEmptyTopicDisplayName; // TODO(server-10) + @JsonKey(readValue: _readUsersIsActiveFallbackTrue) final List realmUsers; @JsonKey(readValue: _readUsersIsActiveFallbackFalse) @@ -92,6 +122,9 @@ class InitialSnapshot { @JsonKey(readValue: _readUsersIsActiveFallbackTrue) final List crossRealmBots; + // TODO(server): Get this API stabilized, to replace [SupportedPermissionSettings.fixture]. + // final SupportedPermissionSettings? serverSupportedPermissionSettings; + // TODO etc., etc. // If adding fields, keep them all in the order they appear in the API docs. @@ -121,23 +154,38 @@ class InitialSnapshot { required this.zulipMergeBase, required this.alertWords, required this.customProfileFields, - required this.emailAddressVisibility, + required this.serverPresencePingIntervalSeconds, + required this.serverPresenceOfflineThresholdSeconds, required this.serverTypingStartedExpiryPeriodMilliseconds, required this.serverTypingStoppedWaitPeriodMilliseconds, required this.serverTypingStartedWaitPeriodMilliseconds, + required this.mutedUsers, + required this.presences, required this.realmEmoji, + required this.realmUserGroups, required this.recentPrivateConversations, + required this.savedSnippets, required this.subscriptions, required this.unreadMsgs, required this.streams, + required this.userStatuses, required this.userSettings, required this.userTopics, + required this.realmCanDeleteAnyMessageGroup, + required this.realmCanDeleteOwnMessageGroup, + required this.realmDeleteOwnMessagePolicy, required this.realmWildcardMentionPolicy, required this.realmMandatoryTopics, required this.realmWaitingPeriodThreshold, + required this.realmMessageContentDeleteLimitSeconds, + required this.realmAllowMessageEditing, + required this.realmMessageContentEditLimitSeconds, + required this.realmEnableReadReceipts, + required this.realmPresenceDisabled, required this.realmDefaultExternalAccounts, required this.maxFileUploadSizeMib, required this.serverEmojiDataUrl, + required this.realmEmptyTopicDisplayName, required this.realmUsers, required this.realmNonActiveUsers, required this.crossRealmBots, @@ -149,14 +197,6 @@ class InitialSnapshot { Map toJson() => _$InitialSnapshotToJson(this); } -enum EmailAddressVisibility { - @JsonValue(1) everyone, - @JsonValue(2) members, - @JsonValue(3) admins, - @JsonValue(4) nobody, - @JsonValue(5) moderators, -} - @JsonEnum(valueField: 'apiValue') enum RealmWildcardMentionPolicy { everyone(apiValue: 1), @@ -173,6 +213,21 @@ enum RealmWildcardMentionPolicy { int? toJson() => apiValue; } +@JsonEnum(valueField: 'apiValue') +enum RealmDeleteOwnMessagePolicy { + members(apiValue: 1), + admins(apiValue: 2), + fullMembers(apiValue: 3), + moderators(apiValue: 4), + everyone(apiValue: 5); + + const RealmDeleteOwnMessagePolicy({required this.apiValue}); + + final int apiValue; + + int toJson() => apiValue; +} + /// An item in `realm_default_external_accounts`. /// /// For docs, search for "realm_default_external_accounts:" @@ -223,19 +278,27 @@ class RecentDmConversation { /// in . @JsonSerializable(fieldRename: FieldRename.snake, createFieldMap: true) class UserSettings { - bool twentyFourHourTime; + @JsonKey( + fromJson: TwentyFourHourTimeMode.fromApiValue, + toJson: TwentyFourHourTimeMode.staticToJson, + ) + TwentyFourHourTimeMode twentyFourHourTime; + bool? displayEmojiReactionUsers; // TODO(server-6) Emojiset emojiset; + bool presenceEnabled; // TODO more, as needed. When adding a setting here, please also: // (1) add it to the [UserSettingName] enum // (2) then re-run the command to refresh the .g.dart files // (3) handle the event that signals an update to the setting + // (4) add the setting to the [updateSettings] route binding UserSettings({ required this.twentyFourHourTime, required this.displayEmojiReactionUsers, required this.emojiset, + required this.presenceEnabled, }); factory UserSettings.fromJson(Map json) => @@ -312,15 +375,9 @@ class UnreadMessagesSnapshot { /// An item in [UnreadMessagesSnapshot.dms]. @JsonSerializable(fieldRename: FieldRename.snake) class UnreadDmSnapshot { - @JsonKey(readValue: _readOtherUserId) final int otherUserId; final List unreadMessageIds; - // TODO(server-5): Simplify away. - static dynamic _readOtherUserId(Map json, String key) { - return json[key] ?? json['sender_id']; - } - UnreadDmSnapshot({ required this.otherUserId, required this.unreadMessageIds, @@ -367,3 +424,248 @@ class UnreadHuddleSnapshot { Map toJson() => _$UnreadHuddleSnapshotToJson(this); } + +/// Metadata about how to interpret the various group-based permission settings. +/// +/// This is the type that [InitialSnapshot.serverSupportedPermissionSettings] +/// would have, according to the API as it exists as of 2025-08; +/// but that API is documented as unstable and subject to change. +/// +/// For a useful value of this type, see [SupportedPermissionSettings.fixture]. +/// +/// For docs, search for "d_perm" in: https://zulip.com/api/register-queue +@JsonSerializable(fieldRename: FieldRename.snake) +class SupportedPermissionSettings { + final Map realm; + final Map stream; + final Map group; + + /// Metadata about how to interpret certain group-based permission settings, + /// including all those that this client uses, based on "current" servers. + /// + /// "Current" here means as of when this code was written, or last updated; + /// details in comments below. Naturally it'd be better to have an API to + /// get this information from the actual server. + /// + /// Effectively we're counting on it being uncommon for the metadata for a + /// given permission to ever change from one server version to the next, + /// so that the values we take from one server version usually remain valid + /// for all past and future server versions that have the corresponding + /// permission at all. + /// + /// TODO(server): Stabilize [InitialSnapshot.serverSupportedPermissionSettings] + /// or a similar API, and switch to using that. See thread: + /// https://chat.zulip.org/#narrow/channel/378-api-design/topic/server_supported_permission_settings/near/2247549 + static SupportedPermissionSettings fixture = SupportedPermissionSettings( + realm: { + // From the server's Realm.REALM_PERMISSION_GROUP_SETTINGS, + // in zerver/models/realms.py. Current as of 6ab30fcce, 2025-08. + 'create_multiuse_invite_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.ADMINISTRATORS, + ), + 'can_access_all_users_group': PermissionSettingsItem( + // require_system_group=True, + // allow_nobody_group=False, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + // # Note that user_can_access_all_other_users in the web + // # app is relying on members always have access. + // allowed_system_groups=[SystemGroups.EVERYONE, SystemGroups.MEMBERS], + ), + 'can_add_subscribers_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_add_custom_emoji_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_create_bots_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_create_groups': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_create_public_channel_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_create_private_channel_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_create_web_public_channel_group': PermissionSettingsItem( + // require_system_group=True, + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.OWNERS, + // allowed_system_groups=[ + // SystemGroups.MODERATORS, + // SystemGroups.ADMINISTRATORS, + // SystemGroups.OWNERS, + // SystemGroups.NOBODY, + // ], + ), + 'can_create_write_only_bots_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_delete_any_message_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.ADMINISTRATORS, + ), + 'can_delete_own_message_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + 'can_invite_users_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_manage_all_groups': PermissionSettingsItem( + // allow_nobody_group=False, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.OWNERS, + ), + 'can_manage_billing_group': PermissionSettingsItem( + // allow_nobody_group=False, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.ADMINISTRATORS, + ), + 'can_mention_many_users_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.ADMINISTRATORS, + ), + 'can_move_messages_between_channels_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_move_messages_between_topics_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + 'can_resolve_topics_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + 'can_set_delete_message_policy_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MODERATORS, + ), + 'can_set_topics_policy_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_summarize_topics_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + 'direct_message_initiator_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + 'direct_message_permission_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + }, + group: {}, // Please go ahead and fill this in when we come to need it. + stream: { + // From the server's Stream.stream_permission_group_settings, + // in zerver/models/streams.py. Current as of f9dc13014, 2025-08. + "can_add_subscribers_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.NOBODY, + ), + "can_administer_channel_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name="stream_creator_or_nobody", + ), + "can_delete_any_message_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.NOBODY, + ), + "can_delete_own_message_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.NOBODY, + ), + "can_move_messages_out_of_channel_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.NOBODY, + ), + "can_move_messages_within_channel_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.NOBODY, + ), + "can_remove_subscribers_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.ADMINISTRATORS, + ), + "can_send_message_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + "can_subscribe_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.NOBODY, + ), + "can_resolve_topics_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.NOBODY, + ), + }, + ); + + SupportedPermissionSettings({required this.realm, required this.stream, required this.group}); + + factory SupportedPermissionSettings.fromJson(Map json) => + _$SupportedPermissionSettingsFromJson(json); + + Map toJson() => _$SupportedPermissionSettingsToJson(this); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class PermissionSettingsItem { + final bool allowEveryoneGroup; + // also other fields not yet used + + PermissionSettingsItem({required this.allowEveryoneGroup}); + + factory PermissionSettingsItem.fromJson(Map json) => + _$PermissionSettingsItemFromJson(json); + + Map toJson() => _$PermissionSettingsItemToJson(this); +} diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index a69b6ebafe..4a4d153e2c 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -8,127 +8,197 @@ part of 'initial_snapshot.dart'; // JsonSerializableGenerator // ************************************************************************** -InitialSnapshot _$InitialSnapshotFromJson(Map json) => - InitialSnapshot( - queueId: json['queue_id'] as String?, - lastEventId: (json['last_event_id'] as num).toInt(), - zulipFeatureLevel: (json['zulip_feature_level'] as num).toInt(), - zulipVersion: json['zulip_version'] as String, - zulipMergeBase: json['zulip_merge_base'] as String?, - alertWords: (json['alert_words'] as List) - .map((e) => e as String) - .toList(), - customProfileFields: (json['custom_profile_fields'] as List) - .map((e) => CustomProfileField.fromJson(e as Map)) - .toList(), - emailAddressVisibility: $enumDecodeNullable( - _$EmailAddressVisibilityEnumMap, json['email_address_visibility']), - serverTypingStartedExpiryPeriodMilliseconds: - (json['server_typing_started_expiry_period_milliseconds'] as num?) - ?.toInt() ?? - 15000, - serverTypingStoppedWaitPeriodMilliseconds: - (json['server_typing_stopped_wait_period_milliseconds'] as num?) - ?.toInt() ?? - 5000, - serverTypingStartedWaitPeriodMilliseconds: - (json['server_typing_started_wait_period_milliseconds'] as num?) - ?.toInt() ?? - 10000, - realmEmoji: (json['realm_emoji'] as Map).map( - (k, e) => - MapEntry(k, RealmEmojiItem.fromJson(e as Map)), - ), - recentPrivateConversations: (json['recent_private_conversations'] - as List) +InitialSnapshot _$InitialSnapshotFromJson( + Map json, +) => InitialSnapshot( + queueId: json['queue_id'] as String?, + lastEventId: (json['last_event_id'] as num).toInt(), + zulipFeatureLevel: (json['zulip_feature_level'] as num).toInt(), + zulipVersion: json['zulip_version'] as String, + zulipMergeBase: json['zulip_merge_base'] as String, + alertWords: (json['alert_words'] as List) + .map((e) => e as String) + .toList(), + customProfileFields: (json['custom_profile_fields'] as List) + .map((e) => CustomProfileField.fromJson(e as Map)) + .toList(), + serverPresencePingIntervalSeconds: + (json['server_presence_ping_interval_seconds'] as num).toInt(), + serverPresenceOfflineThresholdSeconds: + (json['server_presence_offline_threshold_seconds'] as num).toInt(), + serverTypingStartedExpiryPeriodMilliseconds: + (json['server_typing_started_expiry_period_milliseconds'] as num?) + ?.toInt() ?? + 15000, + serverTypingStoppedWaitPeriodMilliseconds: + (json['server_typing_stopped_wait_period_milliseconds'] as num?) + ?.toInt() ?? + 5000, + serverTypingStartedWaitPeriodMilliseconds: + (json['server_typing_started_wait_period_milliseconds'] as num?) + ?.toInt() ?? + 10000, + mutedUsers: (json['muted_users'] as List) + .map((e) => MutedUserItem.fromJson(e as Map)) + .toList(), + presences: (json['presences'] as Map).map( + (k, e) => MapEntry( + int.parse(k), + PerUserPresence.fromJson(e as Map), + ), + ), + realmEmoji: (json['realm_emoji'] as Map).map( + (k, e) => MapEntry(k, RealmEmojiItem.fromJson(e as Map)), + ), + realmUserGroups: (json['realm_user_groups'] as List) + .map((e) => UserGroup.fromJson(e as Map)) + .toList(), + recentPrivateConversations: + (json['recent_private_conversations'] as List) .map((e) => RecentDmConversation.fromJson(e as Map)) .toList(), - subscriptions: (json['subscriptions'] as List) - .map((e) => Subscription.fromJson(e as Map)) - .toList(), - unreadMsgs: UnreadMessagesSnapshot.fromJson( - json['unread_msgs'] as Map), - streams: (json['streams'] as List) - .map((e) => ZulipStream.fromJson(e as Map)) - .toList(), - userSettings: json['user_settings'] == null - ? null - : UserSettings.fromJson( - json['user_settings'] as Map), - userTopics: (json['user_topics'] as List?) - ?.map((e) => UserTopicItem.fromJson(e as Map)) - .toList(), - 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: - (json['realm_default_external_accounts'] as Map).map( + savedSnippets: (json['saved_snippets'] as List?) + ?.map((e) => SavedSnippet.fromJson(e as Map)) + .toList(), + subscriptions: (json['subscriptions'] as List) + .map((e) => Subscription.fromJson(e as Map)) + .toList(), + unreadMsgs: UnreadMessagesSnapshot.fromJson( + json['unread_msgs'] as Map, + ), + streams: (json['streams'] as List) + .map((e) => ZulipStream.fromJson(e as Map)) + .toList(), + userStatuses: (json['user_status'] as Map).map( + (k, e) => MapEntry( + int.parse(k), + UserStatusChange.fromJson(e as Map), + ), + ), + userSettings: UserSettings.fromJson( + json['user_settings'] as Map, + ), + userTopics: (json['user_topics'] as List?) + ?.map((e) => UserTopicItem.fromJson(e as Map)) + .toList(), + realmCanDeleteAnyMessageGroup: + json['realm_can_delete_any_message_group'] == null + ? null + : GroupSettingValue.fromJson(json['realm_can_delete_any_message_group']), + realmCanDeleteOwnMessageGroup: + json['realm_can_delete_own_message_group'] == null + ? null + : GroupSettingValue.fromJson(json['realm_can_delete_own_message_group']), + realmDeleteOwnMessagePolicy: $enumDecodeNullable( + _$RealmDeleteOwnMessagePolicyEnumMap, + json['realm_delete_own_message_policy'], + ), + realmWildcardMentionPolicy: $enumDecode( + _$RealmWildcardMentionPolicyEnumMap, + json['realm_wildcard_mention_policy'], + ), + realmMandatoryTopics: json['realm_mandatory_topics'] as bool, + realmWaitingPeriodThreshold: (json['realm_waiting_period_threshold'] as num) + .toInt(), + realmMessageContentDeleteLimitSeconds: + (json['realm_message_content_delete_limit_seconds'] as num?)?.toInt(), + realmAllowMessageEditing: json['realm_allow_message_editing'] as bool, + realmMessageContentEditLimitSeconds: + (json['realm_message_content_edit_limit_seconds'] as num?)?.toInt(), + realmEnableReadReceipts: json['realm_enable_read_receipts'] as bool, + realmPresenceDisabled: json['realm_presence_disabled'] as bool, + realmDefaultExternalAccounts: + (json['realm_default_external_accounts'] as Map).map( (k, e) => MapEntry( - k, RealmDefaultExternalAccount.fromJson(e as Map)), + k, + RealmDefaultExternalAccount.fromJson(e as Map), + ), ), - maxFileUploadSizeMib: (json['max_file_upload_size_mib'] as num).toInt(), - serverEmojiDataUrl: json['server_emoji_data_url'] == null - ? null - : Uri.parse(json['server_emoji_data_url'] as String), - realmUsers: - (InitialSnapshot._readUsersIsActiveFallbackTrue(json, 'realm_users') - as List) - .map((e) => User.fromJson(e as Map)) - .toList(), - realmNonActiveUsers: (InitialSnapshot._readUsersIsActiveFallbackFalse( - json, 'realm_non_active_users') as List) + maxFileUploadSizeMib: (json['max_file_upload_size_mib'] as num).toInt(), + serverEmojiDataUrl: json['server_emoji_data_url'] == null + ? null + : Uri.parse(json['server_emoji_data_url'] as String), + realmEmptyTopicDisplayName: json['realm_empty_topic_display_name'] as String?, + realmUsers: + (InitialSnapshot._readUsersIsActiveFallbackTrue(json, 'realm_users') + as List) .map((e) => User.fromJson(e as Map)) .toList(), - crossRealmBots: (InitialSnapshot._readUsersIsActiveFallbackTrue( - json, 'cross_realm_bots') as List) + realmNonActiveUsers: + (InitialSnapshot._readUsersIsActiveFallbackFalse( + json, + 'realm_non_active_users', + ) + as List) .map((e) => User.fromJson(e as Map)) .toList(), - ); + crossRealmBots: + (InitialSnapshot._readUsersIsActiveFallbackTrue(json, 'cross_realm_bots') + as List) + .map((e) => User.fromJson(e as Map)) + .toList(), +); -Map _$InitialSnapshotToJson(InitialSnapshot instance) => - { - 'queue_id': instance.queueId, - 'last_event_id': instance.lastEventId, - 'zulip_feature_level': instance.zulipFeatureLevel, - 'zulip_version': instance.zulipVersion, - 'zulip_merge_base': instance.zulipMergeBase, - 'alert_words': instance.alertWords, - 'custom_profile_fields': instance.customProfileFields, - 'email_address_visibility': - _$EmailAddressVisibilityEnumMap[instance.emailAddressVisibility], - 'server_typing_started_expiry_period_milliseconds': - instance.serverTypingStartedExpiryPeriodMilliseconds, - 'server_typing_stopped_wait_period_milliseconds': - instance.serverTypingStoppedWaitPeriodMilliseconds, - 'server_typing_started_wait_period_milliseconds': - instance.serverTypingStartedWaitPeriodMilliseconds, - 'realm_emoji': instance.realmEmoji, - 'recent_private_conversations': instance.recentPrivateConversations, - 'subscriptions': instance.subscriptions, - 'unread_msgs': instance.unreadMsgs, - 'streams': instance.streams, - 'user_settings': instance.userSettings, - 'user_topics': instance.userTopics, - 'realm_wildcard_mention_policy': instance.realmWildcardMentionPolicy, - 'realm_mandatory_topics': instance.realmMandatoryTopics, - 'realm_waiting_period_threshold': instance.realmWaitingPeriodThreshold, - 'realm_default_external_accounts': instance.realmDefaultExternalAccounts, - 'max_file_upload_size_mib': instance.maxFileUploadSizeMib, - 'server_emoji_data_url': instance.serverEmojiDataUrl?.toString(), - 'realm_users': instance.realmUsers, - 'realm_non_active_users': instance.realmNonActiveUsers, - 'cross_realm_bots': instance.crossRealmBots, - }; +Map _$InitialSnapshotToJson( + InitialSnapshot instance, +) => { + 'queue_id': instance.queueId, + 'last_event_id': instance.lastEventId, + 'zulip_feature_level': instance.zulipFeatureLevel, + 'zulip_version': instance.zulipVersion, + 'zulip_merge_base': instance.zulipMergeBase, + 'alert_words': instance.alertWords, + 'custom_profile_fields': instance.customProfileFields, + 'server_presence_ping_interval_seconds': + instance.serverPresencePingIntervalSeconds, + 'server_presence_offline_threshold_seconds': + instance.serverPresenceOfflineThresholdSeconds, + 'server_typing_started_expiry_period_milliseconds': + instance.serverTypingStartedExpiryPeriodMilliseconds, + 'server_typing_stopped_wait_period_milliseconds': + instance.serverTypingStoppedWaitPeriodMilliseconds, + 'server_typing_started_wait_period_milliseconds': + instance.serverTypingStartedWaitPeriodMilliseconds, + 'muted_users': instance.mutedUsers, + 'presences': instance.presences.map((k, e) => MapEntry(k.toString(), e)), + 'realm_emoji': instance.realmEmoji, + 'realm_user_groups': instance.realmUserGroups, + 'recent_private_conversations': instance.recentPrivateConversations, + 'saved_snippets': instance.savedSnippets, + 'subscriptions': instance.subscriptions, + 'unread_msgs': instance.unreadMsgs, + 'streams': instance.streams, + 'user_status': instance.userStatuses.map((k, e) => MapEntry(k.toString(), e)), + 'user_settings': instance.userSettings, + 'user_topics': instance.userTopics, + 'realm_can_delete_any_message_group': instance.realmCanDeleteAnyMessageGroup, + 'realm_can_delete_own_message_group': instance.realmCanDeleteOwnMessageGroup, + 'realm_delete_own_message_policy': instance.realmDeleteOwnMessagePolicy, + 'realm_wildcard_mention_policy': instance.realmWildcardMentionPolicy, + 'realm_mandatory_topics': instance.realmMandatoryTopics, + 'realm_waiting_period_threshold': instance.realmWaitingPeriodThreshold, + 'realm_message_content_delete_limit_seconds': + instance.realmMessageContentDeleteLimitSeconds, + 'realm_allow_message_editing': instance.realmAllowMessageEditing, + 'realm_message_content_edit_limit_seconds': + instance.realmMessageContentEditLimitSeconds, + 'realm_enable_read_receipts': instance.realmEnableReadReceipts, + 'realm_presence_disabled': instance.realmPresenceDisabled, + 'realm_default_external_accounts': instance.realmDefaultExternalAccounts, + 'max_file_upload_size_mib': instance.maxFileUploadSizeMib, + 'server_emoji_data_url': instance.serverEmojiDataUrl?.toString(), + 'realm_empty_topic_display_name': instance.realmEmptyTopicDisplayName, + 'realm_users': instance.realmUsers, + 'realm_non_active_users': instance.realmNonActiveUsers, + 'cross_realm_bots': instance.crossRealmBots, +}; -const _$EmailAddressVisibilityEnumMap = { - EmailAddressVisibility.everyone: 1, - EmailAddressVisibility.members: 2, - EmailAddressVisibility.admins: 3, - EmailAddressVisibility.nobody: 4, - EmailAddressVisibility.moderators: 5, +const _$RealmDeleteOwnMessagePolicyEnumMap = { + RealmDeleteOwnMessagePolicy.members: 1, + RealmDeleteOwnMessagePolicy.admins: 2, + RealmDeleteOwnMessagePolicy.fullMembers: 3, + RealmDeleteOwnMessagePolicy.moderators: 4, + RealmDeleteOwnMessagePolicy.everyone: 5, }; const _$RealmWildcardMentionPolicyEnumMap = { @@ -141,56 +211,63 @@ const _$RealmWildcardMentionPolicyEnumMap = { }; RealmDefaultExternalAccount _$RealmDefaultExternalAccountFromJson( - Map json) => - RealmDefaultExternalAccount( - name: json['name'] as String, - text: json['text'] as String, - hint: json['hint'] as String, - urlPattern: json['url_pattern'] as String, - ); + Map json, +) => RealmDefaultExternalAccount( + name: json['name'] as String, + text: json['text'] as String, + hint: json['hint'] as String, + urlPattern: json['url_pattern'] as String, +); Map _$RealmDefaultExternalAccountToJson( - RealmDefaultExternalAccount instance) => - { - 'name': instance.name, - 'text': instance.text, - 'hint': instance.hint, - 'url_pattern': instance.urlPattern, - }; + RealmDefaultExternalAccount instance, +) => { + 'name': instance.name, + 'text': instance.text, + 'hint': instance.hint, + 'url_pattern': instance.urlPattern, +}; RecentDmConversation _$RecentDmConversationFromJson( - Map json) => - RecentDmConversation( - maxMessageId: (json['max_message_id'] as num).toInt(), - userIds: (json['user_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), - ); + Map json, +) => RecentDmConversation( + maxMessageId: (json['max_message_id'] as num).toInt(), + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); Map _$RecentDmConversationToJson( - RecentDmConversation instance) => - { - 'max_message_id': instance.maxMessageId, - 'user_ids': instance.userIds, - }; + RecentDmConversation instance, +) => { + 'max_message_id': instance.maxMessageId, + 'user_ids': instance.userIds, +}; UserSettings _$UserSettingsFromJson(Map json) => UserSettings( - twentyFourHourTime: json['twenty_four_hour_time'] as bool, - displayEmojiReactionUsers: json['display_emoji_reaction_users'] as bool?, - emojiset: $enumDecode(_$EmojisetEnumMap, json['emojiset']), - ); + twentyFourHourTime: TwentyFourHourTimeMode.fromApiValue( + json['twenty_four_hour_time'] as bool?, + ), + displayEmojiReactionUsers: json['display_emoji_reaction_users'] as bool?, + emojiset: $enumDecode(_$EmojisetEnumMap, json['emojiset']), + presenceEnabled: json['presence_enabled'] as bool, +); const _$UserSettingsFieldMap = { 'twentyFourHourTime': 'twenty_four_hour_time', 'displayEmojiReactionUsers': 'display_emoji_reaction_users', 'emojiset': 'emojiset', + 'presenceEnabled': 'presence_enabled', }; Map _$UserSettingsToJson(UserSettings instance) => { - 'twenty_four_hour_time': instance.twentyFourHourTime, + 'twenty_four_hour_time': TwentyFourHourTimeMode.staticToJson( + instance.twentyFourHourTime, + ), 'display_emoji_reaction_users': instance.displayEmojiReactionUsers, - 'emojiset': _$EmojisetEnumMap[instance.emojiset]!, + 'emojiset': instance.emojiset, + 'presence_enabled': instance.presenceEnabled, }; const _$EmojisetEnumMap = { @@ -206,8 +283,10 @@ UserTopicItem _$UserTopicItemFromJson(Map json) => topicName: TopicName.fromJson(json['topic_name'] as String), lastUpdated: (json['last_updated'] as num).toInt(), visibilityPolicy: $enumDecode( - _$UserTopicVisibilityPolicyEnumMap, json['visibility_policy'], - unknownValue: UserTopicVisibilityPolicy.unknown), + _$UserTopicVisibilityPolicyEnumMap, + json['visibility_policy'], + unknownValue: UserTopicVisibilityPolicy.unknown, + ), ); Map _$UserTopicItemToJson(UserTopicItem instance) => @@ -227,40 +306,38 @@ const _$UserTopicVisibilityPolicyEnumMap = { }; UnreadMessagesSnapshot _$UnreadMessagesSnapshotFromJson( - Map json) => - UnreadMessagesSnapshot( - count: (json['count'] as num).toInt(), - dms: (json['pms'] as List) - .map((e) => UnreadDmSnapshot.fromJson(e as Map)) - .toList(), - channels: (json['streams'] as List) - .map((e) => UnreadChannelSnapshot.fromJson(e as Map)) - .toList(), - huddles: (json['huddles'] as List) - .map((e) => UnreadHuddleSnapshot.fromJson(e as Map)) - .toList(), - mentions: (json['mentions'] as List) - .map((e) => (e as num).toInt()) - .toList(), - oldUnreadsMissing: json['old_unreads_missing'] as bool, - ); + Map json, +) => UnreadMessagesSnapshot( + count: (json['count'] as num).toInt(), + dms: (json['pms'] as List) + .map((e) => UnreadDmSnapshot.fromJson(e as Map)) + .toList(), + channels: (json['streams'] as List) + .map((e) => UnreadChannelSnapshot.fromJson(e as Map)) + .toList(), + huddles: (json['huddles'] as List) + .map((e) => UnreadHuddleSnapshot.fromJson(e as Map)) + .toList(), + mentions: (json['mentions'] as List) + .map((e) => (e as num).toInt()) + .toList(), + oldUnreadsMissing: json['old_unreads_missing'] as bool, +); Map _$UnreadMessagesSnapshotToJson( - UnreadMessagesSnapshot instance) => - { - 'count': instance.count, - 'pms': instance.dms, - 'streams': instance.channels, - 'huddles': instance.huddles, - 'mentions': instance.mentions, - 'old_unreads_missing': instance.oldUnreadsMissing, - }; + UnreadMessagesSnapshot instance, +) => { + 'count': instance.count, + 'pms': instance.dms, + 'streams': instance.channels, + 'huddles': instance.huddles, + 'mentions': instance.mentions, + 'old_unreads_missing': instance.oldUnreadsMissing, +}; UnreadDmSnapshot _$UnreadDmSnapshotFromJson(Map json) => UnreadDmSnapshot( - otherUserId: - (UnreadDmSnapshot._readOtherUserId(json, 'other_user_id') as num) - .toInt(), + otherUserId: (json['other_user_id'] as num).toInt(), unreadMessageIds: (json['unread_message_ids'] as List) .map((e) => (e as num).toInt()) .toList(), @@ -273,35 +350,70 @@ Map _$UnreadDmSnapshotToJson(UnreadDmSnapshot instance) => }; UnreadChannelSnapshot _$UnreadChannelSnapshotFromJson( - Map json) => - UnreadChannelSnapshot( - topic: TopicName.fromJson(json['topic'] as String), - streamId: (json['stream_id'] as num).toInt(), - unreadMessageIds: (json['unread_message_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), - ); + Map json, +) => UnreadChannelSnapshot( + topic: TopicName.fromJson(json['topic'] as String), + streamId: (json['stream_id'] as num).toInt(), + unreadMessageIds: (json['unread_message_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); Map _$UnreadChannelSnapshotToJson( - UnreadChannelSnapshot instance) => - { - 'topic': instance.topic, - 'stream_id': instance.streamId, - 'unread_message_ids': instance.unreadMessageIds, - }; + UnreadChannelSnapshot instance, +) => { + 'topic': instance.topic, + 'stream_id': instance.streamId, + 'unread_message_ids': instance.unreadMessageIds, +}; UnreadHuddleSnapshot _$UnreadHuddleSnapshotFromJson( - Map json) => - UnreadHuddleSnapshot( - userIdsString: json['user_ids_string'] as String, - unreadMessageIds: (json['unread_message_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), - ); + Map json, +) => UnreadHuddleSnapshot( + userIdsString: json['user_ids_string'] as String, + unreadMessageIds: (json['unread_message_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); Map _$UnreadHuddleSnapshotToJson( - UnreadHuddleSnapshot instance) => - { - 'user_ids_string': instance.userIdsString, - 'unread_message_ids': instance.unreadMessageIds, - }; + UnreadHuddleSnapshot instance, +) => { + 'user_ids_string': instance.userIdsString, + 'unread_message_ids': instance.unreadMessageIds, +}; + +SupportedPermissionSettings _$SupportedPermissionSettingsFromJson( + Map json, +) => SupportedPermissionSettings( + realm: (json['realm'] as Map).map( + (k, e) => + MapEntry(k, PermissionSettingsItem.fromJson(e as Map)), + ), + stream: (json['stream'] as Map).map( + (k, e) => + MapEntry(k, PermissionSettingsItem.fromJson(e as Map)), + ), + group: (json['group'] as Map).map( + (k, e) => + MapEntry(k, PermissionSettingsItem.fromJson(e as Map)), + ), +); + +Map _$SupportedPermissionSettingsToJson( + SupportedPermissionSettings instance, +) => { + 'realm': instance.realm, + 'stream': instance.stream, + 'group': instance.group, +}; + +PermissionSettingsItem _$PermissionSettingsItemFromJson( + Map json, +) => PermissionSettingsItem( + allowEveryoneGroup: json['allow_everyone_group'] as bool, +); + +Map _$PermissionSettingsItemToJson( + PermissionSettingsItem instance, +) => {'allow_everyone_group': instance.allowEveryoneGroup}; diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 03af104baf..2a6c9f6aa1 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -1,5 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; +import '../../basic.dart'; +import '../../model/algorithms.dart'; import 'events.dart'; import 'initial_snapshot.dart'; import 'reaction.dart'; @@ -10,6 +12,50 @@ export 'reaction.dart'; part 'model.g.dart'; +/// A Zulip "group-setting value": https://zulip.com/api/group-setting-values +sealed class GroupSettingValue { + const GroupSettingValue(); + + factory GroupSettingValue.fromJson(Object? json) { + return switch (json) { + int() => GroupSettingValueNamed.fromJson(json), + Map() => GroupSettingValueNameless.fromJson(json), + _ => throw FormatException(), + }; + } + + Object? toJson(); +} + +class GroupSettingValueNamed extends GroupSettingValue { + final int groupId; + + const GroupSettingValueNamed(this.groupId); + + factory GroupSettingValueNamed.fromJson(int json) => GroupSettingValueNamed(json); + + @override + int toJson() => groupId; +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class GroupSettingValueNameless extends GroupSettingValue { + // TODO(server): The API docs say these should be "direct_member_ids" and + // "direct_subgroup_ids", but empirically they're "direct_members" + // and "direct_subgroups". Discussion: + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/groups.20redesign/near/2247218 + final List directMembers; + final List directSubgroups; + + GroupSettingValueNameless({required this.directMembers, required this.directSubgroups}); + + factory GroupSettingValueNameless.fromJson(Map json) => + _$GroupSettingValueNamelessFromJson(json); + + @override + Map toJson() => _$GroupSettingValueNamelessToJson(this); +} + /// As in [InitialSnapshot.customProfileFields]. /// /// For docs, search for "custom_profile_fields:" @@ -109,6 +155,25 @@ class CustomProfileFieldExternalAccountData { Map toJson() => _$CustomProfileFieldExternalAccountDataToJson(this); } +/// An item in the [InitialSnapshot.mutedUsers] or [MutedUsersEvent]. +/// +/// For docs, search for "muted_users:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class MutedUserItem { + final int id; + + // Mobile doesn't use the timestamp; ignore. + // final int timestamp; + + const MutedUserItem({required this.id}); + + factory MutedUserItem.fromJson(Map json) => + _$MutedUserItemFromJson(json); + + Map toJson() => _$MutedUserItemToJson(this); +} + /// An item in [InitialSnapshot.realmEmoji] or [RealmEmojiUpdateEvent]. /// /// For docs, search for "realm_emoji:" @@ -138,6 +203,123 @@ class RealmEmojiItem { Map toJson() => _$RealmEmojiItemToJson(this); } +/// A user's status, with [text] and [emoji] parts. +/// +/// If a part is null, that part is empty/unset. +/// For a [UserStatus] with all parts empty, see [zero]. +class UserStatus { + /// The text part (e.g. 'Working remotely'), or null if unset. + /// + /// This won't be the empty string. + final String? text; + + /// The emoji part, or null if unset. + final StatusEmoji? emoji; + + const UserStatus({required this.text, required this.emoji}) : assert(text != ''); + + static const UserStatus zero = UserStatus(text: null, emoji: null); + + @override + bool operator ==(Object other) { + if (other is! UserStatus) return false; + return (text, emoji) == (other.text, other.emoji); + } + + @override + int get hashCode => Object.hash(text, emoji); +} + +/// A user's status emoji, as in [UserStatus.emoji]. +class StatusEmoji { + final String emojiName; + final String emojiCode; + final ReactionType reactionType; + + const StatusEmoji({ + required this.emojiName, + required this.emojiCode, + required this.reactionType, + }) : assert(emojiName != ''), assert(emojiCode != ''); + + @override + bool operator ==(Object other) { + if (other is! StatusEmoji) return false; + return (emojiName, emojiCode, reactionType) == + (other.emojiName, other.emojiCode, other.reactionType); + } + + @override + int get hashCode => Object.hash(emojiName, emojiCode, reactionType); +} + +/// A change to part or all of a user's status. +/// +/// The absence of one of these means there is no change. +class UserStatusChange { + // final Option away; // deprecated in server-6 (FL-148); ignore + final Option text; + final Option emoji; + + const UserStatusChange({required this.text, required this.emoji}); + + UserStatus apply(UserStatus old) { + return UserStatus(text: text.or(old.text), emoji: emoji.or(old.emoji)); + } + + UserStatusChange copyWith({Option? text, Option? emoji}) { + return UserStatusChange(text: text ?? this.text, emoji: emoji ?? this.emoji); + } + + factory UserStatusChange.fromJson(Map json) { + return UserStatusChange( + text: _textFromJson(json), emoji: _emojiFromJson(json)); + } + + static Option _textFromJson(Map json) { + return switch (json['status_text'] as String?) { + null => OptionNone(), + '' => OptionSome(null), + final apiValue => OptionSome(apiValue), + }; + } + + static Option _emojiFromJson(Map json) { + final emojiName = json['emoji_name'] as String?; + final emojiCode = json['emoji_code'] as String?; + final reactionType = json['reaction_type'] as String?; + + if (emojiName == null || emojiCode == null || reactionType == null) { + return OptionNone(); + } else if (emojiName == '' || emojiCode == '' || reactionType == '') { + // Sometimes `reaction_type` is 'unicode_emoji' when the emoji is cleared. + // This is an accident, to be handled by looking at `emoji_code` instead: + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/user.20status/near/2203132 + return OptionSome(null); + } else { + return OptionSome(StatusEmoji( + emojiName: emojiName, + emojiCode: emojiCode, + reactionType: ReactionType.fromApiValue(reactionType))); + } + } + + Map toJson() { + return { + if (text case OptionSome(:var value)) + 'status_text': value ?? '', + if (emoji case OptionSome(:var value)) + ...value == null + ? {'emoji_name': '', 'emoji_code': '', 'reaction_type': ''} + : { + 'emoji_name': value.emojiName, + 'emoji_code': value.emojiCode, + 'reaction_type': value.reactionType, + }, + }; + } +} + /// The name of a user setting that has a property in [UserSettings]. /// /// In Zulip event-handling code (for [UserSettingsUpdateEvent]), @@ -147,7 +329,9 @@ class RealmEmojiItem { enum UserSettingName { twentyFourHourTime, displayEmojiReactionUsers, - emojiset; + emojiset, + presenceEnabled, + ; /// Get a [UserSettingName] from a raw, snake-case string we recognize, else null. /// @@ -158,6 +342,37 @@ enum UserSettingName { // _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake` static final _byRawString = _$UserSettingNameEnumMap .map((key, value) => MapEntry(value, key)); + + String toJson() => _$UserSettingNameEnumMap[this]!; +} + +/// A value from [UserSettings.twentyFourHourTime]. +enum TwentyFourHourTimeMode { + twelveHour(apiValue: false), + twentyFourHour(apiValue: true), + + /// The locale's default format (12-hour for en_US, 24-hour for fr_FR, etc.). + // TODO(#1727) actually follow this + // Not sent by current servers, but planned when most client installs accept it: + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/.60user_settings.2Etwenty_four_hour_time.60/near/2220696 + // TODO(server-future) Write down what server N starts sending null; + // adjust the comment; leave a TODO(server-N) to delete the comment + localeDefault(apiValue: null), + ; + + const TwentyFourHourTimeMode({required this.apiValue}); + + final bool? apiValue; + + static bool? staticToJson(TwentyFourHourTimeMode instance) => instance.apiValue; + + bool? toJson() => TwentyFourHourTimeMode.staticToJson(this); + + static TwentyFourHourTimeMode fromApiValue(bool? value) => switch (value) { + false => twelveHour, + true => twentyFourHour, + null => localeDefault, + }; } /// As in [UserSettings.emojiset]. @@ -177,6 +392,45 @@ enum Emojiset { // _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.kebab` static final _byRawString = _$EmojisetEnumMap .map((key, value) => MapEntry(value, key)); + + String toJson() => _$EmojisetEnumMap[this]!; +} + +/// As in [InitialSnapshot.realmUserGroups] or [UserGroupAddEvent]. +@JsonSerializable(fieldRename: FieldRename.snake) +class UserGroup { + final int id; + + final Set members; + final Set directSubgroupIds; + + String name; + String description; + + // final int? dateCreated; // not using; ignore + // final int? creatorId; // not using; ignore + + final bool isSystemGroup; + + // TODO(server-10): [deactivated] new in FL 290; previously no groups were deactivated + @JsonKey(defaultValue: false) + bool deactivated; + + // TODO(#814): GroupSettingValue canAddMembersGroup, etc.; add to update event too + + UserGroup({ + required this.id, + required this.members, + required this.directSubgroupIds, + required this.name, + required this.description, + required this.isSystemGroup, + required this.deactivated, + }); + + factory UserGroup.fromJson(Map json) => _$UserGroupFromJson(json); + + Map toJson() => _$UserGroupToJson(this); } /// As in [InitialSnapshot.realmUsers], [InitialSnapshot.realmNonActiveUsers], and [InitialSnapshot.crossRealmBots]. @@ -205,7 +459,6 @@ class User { // bool isOwner; // obsoleted by [role]; ignore // bool isAdmin; // obsoleted by [role]; ignore // bool isGuest; // obsoleted by [role]; ignore - bool? isBillingAdmin; // TODO(server-5) final bool isBot; final int? botType; // TODO enum int? botOwnerId; @@ -221,7 +474,9 @@ class User { @JsonKey(readValue: _readProfileData) Map? profileData; - @JsonKey(readValue: _readIsSystemBot) + // This field is absent in `realm_users` and `realm_non_active_users`, + // which contain no system bots; it's present in `cross_realm_bots`. + @JsonKey(defaultValue: false) final bool isSystemBot; static Map? _readProfileData(Map json, String key) { @@ -233,14 +488,6 @@ class User { return (value != null && value.isNotEmpty) ? value : null; } - static bool _readIsSystemBot(Map json, String key) { - // This field is absent in `realm_users` and `realm_non_active_users`, - // which contain no system bots; it's present in `cross_realm_bots`. - return (json[key] as bool?) - ?? (json['is_cross_realm_bot'] as bool?) // TODO(server-5): renamed to `is_system_bot` - ?? false; - } - User({ required this.userId, required this.deliveryEmail, @@ -248,7 +495,6 @@ class User { required this.fullName, required this.dateJoined, required this.isActive, - required this.isBillingAdmin, required this.isBot, required this.botType, required this.botOwnerId, @@ -310,6 +556,59 @@ enum UserRole{ } } +/// A value in [InitialSnapshot.presences]. +/// +/// For docs, search for "presences:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class PerUserPresence { + final int activeTimestamp; + final int idleTimestamp; + + PerUserPresence({ + required this.activeTimestamp, + required this.idleTimestamp, + }); + + factory PerUserPresence.fromJson(Map json) => + _$PerUserPresenceFromJson(json); + + Map toJson() => _$PerUserPresenceToJson(this); +} + +/// As in [PerClientPresence.status] and [updatePresence]. +@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) +enum PresenceStatus { + active, + idle; + + String toJson() => _$PresenceStatusEnumMap[this]!; +} + +/// An item in `saved_snippets` from the initial snapshot. +/// +/// For docs, search for "saved_snippets:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippet { + SavedSnippet({ + required this.id, + required this.title, + required this.content, + required this.dateCreated, + }); + + final int id; + final String title; + final String content; + final int dateCreated; + + factory SavedSnippet.fromJson(Map json) => + _$SavedSnippetFromJson(json); + + Map toJson() => _$SavedSnippetToJson(this); +} + /// As in `streams` in the initial snapshot. /// /// Not called `Stream` because dart:async uses that name. @@ -328,6 +627,12 @@ class ZulipStream { final int streamId; String name; + + // Servers that don't send this property will only send non-archived channels; + // default to false for those servers. + @JsonKey(defaultValue: false) + bool isArchived; // TODO(server-10) remove default and its comment + String description; String renderedDescription; @@ -342,7 +647,10 @@ class ZulipStream { ChannelPostPolicy channelPostPolicy; // final bool isAnnouncementOnly; // deprecated for `channelPostPolicy`; ignore - // GroupSettingsValue canRemoveSubscribersGroup; // TODO(#814) + GroupSettingValue? canAddSubscribersGroup; // TODO(server-10) + GroupSettingValue? canDeleteAnyMessageGroup; // TODO(server-11) + GroupSettingValue? canDeleteOwnMessageGroup; // TODO(server-11) + GroupSettingValue? canSubscribeGroup; // TODO(server-10) // TODO(server-8): added in FL 199, was previously only on [Subscription] objects int? streamWeeklyTraffic; @@ -350,6 +658,7 @@ class ZulipStream { ZulipStream({ required this.streamId, required this.name, + required this.isArchived, required this.description, required this.renderedDescription, required this.dateCreated, @@ -359,9 +668,36 @@ class ZulipStream { required this.historyPublicToSubscribers, required this.messageRetentionDays, required this.channelPostPolicy, + required this.canAddSubscribersGroup, + required this.canDeleteAnyMessageGroup, + required this.canDeleteOwnMessageGroup, + required this.canSubscribeGroup, required this.streamWeeklyTraffic, }); + /// Construct a plain [ZulipStream] from [subscription]. + factory ZulipStream.fromSubscription(Subscription subscription) { + return ZulipStream( + streamId: subscription.streamId, + name: subscription.name, + description: subscription.description, + isArchived: subscription.isArchived, + renderedDescription: subscription.renderedDescription, + dateCreated: subscription.dateCreated, + firstMessageId: subscription.firstMessageId, + inviteOnly: subscription.inviteOnly, + isWebPublic: subscription.isWebPublic, + historyPublicToSubscribers: subscription.historyPublicToSubscribers, + messageRetentionDays: subscription.messageRetentionDays, + channelPostPolicy: subscription.channelPostPolicy, + canAddSubscribersGroup: subscription.canAddSubscribersGroup, + canDeleteAnyMessageGroup: subscription.canDeleteAnyMessageGroup, + canDeleteOwnMessageGroup: subscription.canDeleteOwnMessageGroup, + canSubscribeGroup: subscription.canSubscribeGroup, + streamWeeklyTraffic: subscription.streamWeeklyTraffic, + ); + } + factory ZulipStream.fromJson(Map json) => _$ZulipStreamFromJson(json); @@ -378,6 +714,7 @@ class ZulipStream { enum ChannelPropertyName { // streamId is immutable name, + isArchived, description, // renderedDescription is updated via its own [ChannelUpdateEvent] field // dateCreated is immutable @@ -388,8 +725,10 @@ enum ChannelPropertyName { messageRetentionDays, @JsonValue('stream_post_policy') channelPostPolicy, - // canRemoveSubscribersGroup, // TODO(#814) - // canRemoveSubscribersGroupId, // TODO(#814) handle // TODO(server-8) remove + canAddSubscribersGroup, + canDeleteAnyMessageGroup, + canDeleteOwnMessageGroup, + canSubscribeGroup, streamWeeklyTraffic; /// Get a [ChannelPropertyName] from a raw, snake-case string we recognize, else null. @@ -461,6 +800,7 @@ class Subscription extends ZulipStream { required super.streamId, required super.name, required super.description, + required super.isArchived, required super.renderedDescription, required super.dateCreated, required super.firstMessageId, @@ -469,6 +809,10 @@ class Subscription extends ZulipStream { required super.historyPublicToSubscribers, required super.messageRetentionDays, required super.channelPostPolicy, + required super.canAddSubscribersGroup, + required super.canDeleteAnyMessageGroup, + required super.canDeleteOwnMessageGroup, + required super.canSubscribeGroup, required super.streamWeeklyTraffic, required this.desktopNotifications, required this.emailNotifications, @@ -531,10 +875,170 @@ String? tryParseEmojiCodeToUnicode(String emojiCode) { } } +/// The topic servers understand to mean "there is no topic". +/// +/// This should match +/// https://github.com/zulip/zulip/blob/6.0/zerver/actions/message_edit.py#L940 +/// or similar logic at the latest `main`. +// This is hardcoded in the server, and therefore untranslated; that's +// zulip/zulip#3639. +const String kNoTopicTopic = '(no topic)'; + +/// The name of a Zulip topic. +// TODO(dart): Can we forbid calling Object members on this extension type? +// (The lack of "implements Object" ought to do that, but doesn't.) +// 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]. + String? get displayName => _value.isEmpty ? null : _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; +} + +/// As in [MessageBase.conversation]. +/// +/// Different from [MessageDestination], this information comes from +/// [getMessages] or [getEvents], identifying the conversation that contains a +/// message. +sealed class Conversation { + /// Whether [this] and [other] refer to the same Zulip conversation. + bool isSameAs(Conversation other); +} + +/// The conversation a stream message is in. +@JsonSerializable(fieldRename: FieldRename.snake, createToJson: false) +class StreamConversation extends Conversation { + int streamId; + + @JsonKey(name: 'subject') + TopicName topic; + + /// The name of the channel with ID [streamId] when the message was sent. + /// + /// The primary reference for the name of the channel is + /// the client's data structures about channels, at [streamId]. + /// This value may be used as a fallback when the channel is unknown. + /// + /// This is non-null when found in a [StreamMessage] object in the API, + /// but may become null in the client's data structures, + /// e.g. if the message gets moved between channels. + @JsonKey(required: true, disallowNullValue: true) + String? displayRecipient; + + StreamConversation(this.streamId, this.topic, {required this.displayRecipient}); + + factory StreamConversation.fromJson(Map json) => + _$StreamConversationFromJson(json); + + @override + bool isSameAs(Conversation other) { + return other is StreamConversation + && streamId == other.streamId + && topic.isSameAs(other.topic); + } +} + +/// The conversation a DM message is in. +class DmConversation extends Conversation { + /// The user IDs of all users in the conversation, sorted numerically. + /// + /// This lists the sender as well as all (other) recipients, and it + /// lists each user just once. In particular the self-user is always + /// included. + final List allRecipientIds; + + DmConversation({required this.allRecipientIds}) + : assert(isSortedWithoutDuplicates(allRecipientIds.toList())); + + bool _equalIdSequences(Iterable xs, Iterable ys) { + if (xs.length != ys.length) return false; + final xs_ = xs.iterator; final ys_ = ys.iterator; + while (xs_.moveNext() && ys_.moveNext()) { + if (xs_.current != ys_.current) return false; + } + return true; + } + + @override + bool isSameAs(Conversation other) { + if (other is! DmConversation) return false; + return _equalIdSequences(allRecipientIds, other.allRecipientIds); + } +} + +/// A message or message-like object, for showing in a message list. +/// +/// Other than [Message], we use this for "outbox messages", +/// representing outstanding [sendMessage] requests. +abstract class MessageBase { + /// The Zulip message ID. + /// + /// If null, the message doesn't have an ID acknowledged by the server + /// (e.g.: a locally-echoed message). + int? get id; + + final int senderId; + final int timestamp; + + /// The conversation that contains this message. + /// + /// When implementing this, the return type should be either + /// [StreamConversation] or [DmConversation]; it should never be + /// [Conversation], because we expect a concrete subclass of [MessageBase] + /// to represent either a channel message or a DM message, not both. + T get conversation; + + const MessageBase({required this.senderId, required this.timestamp}); +} + /// As in the get-messages response. /// /// https://zulip.com/api/get-messages#response -sealed class Message { +sealed class Message extends MessageBase { // final String? avatarUrl; // Use [User.avatarUrl] instead; will live-update final String client; String content; @@ -544,6 +1048,7 @@ sealed class Message { @JsonKey(readValue: MessageEditState._readFromMessage, fromJson: Message._messageEditStateFromJson) MessageEditState editState; + @override final int id; bool isMeMessage; int? lastEditTimestamp; @@ -554,23 +1059,21 @@ sealed class Message { final int recipientId; final String senderEmail; final String senderFullName; - final int senderId; final String senderRealmStr; /// 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; // final List topicLinks; // TODO handle // final string type; // handled by runtime type of object @JsonKey(fromJson: _flagsFromJson) List flags; // Unrecognized flags won't roundtrip through {to,from}Json. - final String? matchContent; + String? matchContent; @JsonKey(name: 'match_subject') - final String? matchTopic; + String? matchTopic; static MessageEditState _messageEditStateFromJson(Object? json) { // This is a no-op so that [MessageEditState._readFromMessage] @@ -611,15 +1114,17 @@ sealed class Message { required this.recipientId, required this.senderEmail, required this.senderFullName, - required this.senderId, + required super.senderId, required this.senderRealmStr, - required this.timestamp, + required super.timestamp, required this.flags, required this.matchContent, required this.matchTopic, }); - factory Message.fromJson(Map json) { + // TODO(dart): This has to be a static method, because factories/constructors + // do not support type parameters: https://github.com/dart-lang/language/issues/647 + static Message fromJson(Map json) { final type = json['type'] as String; if (type == 'stream') return StreamMessage.fromJson(json); if (type == 'private') return DmMessage.fromJson(json); @@ -655,79 +1160,32 @@ 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(); - - /// 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 { +class StreamMessage extends Message { @override @JsonKey(includeToJson: true) String get type => 'stream'; - // This is not nullable API-wise, but if the message moves across channels, - // [displayRecipient] still refers to the original channel and it has to be - // invalidated. - @JsonKey(required: true, disallowNullValue: true) - String? displayRecipient; - - int streamId; + @JsonKey(includeToJson: true) + int get streamId => conversation.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; + @JsonKey(name: 'subject', includeToJson: true) + TopicName get topic => conversation.topic; + + @JsonKey(includeToJson: true) + String? get displayRecipient => conversation.displayRecipient; + + @override + @JsonKey(readValue: _readConversation, includeToJson: false) + StreamConversation conversation; + + static Map _readConversation(Map json, String key) { + return json as Map; + } StreamMessage({ required super.client, @@ -747,9 +1205,7 @@ class StreamMessage extends Message { required super.flags, required super.matchContent, required super.matchTopic, - required this.displayRecipient, - required this.streamId, - required this.topic, + required this.conversation, }); factory StreamMessage.fromJson(Map json) => @@ -760,77 +1216,38 @@ class StreamMessage extends Message { } @JsonSerializable(fieldRename: FieldRename.snake) -class DmRecipient { - final int id; - final String email; - final String fullName; - - // final String? shortName; // obsolete, ignore - // final bool? isMirrorDummy; // obsolete, ignore - - DmRecipient({required this.id, required this.email, required this.fullName}); - - factory DmRecipient.fromJson(Map json) => - _$DmRecipientFromJson(json); - - Map toJson() => _$DmRecipientToJson(this); - - @override - String toString() => 'DmRecipient(id: $id, email: $email, fullName: $fullName)'; - - @override - bool operator ==(Object other) { - if (other is! DmRecipient) return false; - return other.id == id && other.email == email && other.fullName == fullName; - } - - @override - int get hashCode => Object.hash('DmRecipient', id, email, fullName); -} - -class DmRecipientListConverter extends JsonConverter, List> { - const DmRecipientListConverter(); - - @override - List fromJson(List json) { - return json.map((e) => DmRecipient.fromJson(e as Map)) - .toList(growable: false) - ..sort((a, b) => a.id.compareTo(b.id)); - } - - @override - List toJson(List object) => object; -} - -@JsonSerializable(fieldRename: FieldRename.snake) -class DmMessage extends Message { +class DmMessage extends Message { @override @JsonKey(includeToJson: true) String get type => 'private'; - /// The `display_recipient` from the server, sorted by user ID numerically. + /// The user IDs of all users in the thread, sorted numerically, as in + /// `display_recipient` from the server. + /// + /// The other fields on `display_recipient` are ignored and won't roundtrip. /// /// This lists the sender as well as all (other) recipients, and it /// lists each user just once. In particular the self-user is always /// included. - /// - /// Note the data here is not updated on changes to the users, so everything - /// other than the user IDs may be stale. - /// Consider using [allRecipientIds] instead, and getting user details - /// from the store. // TODO(server): Document that it's all users. That statement is based on // reverse-engineering notes in zulip-mobile:src/api/modelTypes.js at PmMessage. - @DmRecipientListConverter() - final List displayRecipient; + @JsonKey(name: 'display_recipient', toJson: _allRecipientIdsToJson, includeToJson: true) + List get allRecipientIds => conversation.allRecipientIds; - /// The user IDs of all users in the thread, sorted numerically. - /// - /// This lists the sender as well as all (other) recipients, and it - /// lists each user just once. In particular the self-user is always - /// included. - /// - /// This is a result of [List.map], so it has an efficient `length`. - Iterable get allRecipientIds => displayRecipient.map((e) => e.id); + @override + @JsonKey(name: 'display_recipient', fromJson: _conversationFromJson, includeToJson: false) + final DmConversation conversation; + + static List> _allRecipientIdsToJson(List allRecipientIds) { + return allRecipientIds.map((element) => {'id': element}).toList(); + } + + static DmConversation _conversationFromJson(List json) { + return DmConversation(allRecipientIds: json.map( + (element) => ((element as Map)['id'] as num).toInt() + ).toList(growable: false) + ..sort()); + } DmMessage({ required super.client, @@ -850,7 +1267,7 @@ class DmMessage extends Message { required super.flags, required super.matchContent, required super.matchTopic, - required this.displayRecipient, + required this.conversation, }); factory DmMessage.fromJson(Map json) => @@ -918,18 +1335,11 @@ enum MessageEditState { continue; } - // TODO(server-5) prev_subject was the old name of prev_topic on pre-5.0 servers - 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) { - hasMoved = true; - } else { - hasMoved |= !topicMoveWasResolveOrUnresolve(topic, prevTopic); - } + final prevTopicStr = entry['prev_topic'] as String?; + if (prevTopicStr != null) { + final prevTopic = TopicName.fromJson(prevTopicStr); + final topic = TopicName.fromJson(entry['topic'] as String); + hasMoved |= !topicMoveWasResolveOrUnresolve(topic, prevTopic); } } @@ -948,4 +1358,15 @@ enum PropagateMode { changeAll; String toJson() => _$PropagateModeEnumMap[this]!; + + /// Get a [PropagateMode] from a raw string. Throws if the string is + /// unrecognized. + /// + /// Example: + /// 'change_one' -> PropagateMode.changeOne + static PropagateMode fromRawString(String raw) => _byRawString[raw]!; + + // _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake` + static final _byRawString = _$PropagateModeEnumMap + .map((key, value) => MapEntry(value, key)); } diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index cfa38aec5f..b21b2ee29e 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -8,11 +8,32 @@ part of 'model.dart'; // JsonSerializableGenerator // ************************************************************************** +GroupSettingValueNameless _$GroupSettingValueNamelessFromJson( + Map json, +) => GroupSettingValueNameless( + directMembers: (json['direct_members'] as List) + .map((e) => (e as num).toInt()) + .toList(), + directSubgroups: (json['direct_subgroups'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); + +Map _$GroupSettingValueNamelessToJson( + GroupSettingValueNameless instance, +) => { + 'direct_members': instance.directMembers, + 'direct_subgroups': instance.directSubgroups, +}; + CustomProfileField _$CustomProfileFieldFromJson(Map json) => CustomProfileField( id: (json['id'] as num).toInt(), - type: $enumDecode(_$CustomProfileFieldTypeEnumMap, json['type'], - unknownValue: CustomProfileFieldType.unknown), + type: $enumDecode( + _$CustomProfileFieldTypeEnumMap, + json['type'], + unknownValue: CustomProfileFieldType.unknown, + ), order: (json['order'] as num).toInt(), name: json['name'] as String, hint: json['hint'] as String, @@ -44,31 +65,32 @@ const _$CustomProfileFieldTypeEnumMap = { }; CustomProfileFieldChoiceDataItem _$CustomProfileFieldChoiceDataItemFromJson( - Map json) => - CustomProfileFieldChoiceDataItem( - text: json['text'] as String, - ); + Map json, +) => CustomProfileFieldChoiceDataItem(text: json['text'] as String); Map _$CustomProfileFieldChoiceDataItemToJson( - CustomProfileFieldChoiceDataItem instance) => - { - 'text': instance.text, - }; + CustomProfileFieldChoiceDataItem instance, +) => {'text': instance.text}; CustomProfileFieldExternalAccountData - _$CustomProfileFieldExternalAccountDataFromJson( - Map json) => - CustomProfileFieldExternalAccountData( - subtype: json['subtype'] as String, - urlPattern: json['url_pattern'] as String?, - ); +_$CustomProfileFieldExternalAccountDataFromJson(Map json) => + CustomProfileFieldExternalAccountData( + subtype: json['subtype'] as String, + urlPattern: json['url_pattern'] as String?, + ); Map _$CustomProfileFieldExternalAccountDataToJson( - CustomProfileFieldExternalAccountData instance) => - { - 'subtype': instance.subtype, - 'url_pattern': instance.urlPattern, - }; + CustomProfileFieldExternalAccountData instance, +) => { + 'subtype': instance.subtype, + 'url_pattern': instance.urlPattern, +}; + +MutedUserItem _$MutedUserItemFromJson(Map json) => + MutedUserItem(id: (json['id'] as num).toInt()); + +Map _$MutedUserItemToJson(MutedUserItem instance) => + {'id': instance.id}; RealmEmojiItem _$RealmEmojiItemFromJson(Map json) => RealmEmojiItem( @@ -90,50 +112,78 @@ Map _$RealmEmojiItemToJson(RealmEmojiItem instance) => 'author_id': instance.authorId, }; +UserGroup _$UserGroupFromJson(Map json) => UserGroup( + id: (json['id'] as num).toInt(), + members: (json['members'] as List) + .map((e) => (e as num).toInt()) + .toSet(), + directSubgroupIds: (json['direct_subgroup_ids'] as List) + .map((e) => (e as num).toInt()) + .toSet(), + name: json['name'] as String, + description: json['description'] as String, + isSystemGroup: json['is_system_group'] as bool, + deactivated: json['deactivated'] as bool? ?? false, +); + +Map _$UserGroupToJson(UserGroup instance) => { + 'id': instance.id, + 'members': instance.members.toList(), + 'direct_subgroup_ids': instance.directSubgroupIds.toList(), + 'name': instance.name, + 'description': instance.description, + 'is_system_group': instance.isSystemGroup, + 'deactivated': instance.deactivated, +}; + User _$UserFromJson(Map json) => User( - userId: (json['user_id'] as num).toInt(), - deliveryEmail: json['delivery_email'] as String?, - email: json['email'] as String, - fullName: json['full_name'] as String, - dateJoined: json['date_joined'] as String, - isActive: json['is_active'] as bool, - isBillingAdmin: json['is_billing_admin'] as bool?, - isBot: json['is_bot'] as bool, - botType: (json['bot_type'] as num?)?.toInt(), - botOwnerId: (json['bot_owner_id'] as num?)?.toInt(), - role: $enumDecode(_$UserRoleEnumMap, json['role'], - unknownValue: UserRole.unknown), - timezone: json['timezone'] as String, - avatarUrl: json['avatar_url'] as String?, - avatarVersion: (json['avatar_version'] as num).toInt(), - profileData: - (User._readProfileData(json, 'profile_data') as Map?) - ?.map( - (k, e) => MapEntry(int.parse(k), - ProfileFieldUserData.fromJson(e as Map)), - ), - isSystemBot: User._readIsSystemBot(json, 'is_system_bot') as bool, - ); + userId: (json['user_id'] as num).toInt(), + deliveryEmail: json['delivery_email'] as String?, + email: json['email'] as String, + fullName: json['full_name'] as String, + dateJoined: json['date_joined'] as String, + isActive: json['is_active'] as bool, + isBot: json['is_bot'] as bool, + botType: (json['bot_type'] as num?)?.toInt(), + botOwnerId: (json['bot_owner_id'] as num?)?.toInt(), + role: $enumDecode( + _$UserRoleEnumMap, + json['role'], + unknownValue: UserRole.unknown, + ), + timezone: json['timezone'] as String, + avatarUrl: json['avatar_url'] as String?, + avatarVersion: (json['avatar_version'] as num).toInt(), + profileData: + (User._readProfileData(json, 'profile_data') as Map?) + ?.map( + (k, e) => MapEntry( + int.parse(k), + ProfileFieldUserData.fromJson(e as Map), + ), + ), + isSystemBot: json['is_system_bot'] as bool? ?? false, +); Map _$UserToJson(User instance) => { - 'user_id': instance.userId, - 'delivery_email': instance.deliveryEmail, - 'email': instance.email, - 'full_name': instance.fullName, - 'date_joined': instance.dateJoined, - 'is_active': instance.isActive, - 'is_billing_admin': instance.isBillingAdmin, - 'is_bot': instance.isBot, - 'bot_type': instance.botType, - 'bot_owner_id': instance.botOwnerId, - 'role': instance.role, - 'timezone': instance.timezone, - 'avatar_url': instance.avatarUrl, - 'avatar_version': instance.avatarVersion, - 'profile_data': - instance.profileData?.map((k, e) => MapEntry(k.toString(), e)), - 'is_system_bot': instance.isSystemBot, - }; + 'user_id': instance.userId, + 'delivery_email': instance.deliveryEmail, + 'email': instance.email, + 'full_name': instance.fullName, + 'date_joined': instance.dateJoined, + 'is_active': instance.isActive, + 'is_bot': instance.isBot, + 'bot_type': instance.botType, + 'bot_owner_id': instance.botOwnerId, + 'role': instance.role, + 'timezone': instance.timezone, + 'avatar_url': instance.avatarUrl, + 'avatar_version': instance.avatarVersion, + 'profile_data': instance.profileData?.map( + (k, e) => MapEntry(k.toString(), e), + ), + 'is_system_bot': instance.isSystemBot, +}; const _$UserRoleEnumMap = { UserRole.owner: 100, @@ -145,39 +195,82 @@ const _$UserRoleEnumMap = { }; ProfileFieldUserData _$ProfileFieldUserDataFromJson( - Map json) => - ProfileFieldUserData( - value: json['value'] as String, - renderedValue: json['rendered_value'] as String?, - ); + Map json, +) => ProfileFieldUserData( + value: json['value'] as String, + renderedValue: json['rendered_value'] as String?, +); Map _$ProfileFieldUserDataToJson( - ProfileFieldUserData instance) => + ProfileFieldUserData instance, +) => { + 'value': instance.value, + 'rendered_value': instance.renderedValue, +}; + +PerUserPresence _$PerUserPresenceFromJson(Map json) => + PerUserPresence( + activeTimestamp: (json['active_timestamp'] as num).toInt(), + idleTimestamp: (json['idle_timestamp'] as num).toInt(), + ); + +Map _$PerUserPresenceToJson(PerUserPresence instance) => { - 'value': instance.value, - 'rendered_value': instance.renderedValue, + 'active_timestamp': instance.activeTimestamp, + 'idle_timestamp': instance.idleTimestamp, + }; + +SavedSnippet _$SavedSnippetFromJson(Map json) => SavedSnippet( + id: (json['id'] as num).toInt(), + title: json['title'] as String, + content: json['content'] as String, + dateCreated: (json['date_created'] as num).toInt(), +); + +Map _$SavedSnippetToJson(SavedSnippet instance) => + { + 'id': instance.id, + 'title': instance.title, + 'content': instance.content, + 'date_created': instance.dateCreated, }; ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream( - streamId: (json['stream_id'] as num).toInt(), - name: json['name'] as String, - description: json['description'] as String, - renderedDescription: json['rendered_description'] as String, - dateCreated: (json['date_created'] as num).toInt(), - firstMessageId: (json['first_message_id'] as num?)?.toInt(), - inviteOnly: json['invite_only'] as bool, - isWebPublic: json['is_web_public'] as bool, - historyPublicToSubscribers: json['history_public_to_subscribers'] as bool, - messageRetentionDays: (json['message_retention_days'] as num?)?.toInt(), - channelPostPolicy: - $enumDecode(_$ChannelPostPolicyEnumMap, json['stream_post_policy']), - streamWeeklyTraffic: (json['stream_weekly_traffic'] as num?)?.toInt(), - ); + streamId: (json['stream_id'] as num).toInt(), + name: json['name'] as String, + isArchived: json['is_archived'] as bool? ?? false, + description: json['description'] as String, + renderedDescription: json['rendered_description'] as String, + dateCreated: (json['date_created'] as num).toInt(), + firstMessageId: (json['first_message_id'] as num?)?.toInt(), + inviteOnly: json['invite_only'] as bool, + isWebPublic: json['is_web_public'] as bool, + historyPublicToSubscribers: json['history_public_to_subscribers'] as bool, + messageRetentionDays: (json['message_retention_days'] as num?)?.toInt(), + channelPostPolicy: $enumDecode( + _$ChannelPostPolicyEnumMap, + json['stream_post_policy'], + ), + canAddSubscribersGroup: json['can_add_subscribers_group'] == null + ? null + : GroupSettingValue.fromJson(json['can_add_subscribers_group']), + canDeleteAnyMessageGroup: json['can_delete_any_message_group'] == null + ? null + : GroupSettingValue.fromJson(json['can_delete_any_message_group']), + canDeleteOwnMessageGroup: json['can_delete_own_message_group'] == null + ? null + : GroupSettingValue.fromJson(json['can_delete_own_message_group']), + canSubscribeGroup: json['can_subscribe_group'] == null + ? null + : GroupSettingValue.fromJson(json['can_subscribe_group']), + streamWeeklyTraffic: (json['stream_weekly_traffic'] as num?)?.toInt(), +); Map _$ZulipStreamToJson(ZulipStream instance) => { 'stream_id': instance.streamId, 'name': instance.name, + 'is_archived': instance.isArchived, 'description': instance.description, 'rendered_description': instance.renderedDescription, 'date_created': instance.dateCreated, @@ -187,6 +280,10 @@ Map _$ZulipStreamToJson(ZulipStream instance) => 'history_public_to_subscribers': instance.historyPublicToSubscribers, 'message_retention_days': instance.messageRetentionDays, 'stream_post_policy': instance.channelPostPolicy, + 'can_add_subscribers_group': instance.canAddSubscribersGroup, + 'can_delete_any_message_group': instance.canDeleteAnyMessageGroup, + 'can_delete_own_message_group': instance.canDeleteOwnMessageGroup, + 'can_subscribe_group': instance.canSubscribeGroup, 'stream_weekly_traffic': instance.streamWeeklyTraffic, }; @@ -199,33 +296,49 @@ const _$ChannelPostPolicyEnumMap = { }; Subscription _$SubscriptionFromJson(Map json) => Subscription( - streamId: (json['stream_id'] as num).toInt(), - name: json['name'] as String, - description: json['description'] as String, - renderedDescription: json['rendered_description'] as String, - dateCreated: (json['date_created'] as num).toInt(), - firstMessageId: (json['first_message_id'] as num?)?.toInt(), - inviteOnly: json['invite_only'] as bool, - isWebPublic: json['is_web_public'] as bool, - historyPublicToSubscribers: json['history_public_to_subscribers'] as bool, - messageRetentionDays: (json['message_retention_days'] as num?)?.toInt(), - channelPostPolicy: - $enumDecode(_$ChannelPostPolicyEnumMap, json['stream_post_policy']), - streamWeeklyTraffic: (json['stream_weekly_traffic'] as num?)?.toInt(), - desktopNotifications: json['desktop_notifications'] as bool?, - emailNotifications: json['email_notifications'] as bool?, - wildcardMentionsNotify: json['wildcard_mentions_notify'] as bool?, - pushNotifications: json['push_notifications'] as bool?, - audibleNotifications: json['audible_notifications'] as bool?, - pinToTop: json['pin_to_top'] as bool, - isMuted: json['is_muted'] as bool, - color: (Subscription._readColor(json, 'color') as num).toInt(), - ); + streamId: (json['stream_id'] as num).toInt(), + name: json['name'] as String, + description: json['description'] as String, + isArchived: json['is_archived'] as bool? ?? false, + renderedDescription: json['rendered_description'] as String, + dateCreated: (json['date_created'] as num).toInt(), + firstMessageId: (json['first_message_id'] as num?)?.toInt(), + inviteOnly: json['invite_only'] as bool, + isWebPublic: json['is_web_public'] as bool, + historyPublicToSubscribers: json['history_public_to_subscribers'] as bool, + messageRetentionDays: (json['message_retention_days'] as num?)?.toInt(), + channelPostPolicy: $enumDecode( + _$ChannelPostPolicyEnumMap, + json['stream_post_policy'], + ), + canAddSubscribersGroup: json['can_add_subscribers_group'] == null + ? null + : GroupSettingValue.fromJson(json['can_add_subscribers_group']), + canDeleteAnyMessageGroup: json['can_delete_any_message_group'] == null + ? null + : GroupSettingValue.fromJson(json['can_delete_any_message_group']), + canDeleteOwnMessageGroup: json['can_delete_own_message_group'] == null + ? null + : GroupSettingValue.fromJson(json['can_delete_own_message_group']), + canSubscribeGroup: json['can_subscribe_group'] == null + ? null + : GroupSettingValue.fromJson(json['can_subscribe_group']), + streamWeeklyTraffic: (json['stream_weekly_traffic'] as num?)?.toInt(), + desktopNotifications: json['desktop_notifications'] as bool?, + emailNotifications: json['email_notifications'] as bool?, + wildcardMentionsNotify: json['wildcard_mentions_notify'] as bool?, + pushNotifications: json['push_notifications'] as bool?, + audibleNotifications: json['audible_notifications'] as bool?, + pinToTop: json['pin_to_top'] as bool, + isMuted: json['is_muted'] as bool, + color: (Subscription._readColor(json, 'color') as num).toInt(), +); Map _$SubscriptionToJson(Subscription instance) => { 'stream_id': instance.streamId, 'name': instance.name, + 'is_archived': instance.isArchived, 'description': instance.description, 'rendered_description': instance.renderedDescription, 'date_created': instance.dateCreated, @@ -235,6 +348,10 @@ Map _$SubscriptionToJson(Subscription instance) => 'history_public_to_subscribers': instance.historyPublicToSubscribers, 'message_retention_days': instance.messageRetentionDays, 'stream_post_policy': instance.channelPostPolicy, + 'can_add_subscribers_group': instance.canAddSubscribersGroup, + 'can_delete_any_message_group': instance.canDeleteAnyMessageGroup, + 'can_delete_own_message_group': instance.canDeleteOwnMessageGroup, + 'can_subscribe_group': instance.canSubscribeGroup, 'stream_weekly_traffic': instance.streamWeeklyTraffic, 'desktop_notifications': instance.desktopNotifications, 'email_notifications': instance.emailNotifications, @@ -246,89 +363,27 @@ Map _$SubscriptionToJson(Subscription instance) => 'color': instance.color, }; -StreamMessage _$StreamMessageFromJson(Map json) { +StreamConversation _$StreamConversationFromJson(Map json) { $checkKeys( json, requiredKeys: const ['display_recipient'], disallowNullValues: const ['display_recipient'], ); - return StreamMessage( - client: json['client'] as String, - content: json['content'] as String, - contentType: json['content_type'] as String, - editState: Message._messageEditStateFromJson( - MessageEditState._readFromMessage(json, 'edit_state')), - id: (json['id'] as num).toInt(), - isMeMessage: json['is_me_message'] as bool, - lastEditTimestamp: (json['last_edit_timestamp'] as num?)?.toInt(), - reactions: Message._reactionsFromJson(json['reactions']), - recipientId: (json['recipient_id'] as num).toInt(), - senderEmail: json['sender_email'] as String, - senderFullName: json['sender_full_name'] as String, - senderId: (json['sender_id'] as num).toInt(), - senderRealmStr: json['sender_realm_str'] as String, - timestamp: (json['timestamp'] as num).toInt(), - flags: Message._flagsFromJson(json['flags']), - matchContent: json['match_content'] as String?, - matchTopic: json['match_subject'] as String?, + return StreamConversation( + (json['stream_id'] as num).toInt(), + TopicName.fromJson(json['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')); + ); } -Map _$StreamMessageToJson(StreamMessage instance) => - { - 'client': instance.client, - 'content': instance.content, - 'content_type': instance.contentType, - 'edit_state': _$MessageEditStateEnumMap[instance.editState]!, - 'id': instance.id, - 'is_me_message': instance.isMeMessage, - 'last_edit_timestamp': instance.lastEditTimestamp, - 'reactions': Message._reactionsToJson(instance.reactions), - 'recipient_id': instance.recipientId, - 'sender_email': instance.senderEmail, - 'sender_full_name': instance.senderFullName, - 'sender_id': instance.senderId, - 'sender_realm_str': instance.senderRealmStr, - 'submessages': Poll.toJson(instance.poll), - 'timestamp': instance.timestamp, - 'flags': instance.flags, - 'match_content': instance.matchContent, - 'match_subject': instance.matchTopic, - 'type': instance.type, - if (instance.displayRecipient case final value?) - 'display_recipient': value, - 'stream_id': instance.streamId, - 'subject': instance.topic, - }; - -const _$MessageEditStateEnumMap = { - MessageEditState.none: 'none', - MessageEditState.edited: 'edited', - MessageEditState.moved: 'moved', -}; - -DmRecipient _$DmRecipientFromJson(Map json) => DmRecipient( - id: (json['id'] as num).toInt(), - email: json['email'] as String, - fullName: json['full_name'] as String, - ); - -Map _$DmRecipientToJson(DmRecipient instance) => - { - 'id': instance.id, - 'email': instance.email, - 'full_name': instance.fullName, - }; - -DmMessage _$DmMessageFromJson(Map json) => DmMessage( +StreamMessage _$StreamMessageFromJson(Map json) => + StreamMessage( client: json['client'] as String, content: json['content'] as String, contentType: json['content_type'] as String, editState: Message._messageEditStateFromJson( - MessageEditState._readFromMessage(json, 'edit_state')), + MessageEditState._readFromMessage(json, 'edit_state'), + ), id: (json['id'] as num).toInt(), isMeMessage: json['is_me_message'] as bool, lastEditTimestamp: (json['last_edit_timestamp'] as num?)?.toInt(), @@ -342,11 +397,16 @@ DmMessage _$DmMessageFromJson(Map json) => DmMessage( flags: Message._flagsFromJson(json['flags']), matchContent: json['match_content'] as String?, matchTopic: json['match_subject'] as String?, - displayRecipient: const DmRecipientListConverter() - .fromJson(json['display_recipient'] as List), + conversation: StreamConversation.fromJson( + StreamMessage._readConversation(json, 'conversation') + as Map, + ), )..poll = Poll.fromJson(Message._readPoll(json, 'submessages')); -Map _$DmMessageToJson(DmMessage instance) => { +Map _$StreamMessageToJson(StreamMessage instance) => + { + 'sender_id': instance.senderId, + 'timestamp': instance.timestamp, 'client': instance.client, 'content': instance.content, 'content_type': instance.contentType, @@ -358,22 +418,78 @@ Map _$DmMessageToJson(DmMessage instance) => { 'recipient_id': instance.recipientId, 'sender_email': instance.senderEmail, 'sender_full_name': instance.senderFullName, - 'sender_id': instance.senderId, 'sender_realm_str': instance.senderRealmStr, 'submessages': Poll.toJson(instance.poll), - 'timestamp': instance.timestamp, 'flags': instance.flags, 'match_content': instance.matchContent, 'match_subject': instance.matchTopic, 'type': instance.type, - 'display_recipient': - const DmRecipientListConverter().toJson(instance.displayRecipient), + 'stream_id': instance.streamId, + 'subject': instance.topic, + 'display_recipient': instance.displayRecipient, }; +const _$MessageEditStateEnumMap = { + MessageEditState.none: 'none', + MessageEditState.edited: 'edited', + MessageEditState.moved: 'moved', +}; + +DmMessage _$DmMessageFromJson(Map json) => DmMessage( + client: json['client'] as String, + content: json['content'] as String, + contentType: json['content_type'] as String, + editState: Message._messageEditStateFromJson( + MessageEditState._readFromMessage(json, 'edit_state'), + ), + id: (json['id'] as num).toInt(), + isMeMessage: json['is_me_message'] as bool, + lastEditTimestamp: (json['last_edit_timestamp'] as num?)?.toInt(), + reactions: Message._reactionsFromJson(json['reactions']), + recipientId: (json['recipient_id'] as num).toInt(), + senderEmail: json['sender_email'] as String, + senderFullName: json['sender_full_name'] as String, + senderId: (json['sender_id'] as num).toInt(), + senderRealmStr: json['sender_realm_str'] as String, + timestamp: (json['timestamp'] as num).toInt(), + flags: Message._flagsFromJson(json['flags']), + matchContent: json['match_content'] as String?, + matchTopic: json['match_subject'] as String?, + conversation: DmMessage._conversationFromJson( + json['display_recipient'] as List, + ), +)..poll = Poll.fromJson(Message._readPoll(json, 'submessages')); + +Map _$DmMessageToJson(DmMessage instance) => { + 'sender_id': instance.senderId, + 'timestamp': instance.timestamp, + 'client': instance.client, + 'content': instance.content, + 'content_type': instance.contentType, + 'edit_state': _$MessageEditStateEnumMap[instance.editState]!, + 'id': instance.id, + 'is_me_message': instance.isMeMessage, + 'last_edit_timestamp': instance.lastEditTimestamp, + 'reactions': Message._reactionsToJson(instance.reactions), + 'recipient_id': instance.recipientId, + 'sender_email': instance.senderEmail, + 'sender_full_name': instance.senderFullName, + 'sender_realm_str': instance.senderRealmStr, + 'submessages': Poll.toJson(instance.poll), + 'flags': instance.flags, + 'match_content': instance.matchContent, + 'match_subject': instance.matchTopic, + 'type': instance.type, + 'display_recipient': DmMessage._allRecipientIdsToJson( + instance.allRecipientIds, + ), +}; + const _$UserSettingNameEnumMap = { UserSettingName.twentyFourHourTime: 'twenty_four_hour_time', UserSettingName.displayEmojiReactionUsers: 'display_emoji_reaction_users', UserSettingName.emojiset: 'emojiset', + UserSettingName.presenceEnabled: 'presence_enabled', }; const _$EmojisetEnumMap = { @@ -383,13 +499,23 @@ const _$EmojisetEnumMap = { Emojiset.text: 'text', }; +const _$PresenceStatusEnumMap = { + PresenceStatus.active: 'active', + PresenceStatus.idle: 'idle', +}; + const _$ChannelPropertyNameEnumMap = { ChannelPropertyName.name: 'name', + ChannelPropertyName.isArchived: 'is_archived', ChannelPropertyName.description: 'description', ChannelPropertyName.firstMessageId: 'first_message_id', ChannelPropertyName.inviteOnly: 'invite_only', ChannelPropertyName.messageRetentionDays: 'message_retention_days', ChannelPropertyName.channelPostPolicy: 'stream_post_policy', + ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group', + ChannelPropertyName.canDeleteAnyMessageGroup: 'can_delete_any_message_group', + ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_message_group', + ChannelPropertyName.canSubscribeGroup: 'can_subscribe_group', ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic', }; diff --git a/lib/api/model/narrow.dart b/lib/api/model/narrow.dart index 8082ac64ff..3f9ebf2bc8 100644 --- a/lib/api/model/narrow.dart +++ b/lib/api/model/narrow.dart @@ -6,18 +6,46 @@ part 'narrow.g.dart'; typedef ApiNarrow = List; -/// Resolve any [ApiNarrowDm] elements appropriately. +/// Adapt the given narrow to be sent to the given Zulip server version. /// -/// This encapsulates a server-feature check. -ApiNarrow resolveDmElements(ApiNarrow narrow, int zulipFeatureLevel) { - if (!narrow.any((element) => element is ApiNarrowDm)) { +/// Any elements that take a different name on old vs. new servers +/// will be resolved to the specific name to use. +/// Any elements that are unknown to old servers and can +/// reasonably be omitted will be omitted. +ApiNarrow resolveApiNarrowForServer(ApiNarrow narrow, int zulipFeatureLevel) { + final supportsOperatorDm = zulipFeatureLevel >= 177; // TODO(server-7) + final supportsOperatorChannel = zulipFeatureLevel >= 250; // TODO(server-9) + final supportsOperatorWith = zulipFeatureLevel >= 271; // TODO(server-9) + + bool hasDmElement = false; + bool hasChannelElement = false; + bool hasWithElement = false; + for (final element in narrow) { + switch (element) { + case ApiNarrowChannel(): hasChannelElement = true; + case ApiNarrowDm(): hasDmElement = true; + case ApiNarrowWith(): hasWithElement = true; + default: + } + } + if (!(hasChannelElement || hasDmElement || (hasWithElement && !supportsOperatorWith))) { return narrow; } - final supportsOperatorDm = zulipFeatureLevel >= 177; // TODO(server-7) - return narrow.map((element) => switch (element) { - ApiNarrowDm() => element.resolve(legacy: !supportsOperatorDm), - _ => element, - }).toList(); + + final result = []; + for (final element in narrow) { + switch (element) { + case ApiNarrowChannel(): + result.add(element.resolve(legacy: !supportsOperatorChannel)); + case ApiNarrowDm(): + result.add(element.resolve(legacy: !supportsOperatorDm)); + case ApiNarrowWith() when !supportsOperatorWith: + break; // drop unsupported element + default: + result.add(element); + } + } + return result; } /// An element in the list representing a narrow in the Zulip API. @@ -70,17 +98,51 @@ sealed class ApiNarrowElement { }; } -class ApiNarrowStream extends ApiNarrowElement { - @override String get operator => 'stream'; +class ApiNarrowChannel extends ApiNarrowElement { + @override String get operator { + assert(false, + "The [operator] getter was called on a plain [ApiNarrowChannel]. " + "Before passing to [jsonEncode] or otherwise getting [operator], " + "the [ApiNarrowChannel] must be replaced by the result of [ApiNarrowChannel.resolve]." + ); + return "channel"; + } @override final int operand; - ApiNarrowStream(this.operand, {super.negated}); + ApiNarrowChannel(this.operand, {super.negated}); - factory ApiNarrowStream.fromJson(Map json) => ApiNarrowStream( - json['operand'] as int, - negated: json['negated'] as bool? ?? false, - ); + factory ApiNarrowChannel.fromJson(Map json) { + var operand = (json['operand'] as int); + var negated = json['negated'] as bool? ?? false; + return json['operator'] == 'stream' + ? ApiNarrowStream._(operand, negated: negated) + : ApiNarrowChannelModern._(operand, negated: negated); + } + + /// This element resolved, as either an [ApiNarrowChannelModern] or an [ApiNarrowStream]. + ApiNarrowChannel resolve({required bool legacy}) { + return legacy ? ApiNarrowStream._(operand, negated: negated) + : ApiNarrowChannelModern._(operand, negated: negated); + } +} + +/// An [ApiNarrowElement] with the 'channel' operator (and not the legacy 'stream'). +/// +/// To construct one of these, use [ApiNarrowChannel.resolve]. +class ApiNarrowChannelModern extends ApiNarrowChannel { + @override String get operator => 'channel'; + + ApiNarrowChannelModern._(super.operand, {super.negated}); +} + +/// An [ApiNarrowElement] with the legacy 'stream' operator. +/// +/// To construct one of these, use [ApiNarrowChannel.resolve]. +class ApiNarrowStream extends ApiNarrowChannel { + @override String get operator => 'stream'; + + ApiNarrowStream._(super.operand, {super.negated}); } class ApiNarrowTopic extends ApiNarrowElement { @@ -102,7 +164,7 @@ class ApiNarrowTopic extends ApiNarrowElement { /// and more generally its [operator] getter must not be called. /// Instead, call [resolve] and use the object it returns. /// -/// If part of [ApiNarrow] use [resolveDmElements]. +/// If part of [ApiNarrow] use [resolveApiNarrowForServer]. class ApiNarrowDm extends ApiNarrowElement { @override String get operator { assert(false, @@ -150,6 +212,20 @@ class ApiNarrowPmWith extends ApiNarrowDm { ApiNarrowPmWith._(super.operand, {super.negated}); } +/// An [ApiNarrowElement] with the 'search' operator. +class ApiNarrowSearch extends ApiNarrowElement { + @override String get operator => 'search'; + + @override final String operand; + + ApiNarrowSearch(this.operand, {super.negated}); + + factory ApiNarrowSearch.fromJson(Map json) => ApiNarrowSearch( + json['operand'] as String, + negated: json['negated'] as bool? ?? false, + ); +} + class ApiNarrowIs extends ApiNarrowElement { @override String get operator => 'is'; @@ -190,6 +266,22 @@ enum IsOperand { String toJson() => toString(); } +/// An [ApiNarrowElement] with the 'with' operator. +/// +/// If part of [ApiNarrow] use [resolveApiNarrowForServer]. +class ApiNarrowWith extends ApiNarrowElement { + @override String get operator => 'with'; + + @override final int operand; + + ApiNarrowWith(this.operand, {super.negated}); + + factory ApiNarrowWith.fromJson(Map json) => ApiNarrowWith( + json['operand'] as int, + negated: json['negated'] as bool? ?? false, + ); +} + class ApiNarrowMessageId extends ApiNarrowElement { @override String get operator => 'id'; diff --git a/lib/api/model/reaction.dart b/lib/api/model/reaction.dart index 54804d0d05..50d93924d8 100644 --- a/lib/api/model/reaction.dart +++ b/lib/api/model/reaction.dart @@ -175,4 +175,9 @@ enum ReactionType { zulipExtraEmoji; String toJson() => _$ReactionTypeEnumMap[this]!; + + static ReactionType fromApiValue(String value) => _byApiValue[value]!; + + static final _byApiValue = _$ReactionTypeEnumMap + .map((key, value) => MapEntry(value, key)); } diff --git a/lib/api/model/reaction.g.dart b/lib/api/model/reaction.g.dart index 886c774063..36e93d82cb 100644 --- a/lib/api/model/reaction.g.dart +++ b/lib/api/model/reaction.g.dart @@ -9,18 +9,18 @@ part of 'reaction.dart'; // ************************************************************************** Reaction _$ReactionFromJson(Map json) => Reaction( - emojiName: json['emoji_name'] as String, - emojiCode: json['emoji_code'] as String, - reactionType: $enumDecode(_$ReactionTypeEnumMap, json['reaction_type']), - userId: (json['user_id'] as num).toInt(), - ); + emojiName: json['emoji_name'] as String, + emojiCode: json['emoji_code'] as String, + reactionType: $enumDecode(_$ReactionTypeEnumMap, json['reaction_type']), + userId: (json['user_id'] as num).toInt(), +); Map _$ReactionToJson(Reaction instance) => { - 'emoji_name': instance.emojiName, - 'emoji_code': instance.emojiCode, - 'reaction_type': instance.reactionType, - 'user_id': instance.userId, - }; + 'emoji_name': instance.emojiName, + 'emoji_code': instance.emojiCode, + 'reaction_type': instance.reactionType, + 'user_id': instance.userId, +}; const _$ReactionTypeEnumMap = { ReactionType.unicodeEmoji: 'unicode_emoji', diff --git a/lib/api/model/submessage.dart b/lib/api/model/submessage.dart index f338265b46..81181f061f 100644 --- a/lib/api/model/submessage.dart +++ b/lib/api/model/submessage.dart @@ -64,6 +64,7 @@ class Submessage { widgetData: widgetData, pollEventSubmessages: submessages.skip(1), messageSenderId: messageSenderId, + debugSubmessages: kDebugMode ? submessages : null, ); case UnsupportedWidgetData(): assert(debugLog('Unsupported widgetData: ${widgetData.json}')); @@ -368,11 +369,13 @@ class Poll extends ChangeNotifier { required PollWidgetData widgetData, required Iterable pollEventSubmessages, required int messageSenderId, + required List? debugSubmessages, }) { final poll = Poll._( messageSenderId: messageSenderId, question: widgetData.extraData.question, options: widgetData.extraData.options, + debugSubmessages: debugSubmessages, ); for (final submessage in pollEventSubmessages) { @@ -386,17 +389,23 @@ class Poll extends ChangeNotifier { required this.messageSenderId, required this.question, required List options, + required List? debugSubmessages, }) { for (int index = 0; index < options.length; index += 1) { // Initial poll options use a placeholder senderId. // See [PollEventSubmessage.optionKey] for details. _addOption(senderId: null, idx: index, option: options[index]); } + if (kDebugMode) { + _debugSubmessages = debugSubmessages; + } } final int messageSenderId; String question; + List? _debugSubmessages; + /// The limit of options any single user can add to a poll. /// /// See https://github.com/zulip/zulip/blob/304d948416465c1a085122af5d752f03d6797003/web/shared/src/poll_data.ts#L69-L71 @@ -417,6 +426,14 @@ class Poll extends ChangeNotifier { } _applyEvent(event.senderId, pollEventSubmessage); notifyListeners(); + + if (kDebugMode) { + assert(_debugSubmessages != null); + _debugSubmessages!.add(Submessage( + senderId: event.senderId, + msgType: event.msgType, + content: event.content)); + } } void _applyEvent(int senderId, PollEventSubmessage event) { @@ -472,9 +489,18 @@ class Poll extends ChangeNotifier { } static List toJson(Poll? poll) { - // Rather than maintaining a up-to-date submessages list, return as if it is - // empty, because we are not sending the submessages to the server anyway. - return []; + List? result; + + if (kDebugMode) { + // Useful for setting up a message list with a poll message, which goes + // through this codepath (when preparing a fetch response). + result = poll?._debugSubmessages; + } + + // In prod, rather than maintaining a up-to-date submessages list, + // return as if it is empty, because we are not sending the submessages + // to the server anyway. + return result ?? []; } } diff --git a/lib/api/model/submessage.g.dart b/lib/api/model/submessage.g.dart index 0593563401..b2de37927f 100644 --- a/lib/api/model/submessage.g.dart +++ b/lib/api/model/submessage.g.dart @@ -9,11 +9,14 @@ part of 'submessage.dart'; // ************************************************************************** Submessage _$SubmessageFromJson(Map json) => Submessage( - senderId: (json['sender_id'] as num).toInt(), - msgType: $enumDecode(_$SubmessageTypeEnumMap, json['msg_type'], - unknownValue: SubmessageType.unknown), - content: json['content'] as String, - ); + senderId: (json['sender_id'] as num).toInt(), + msgType: $enumDecode( + _$SubmessageTypeEnumMap, + json['msg_type'], + unknownValue: SubmessageType.unknown, + ), + content: json['content'] as String, +); Map _$SubmessageToJson(Submessage instance) => { @@ -30,7 +33,8 @@ const _$SubmessageTypeEnumMap = { PollWidgetData _$PollWidgetDataFromJson(Map json) => PollWidgetData( extraData: PollWidgetExtraData.fromJson( - json['extra_data'] as Map), + json['extra_data'] as Map, + ), ); Map _$PollWidgetDataToJson(PollWidgetData instance) => @@ -47,33 +51,34 @@ const _$WidgetTypeEnumMap = { PollWidgetExtraData _$PollWidgetExtraDataFromJson(Map json) => PollWidgetExtraData( question: json['question'] as String? ?? '', - options: (json['options'] as List?) + options: + (json['options'] as List?) ?.map((e) => e as String) .toList() ?? [], ); Map _$PollWidgetExtraDataToJson( - PollWidgetExtraData instance) => - { - 'question': instance.question, - 'options': instance.options, - }; + PollWidgetExtraData instance, +) => { + 'question': instance.question, + 'options': instance.options, +}; PollNewOptionEventSubmessage _$PollNewOptionEventSubmessageFromJson( - Map json) => - PollNewOptionEventSubmessage( - option: json['option'] as String, - idx: (json['idx'] as num).toInt(), - ); + Map json, +) => PollNewOptionEventSubmessage( + option: json['option'] as String, + idx: (json['idx'] as num).toInt(), +); Map _$PollNewOptionEventSubmessageToJson( - PollNewOptionEventSubmessage instance) => - { - 'type': _$PollEventSubmessageTypeEnumMap[instance.type]!, - 'option': instance.option, - 'idx': instance.idx, - }; + PollNewOptionEventSubmessage instance, +) => { + 'type': _$PollEventSubmessageTypeEnumMap[instance.type]!, + 'option': instance.option, + 'idx': instance.idx, +}; const _$PollEventSubmessageTypeEnumMap = { PollEventSubmessageType.newOption: 'new_option', @@ -83,33 +88,34 @@ const _$PollEventSubmessageTypeEnumMap = { }; PollQuestionEventSubmessage _$PollQuestionEventSubmessageFromJson( - Map json) => - PollQuestionEventSubmessage( - question: json['question'] as String, - ); + Map json, +) => PollQuestionEventSubmessage(question: json['question'] as String); Map _$PollQuestionEventSubmessageToJson( - PollQuestionEventSubmessage instance) => - { - 'type': _$PollEventSubmessageTypeEnumMap[instance.type]!, - 'question': instance.question, - }; + PollQuestionEventSubmessage instance, +) => { + 'type': _$PollEventSubmessageTypeEnumMap[instance.type]!, + 'question': instance.question, +}; PollVoteEventSubmessage _$PollVoteEventSubmessageFromJson( - Map json) => - PollVoteEventSubmessage( - key: json['key'] as String, - op: $enumDecode(_$PollVoteOpEnumMap, json['vote'], - unknownValue: PollVoteOp.unknown), - ); + Map json, +) => PollVoteEventSubmessage( + key: json['key'] as String, + op: $enumDecode( + _$PollVoteOpEnumMap, + json['vote'], + unknownValue: PollVoteOp.unknown, + ), +); Map _$PollVoteEventSubmessageToJson( - PollVoteEventSubmessage instance) => - { - 'type': _$PollEventSubmessageTypeEnumMap[instance.type]!, - 'key': instance.key, - 'vote': instance.op, - }; + PollVoteEventSubmessage instance, +) => { + 'type': _$PollEventSubmessageTypeEnumMap[instance.type]!, + 'key': instance.key, + 'vote': instance.op, +}; const _$PollVoteOpEnumMap = { PollVoteOp.add: 1, diff --git a/lib/api/model/web_auth.dart b/lib/api/model/web_auth.dart index 490c4b79db..9de8992988 100644 --- a/lib/api/model/web_auth.dart +++ b/lib/api/model/web_auth.dart @@ -7,7 +7,7 @@ import 'package:flutter/foundation.dart'; class WebAuthPayload { final Uri realm; final String email; - final int? userId; // TODO(server-5) new in FL 108 + final int userId; final String otpEncryptedApiKey; WebAuthPayload._({ @@ -25,7 +25,7 @@ class WebAuthPayload { queryParameters: { 'realm': String realmStr, 'email': String email, - // 'user_id' handled below + 'user_id': String userIdStr, 'otp_encrypted_api_key': String otpEncryptedApiKey, }, ) @@ -33,13 +33,8 @@ class WebAuthPayload { final Uri? realm = Uri.tryParse(realmStr); if (realm == null) throw const FormatException(); - // TODO(server-5) require in queryParameters (new in FL 108) - final userIdStr = url.queryParameters['user_id']; - int? userId; - if (userIdStr != null) { - userId = int.tryParse(userIdStr, radix: 10); - if (userId == null) throw const FormatException(); - } + final int? userId = int.tryParse(userIdStr, radix: 10); + if (userId == null) throw const FormatException(); if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(otpEncryptedApiKey)) { throw const FormatException(); diff --git a/lib/api/notifications.dart b/lib/api/notifications.dart index 6d028aa267..9c5df3c749 100644 --- a/lib/api/notifications.dart +++ b/lib/api/notifications.dart @@ -177,13 +177,9 @@ sealed class FcmMessageRecipient { @JsonSerializable(fieldRename: FieldRename.snake, createToJson: false) @_IntConverter() class FcmMessageChannelRecipient extends FcmMessageRecipient { - // Sending the stream ID in notifications is new in Zulip Server 5. - // But handling the lack of it would add complication, and we don't strictly - // need to -- we intend (#268) to cut pre-server-5 support before beta release. - // TODO(server-5): cut comment final int streamId; - // Current servers (as of 2023) always send the stream name. But + // Current servers (as of 2025) always send the stream name. But // future servers might not, once clients get the name from local data. // So might as well be ready. @JsonKey(name: 'stream') diff --git a/lib/api/notifications.g.dart b/lib/api/notifications.g.dart index b56412f1fd..835cca4150 100644 --- a/lib/api/notifications.g.dart +++ b/lib/api/notifications.g.dart @@ -13,16 +13,18 @@ MessageFcmMessage _$MessageFcmMessageFromJson(Map json) => server: json['server'] as String, realmId: const _IntConverter().fromJson(json['realm_id'] as String), realmUrl: Uri.parse( - FcmMessageWithIdentity._readRealmUrl(json, 'realm_url') as String), + FcmMessageWithIdentity._readRealmUrl(json, 'realm_url') as String, + ), userId: const _IntConverter().fromJson(json['user_id'] as String), senderId: const _IntConverter().fromJson(json['sender_id'] as String), senderAvatarUrl: Uri.parse(json['sender_avatar_url'] as String), senderFullName: json['sender_full_name'] as String, recipient: FcmMessageRecipient.fromJson( - MessageFcmMessage._readWhole(json, 'recipient') - as Map), - zulipMessageId: - const _IntConverter().fromJson(json['zulip_message_id'] as String), + MessageFcmMessage._readWhole(json, 'recipient') as Map, + ), + zulipMessageId: const _IntConverter().fromJson( + json['zulip_message_id'] as String, + ), content: json['content'] as String, time: const _IntConverter().fromJson(json['time'] as String), ); @@ -43,22 +45,24 @@ Map _$MessageFcmMessageToJson(MessageFcmMessage instance) => }; FcmMessageChannelRecipient _$FcmMessageChannelRecipientFromJson( - Map json) => - FcmMessageChannelRecipient( - streamId: const _IntConverter().fromJson(json['stream_id'] as String), - streamName: json['stream'] as String?, - topic: TopicName.fromJson(json['topic'] as String), - ); + Map json, +) => FcmMessageChannelRecipient( + streamId: const _IntConverter().fromJson(json['stream_id'] as String), + streamName: json['stream'] as String?, + topic: TopicName.fromJson(json['topic'] as String), +); RemoveFcmMessage _$RemoveFcmMessageFromJson(Map json) => RemoveFcmMessage( server: json['server'] as String, realmId: const _IntConverter().fromJson(json['realm_id'] as String), realmUrl: Uri.parse( - FcmMessageWithIdentity._readRealmUrl(json, 'realm_url') as String), + FcmMessageWithIdentity._readRealmUrl(json, 'realm_url') as String, + ), userId: const _IntConverter().fromJson(json['user_id'] as String), - zulipMessageIds: const _IntListConverter() - .fromJson(json['zulip_message_ids'] as String), + zulipMessageIds: const _IntListConverter().fromJson( + json['zulip_message_ids'] as String, + ), ); Map _$RemoveFcmMessageToJson(RemoveFcmMessage instance) => @@ -68,6 +72,7 @@ Map _$RemoveFcmMessageToJson(RemoveFcmMessage instance) => 'realm_url': instance.realmUrl.toString(), 'user_id': const _IntConverter().toJson(instance.userId), 'event': instance.type, - 'zulip_message_ids': - const _IntListConverter().toJson(instance.zulipMessageIds), + 'zulip_message_ids': const _IntListConverter().toJson( + instance.zulipMessageIds, + ), }; diff --git a/lib/api/route/channels.dart b/lib/api/route/channels.dart index bfa46f5ab8..c21a6c8752 100644 --- a/lib/api/route/channels.dart +++ b/lib/api/route/channels.dart @@ -4,11 +4,51 @@ import '../core.dart'; import '../model/model.dart'; part 'channels.g.dart'; +/// https://zulip.com/api/subscribe +/// +/// [subscriptions] is a list of channel names. +/// (This is one of the few remaining areas where the Zulip API hasn't migrated +/// to using IDs.) +Future subscribeToChannel(ApiConnection connection, { + // TODO(server-future): This should use a stream ID, not stream name. + // (Keep dartdoc up to date.) + // Server issue: https://github.com/zulip/zulip/issues/10744 + required List subscriptions, + List? principals, +}) { + return connection.post('subscribeToChannel', (_) {}, 'users/me/subscriptions', { + 'subscriptions': subscriptions.map((name) => {'name': name}).toList(), + if (principals != null) 'principals': principals, + }); +} + +/// https://zulip.com/api/unsubscribe +/// +/// [subscriptions] is a list of channel names. +/// (This is one of the few remaining areas where the Zulip API hasn't migrated +/// to using IDs.) +Future unsubscribeFromChannel(ApiConnection connection, { + // TODO(server-future): This should use a stream ID, not stream name. + // (Keep dartdoc up to date.) + // Server issue: https://github.com/zulip/zulip/issues/10744 + required List subscriptions, + List? principals, +}) { + return connection.delete('unsubscribeFromChannel', (_) {}, 'users/me/subscriptions', { + 'subscriptions': subscriptions, + if (principals != null) 'principals': principals, + }); +} + /// https://zulip.com/api/get-stream-topics Future getStreamTopics(ApiConnection connection, { required int streamId, + required bool allowEmptyTopicName, }) { - return connection.get('getStreamTopics', GetStreamTopicsResult.fromJson, 'users/me/$streamId/topics', {}); + assert(allowEmptyTopicName, '`allowEmptyTopicName` should only be true'); + return connection.get('getStreamTopics', GetStreamTopicsResult.fromJson, 'users/me/$streamId/topics', { + 'allow_empty_topic_name': allowEmptyTopicName, + }); } @JsonSerializable(fieldRename: FieldRename.snake) diff --git a/lib/api/route/channels.g.dart b/lib/api/route/channels.g.dart index 4a5f7009c3..c43f0f50f0 100644 --- a/lib/api/route/channels.g.dart +++ b/lib/api/route/channels.g.dart @@ -9,29 +9,24 @@ part of 'channels.dart'; // ************************************************************************** GetStreamTopicsResult _$GetStreamTopicsResultFromJson( - Map json) => - GetStreamTopicsResult( - topics: (json['topics'] as List) - .map((e) => GetStreamTopicsEntry.fromJson(e as Map)) - .toList(), - ); + Map json, +) => GetStreamTopicsResult( + topics: (json['topics'] as List) + .map((e) => GetStreamTopicsEntry.fromJson(e as Map)) + .toList(), +); Map _$GetStreamTopicsResultToJson( - GetStreamTopicsResult instance) => - { - 'topics': instance.topics, - }; + GetStreamTopicsResult instance, +) => {'topics': instance.topics}; GetStreamTopicsEntry _$GetStreamTopicsEntryFromJson( - Map json) => - GetStreamTopicsEntry( - maxId: (json['max_id'] as num).toInt(), - name: TopicName.fromJson(json['name'] as String), - ); + Map json, +) => GetStreamTopicsEntry( + maxId: (json['max_id'] as num).toInt(), + name: TopicName.fromJson(json['name'] as String), +); Map _$GetStreamTopicsEntryToJson( - GetStreamTopicsEntry instance) => - { - 'max_id': instance.maxId, - 'name': instance.name, - }; + GetStreamTopicsEntry instance, +) => {'max_id': instance.maxId, 'name': instance.name}; diff --git a/lib/api/route/events.dart b/lib/api/route/events.dart index bbd7be5a0a..589a9b8cbf 100644 --- a/lib/api/route/events.dart +++ b/lib/api/route/events.dart @@ -18,6 +18,8 @@ Future registerQueue(ApiConnection connection) { 'user_avatar_url_field_optional': false, // TODO(#254): turn on 'stream_typing_notifications': true, 'user_settings_object': true, + 'include_deactivated_groups': true, + 'empty_topic_name': true, }, }); } diff --git a/lib/api/route/events.g.dart b/lib/api/route/events.g.dart index e452021510..3c77877ae8 100644 --- a/lib/api/route/events.g.dart +++ b/lib/api/route/events.g.dart @@ -17,7 +17,4 @@ GetEventsResult _$GetEventsResultFromJson(Map json) => ); Map _$GetEventsResultToJson(GetEventsResult instance) => - { - 'events': instance.events, - 'queue_id': instance.queueId, - }; + {'events': instance.events, 'queue_id': instance.queueId}; diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index be6728c790..7460a02461 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -1,72 +1,28 @@ import 'package:json_annotation/json_annotation.dart'; import '../core.dart'; -import '../exception.dart'; import '../model/model.dart'; import '../model/narrow.dart'; part 'messages.g.dart'; -/// Convenience function to get a single message from any server. -/// -/// This encapsulates a server-feature check. -/// -/// Gives null if the server reports that the message doesn't exist. -// TODO(server-5) Simplify this away; just use getMessage. -Future getMessageCompat(ApiConnection connection, { - required int messageId, - bool? applyMarkdown, -}) async { - final useLegacyApi = connection.zulipFeatureLevel! < 120; - if (useLegacyApi) { - final response = await getMessages(connection, - narrow: [ApiNarrowMessageId(messageId)], - anchor: NumericAnchor(messageId), - numBefore: 0, - numAfter: 0, - applyMarkdown: applyMarkdown, - - // Hard-code this param to `true`, as the new single-message API - // effectively does: - // https://chat.zulip.org/#narrow/stream/378-api-design/topic/.60client_gravatar.60.20in.20.60messages.2F.7Bmessage_id.7D.60/near/1418337 - clientGravatar: true, - ); - return response.messages.firstOrNull; - } else { - try { - final response = await getMessage(connection, - messageId: messageId, - applyMarkdown: applyMarkdown, - ); - return response.message; - } on ZulipApiException catch (e) { - if (e.code == 'BAD_REQUEST') { - // Servers use this code when the message doesn't exist, according to - // the example in the doc. - return null; - } - rethrow; - } - } -} - /// https://zulip.com/api/get-message -/// -/// This binding only supports feature levels 120+. -// TODO(server-5) remove FL 120+ mention in doc, and the related `assert` Future getMessage(ApiConnection connection, { required int messageId, bool? applyMarkdown, + required bool allowEmptyTopicName, }) { - assert(connection.zulipFeatureLevel! >= 120); + assert(allowEmptyTopicName, '`allowEmptyTopicName` should only be true'); return connection.get('getMessage', GetMessageResult.fromJson, 'messages/$messageId', { if (applyMarkdown != null) 'apply_markdown': applyMarkdown, + 'allow_empty_topic_name': allowEmptyTopicName, }); } @JsonSerializable(fieldRename: FieldRename.snake) class GetMessageResult { // final String rawContent; // deprecated; ignore + @JsonKey(fromJson: Message.fromJson) final Message message; GetMessageResult({ @@ -88,16 +44,19 @@ Future getMessages(ApiConnection connection, { required int numAfter, bool? clientGravatar, bool? applyMarkdown, + required bool allowEmptyTopicName, // bool? useFirstUnreadAnchor // omitted because deprecated }) { + assert(allowEmptyTopicName, '`allowEmptyTopicName` should only be true'); return connection.get('getMessages', GetMessagesResult.fromJson, 'messages', { - 'narrow': resolveDmElements(narrow, connection.zulipFeatureLevel!), + 'narrow': resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!), 'anchor': RawParameter(anchor.toJson()), if (includeAnchor != null) 'include_anchor': includeAnchor, 'num_before': numBefore, 'num_after': numAfter, if (clientGravatar != null) 'client_gravatar': clientGravatar, if (applyMarkdown != null) 'apply_markdown': applyMarkdown, + 'allow_empty_topic_name': allowEmptyTopicName, }); } @@ -138,6 +97,7 @@ class GetMessagesResult { final bool foundOldest; final bool foundAnchor; final bool historyLimited; + @JsonKey(fromJson: _messagesFromJson) final List messages; GetMessagesResult({ @@ -149,6 +109,12 @@ class GetMessagesResult { required this.messages, }); + static List _messagesFromJson(Object json) { + return (json as List) + .map((e) => Message.fromJson(e as Map)) + .toList(); + } + factory GetMessagesResult.fromJson(Map json) => _$GetMessagesResultFromJson(json); @@ -161,15 +127,6 @@ const int kMaxTopicLengthCodePoints = 60; // https://zulip.com/api/send-message#parameter-content const int kMaxMessageLengthCodePoints = 10000; -/// The topic servers understand to mean "there is no topic". -/// -/// This should match -/// https://github.com/zulip/zulip/blob/6.0/zerver/actions/message_edit.py#L940 -/// or similar logic at the latest `main`. -// This is hardcoded in the server, and therefore untranslated; that's -// zulip/zulip#3639. -const String kNoTopicTopic = '(no topic)'; - /// https://zulip.com/api/send-message Future sendMessage( ApiConnection connection, { @@ -193,8 +150,8 @@ Future sendMessage( 'to': destination.userIds, }}), 'content': RawParameter(content), - if (queueId != null) 'queue_id': queueId, // TODO should this use RawParameter? - if (localId != null) 'local_id': localId, // TODO should this use RawParameter? + if (queueId != null) 'queue_id': RawParameter(queueId), + if (localId != null) 'local_id': RawParameter(localId), if (readBySender != null) 'read_by_sender': readBySender, }, overrideUserAgent: switch ((supportsReadBySender, readBySender)) { @@ -267,6 +224,7 @@ Future updateMessage( bool? sendNotificationToOldThread, bool? sendNotificationToNewThread, String? content, + String? prevContentSha256, int? streamId, }) { return connection.patch('updateMessage', UpdateMessageResult.fromJson, 'messages/$messageId', { @@ -275,6 +233,7 @@ Future updateMessage( if (sendNotificationToOldThread != null) 'send_notification_to_old_thread': sendNotificationToOldThread, if (sendNotificationToNewThread != null) 'send_notification_to_new_thread': sendNotificationToNewThread, if (content != null) 'content': RawParameter(content), + if (prevContentSha256 != null) 'prev_content_sha256': RawParameter(prevContentSha256), if (streamId != null) 'stream_id': streamId, }); } @@ -305,10 +264,11 @@ Future uploadFile( @JsonSerializable(fieldRename: FieldRename.snake) class UploadFileResult { - final String uri; + @JsonKey(name: 'uri') + final String url; UploadFileResult({ - required this.uri, + required this.url, }); factory UploadFileResult.fromJson(Map json) => @@ -382,9 +342,6 @@ class UpdateMessageFlagsResult { } /// https://zulip.com/api/update-message-flags-for-narrow -/// -/// This binding only supports feature levels 155+. -// TODO(server-6) remove FL 155+ mention in doc, and the related `assert` Future updateMessageFlagsForNarrow(ApiConnection connection, { required Anchor anchor, bool? includeAnchor, @@ -394,13 +351,12 @@ Future updateMessageFlagsForNarrow(ApiConnect required UpdateMessageFlagsOp op, required MessageFlag flag, }) { - assert(connection.zulipFeatureLevel! >= 155); return connection.post('updateMessageFlagsForNarrow', UpdateMessageFlagsForNarrowResult.fromJson, 'messages/flags/narrow', { 'anchor': RawParameter(anchor.toJson()), if (includeAnchor != null) 'include_anchor': includeAnchor, 'num_before': numBefore, 'num_after': numAfter, - 'narrow': resolveDmElements(narrow, connection.zulipFeatureLevel!), + 'narrow': resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!), 'op': RawParameter(op.toJson()), 'flag': RawParameter(flag.toJson()), }); @@ -430,62 +386,22 @@ class UpdateMessageFlagsForNarrowResult { Map toJson() => _$UpdateMessageFlagsForNarrowResultToJson(this); } -/// https://zulip.com/api/mark-all-as-read -/// -/// This binding is deprecated, in FL 155+ use -/// [updateMessageFlagsForNarrow] instead. -// TODO(server-6): Remove as deprecated by updateMessageFlagsForNarrow -// -// For FL < 153 this call was atomic on the server and would -// not mark any messages as read if it timed out. -// From FL 153 and onward the server started processing -// in batches so progress could still be made in the event -// of a timeout interruption. Thus, in FL 153 this call -// started returning `result: partially_completed` and -// `code: REQUEST_TIMEOUT` for timeouts. -// -// In FL 211 the `partially_completed` variant of -// `result` was removed, the string `code` field also -// removed, and a boolean `complete` field introduced. -// -// For full support of this endpoint we would need three -// variants of the return structure based on feature -// level (`{}`, `{code: string}`, and `{complete: bool}`) -// as well as handling of `partially_completed` variant -// of `result` in `lib/api/core.dart`. For simplicity we -// ignore these return values. -// -// We don't use this method for FL 155+ (it is replaced -// by `updateMessageFlagsForNarrow`) so there are only -// two versions (FL 153 and FL 154) affected. -Future markAllAsRead(ApiConnection connection) { - return connection.post('markAllAsRead', (_) {}, 'mark_all_as_read', {}); -} - -/// https://zulip.com/api/mark-stream-as-read -/// -/// This binding is deprecated, in FL 155+ use -/// [updateMessageFlagsForNarrow] instead. -// TODO(server-6): Remove as deprecated by updateMessageFlagsForNarrow -Future markStreamAsRead(ApiConnection connection, { - required int streamId, +/// https://zulip.com/api/get-read-receipts +Future getReadReceipts(ApiConnection connection, { + required int messageId, }) { - return connection.post('markStreamAsRead', (_) {}, 'mark_stream_as_read', { - 'stream_id': streamId, - }); + return connection.get('getReadReceipts', GetReadReceiptsResult.fromJson, + 'messages/$messageId/read_receipts', null); } -/// https://zulip.com/api/mark-topic-as-read -/// -/// This binding is deprecated, in FL 155+ use -/// [updateMessageFlagsForNarrow] instead. -// TODO(server-6): Remove as deprecated by updateMessageFlagsForNarrow -Future markTopicAsRead(ApiConnection connection, { - required int streamId, - required TopicName topicName, -}) { - return connection.post('markTopicAsRead', (_) {}, 'mark_topic_as_read', { - 'stream_id': streamId, - 'topic_name': RawParameter(topicName.apiName), - }); +@JsonSerializable(fieldRename: FieldRename.snake) +class GetReadReceiptsResult { + const GetReadReceiptsResult({required this.userIds}); + + final List userIds; + + factory GetReadReceiptsResult.fromJson(Map json) => + _$GetReadReceiptsResultFromJson(json); + + Map toJson() => _$GetReadReceiptsResultToJson(this); } diff --git a/lib/api/route/messages.g.dart b/lib/api/route/messages.g.dart index 3a449d73ac..4169634701 100644 --- a/lib/api/route/messages.g.dart +++ b/lib/api/route/messages.g.dart @@ -14,9 +14,7 @@ GetMessageResult _$GetMessageResultFromJson(Map json) => ); Map _$GetMessageResultToJson(GetMessageResult instance) => - { - 'message': instance.message, - }; + {'message': instance.message}; GetMessagesResult _$GetMessagesResultFromJson(Map json) => GetMessagesResult( @@ -25,9 +23,7 @@ GetMessagesResult _$GetMessagesResultFromJson(Map json) => foundOldest: json['found_oldest'] as bool, foundAnchor: json['found_anchor'] as bool, historyLimited: json['history_limited'] as bool, - messages: (json['messages'] as List) - .map((e) => Message.fromJson(e as Map)) - .toList(), + messages: GetMessagesResult._messagesFromJson(json['messages'] as Object), ); Map _$GetMessagesResultToJson(GetMessagesResult instance) => @@ -41,67 +37,69 @@ Map _$GetMessagesResultToJson(GetMessagesResult instance) => }; SendMessageResult _$SendMessageResultFromJson(Map json) => - SendMessageResult( - id: (json['id'] as num).toInt(), - ); + SendMessageResult(id: (json['id'] as num).toInt()); Map _$SendMessageResultToJson(SendMessageResult instance) => - { - 'id': instance.id, - }; + {'id': instance.id}; UpdateMessageResult _$UpdateMessageResultFromJson(Map json) => UpdateMessageResult(); Map _$UpdateMessageResultToJson( - UpdateMessageResult instance) => - {}; + UpdateMessageResult instance, +) => {}; UploadFileResult _$UploadFileResultFromJson(Map json) => - UploadFileResult( - uri: json['uri'] as String, - ); + UploadFileResult(url: json['uri'] as String); Map _$UploadFileResultToJson(UploadFileResult instance) => - { - 'uri': instance.uri, - }; + {'uri': instance.url}; UpdateMessageFlagsResult _$UpdateMessageFlagsResultFromJson( - Map json) => - UpdateMessageFlagsResult( - messages: (json['messages'] as List) - .map((e) => (e as num).toInt()) - .toList(), - ); + Map json, +) => UpdateMessageFlagsResult( + messages: (json['messages'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); Map _$UpdateMessageFlagsResultToJson( - UpdateMessageFlagsResult instance) => - { - 'messages': instance.messages, - }; + UpdateMessageFlagsResult instance, +) => {'messages': instance.messages}; UpdateMessageFlagsForNarrowResult _$UpdateMessageFlagsForNarrowResultFromJson( - Map json) => - UpdateMessageFlagsForNarrowResult( - processedCount: (json['processed_count'] as num).toInt(), - updatedCount: (json['updated_count'] as num).toInt(), - firstProcessedId: (json['first_processed_id'] as num?)?.toInt(), - lastProcessedId: (json['last_processed_id'] as num?)?.toInt(), - foundOldest: json['found_oldest'] as bool, - foundNewest: json['found_newest'] as bool, - ); + Map json, +) => UpdateMessageFlagsForNarrowResult( + processedCount: (json['processed_count'] as num).toInt(), + updatedCount: (json['updated_count'] as num).toInt(), + firstProcessedId: (json['first_processed_id'] as num?)?.toInt(), + lastProcessedId: (json['last_processed_id'] as num?)?.toInt(), + foundOldest: json['found_oldest'] as bool, + foundNewest: json['found_newest'] as bool, +); Map _$UpdateMessageFlagsForNarrowResultToJson( - UpdateMessageFlagsForNarrowResult instance) => - { - 'processed_count': instance.processedCount, - 'updated_count': instance.updatedCount, - 'first_processed_id': instance.firstProcessedId, - 'last_processed_id': instance.lastProcessedId, - 'found_oldest': instance.foundOldest, - 'found_newest': instance.foundNewest, - }; + UpdateMessageFlagsForNarrowResult instance, +) => { + 'processed_count': instance.processedCount, + 'updated_count': instance.updatedCount, + 'first_processed_id': instance.firstProcessedId, + 'last_processed_id': instance.lastProcessedId, + 'found_oldest': instance.foundOldest, + 'found_newest': instance.foundNewest, +}; + +GetReadReceiptsResult _$GetReadReceiptsResultFromJson( + Map json, +) => GetReadReceiptsResult( + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); + +Map _$GetReadReceiptsResultToJson( + GetReadReceiptsResult instance, +) => {'user_ids': instance.userIds}; const _$AnchorCodeEnumMap = { AnchorCode.newest: 'newest', diff --git a/lib/api/route/realm.dart b/lib/api/route/realm.dart index a43c2e9921..282790bef2 100644 --- a/lib/api/route/realm.dart +++ b/lib/api/route/realm.dart @@ -36,7 +36,7 @@ class GetServerSettingsResult { final int zulipFeatureLevel; final String zulipVersion; - final String? zulipMergeBase; // TODO(server-5) + final String zulipMergeBase; final bool pushNotificationsEnabled; final bool isIncompatible; @@ -50,7 +50,7 @@ class GetServerSettingsResult { final String realmName; final String realmIcon; final String realmDescription; - final bool? realmWebPublicAccessEnabled; // TODO(server-5) + final bool realmWebPublicAccessEnabled; GetServerSettingsResult({ required this.authenticationMethods, diff --git a/lib/api/route/realm.g.dart b/lib/api/route/realm.g.dart index e34fbc9d0c..c71df7d433 100644 --- a/lib/api/route/realm.g.dart +++ b/lib/api/route/realm.g.dart @@ -9,69 +9,71 @@ part of 'realm.dart'; // ************************************************************************** GetServerSettingsResult _$GetServerSettingsResultFromJson( - Map json) => - GetServerSettingsResult( - authenticationMethods: - Map.from(json['authentication_methods'] as Map), - externalAuthenticationMethods: (json['external_authentication_methods'] - as List) - .map((e) => - ExternalAuthenticationMethod.fromJson(e as Map)) + Map json, +) => GetServerSettingsResult( + authenticationMethods: Map.from( + json['authentication_methods'] as Map, + ), + externalAuthenticationMethods: + (json['external_authentication_methods'] as List) + .map( + (e) => ExternalAuthenticationMethod.fromJson( + e as Map, + ), + ) .toList(), - zulipFeatureLevel: (json['zulip_feature_level'] as num).toInt(), - zulipVersion: json['zulip_version'] as String, - zulipMergeBase: json['zulip_merge_base'] as String?, - pushNotificationsEnabled: json['push_notifications_enabled'] as bool, - isIncompatible: json['is_incompatible'] as bool, - emailAuthEnabled: json['email_auth_enabled'] as bool, - requireEmailFormatUsernames: - json['require_email_format_usernames'] as bool, - realmUrl: Uri.parse(json['realm_uri'] as String), - realmName: json['realm_name'] as String, - realmIcon: json['realm_icon'] as String, - realmDescription: json['realm_description'] as String, - realmWebPublicAccessEnabled: - json['realm_web_public_access_enabled'] as bool?, - ); + zulipFeatureLevel: (json['zulip_feature_level'] as num).toInt(), + zulipVersion: json['zulip_version'] as String, + zulipMergeBase: json['zulip_merge_base'] as String, + pushNotificationsEnabled: json['push_notifications_enabled'] as bool, + isIncompatible: json['is_incompatible'] as bool, + emailAuthEnabled: json['email_auth_enabled'] as bool, + requireEmailFormatUsernames: json['require_email_format_usernames'] as bool, + realmUrl: Uri.parse(json['realm_uri'] as String), + realmName: json['realm_name'] as String, + realmIcon: json['realm_icon'] as String, + realmDescription: json['realm_description'] as String, + realmWebPublicAccessEnabled: json['realm_web_public_access_enabled'] as bool, +); Map _$GetServerSettingsResultToJson( - GetServerSettingsResult instance) => - { - 'authentication_methods': instance.authenticationMethods, - 'external_authentication_methods': instance.externalAuthenticationMethods, - 'zulip_feature_level': instance.zulipFeatureLevel, - 'zulip_version': instance.zulipVersion, - 'zulip_merge_base': instance.zulipMergeBase, - 'push_notifications_enabled': instance.pushNotificationsEnabled, - 'is_incompatible': instance.isIncompatible, - 'email_auth_enabled': instance.emailAuthEnabled, - 'require_email_format_usernames': instance.requireEmailFormatUsernames, - 'realm_uri': instance.realmUrl.toString(), - 'realm_name': instance.realmName, - 'realm_icon': instance.realmIcon, - 'realm_description': instance.realmDescription, - 'realm_web_public_access_enabled': instance.realmWebPublicAccessEnabled, - }; + GetServerSettingsResult instance, +) => { + 'authentication_methods': instance.authenticationMethods, + 'external_authentication_methods': instance.externalAuthenticationMethods, + 'zulip_feature_level': instance.zulipFeatureLevel, + 'zulip_version': instance.zulipVersion, + 'zulip_merge_base': instance.zulipMergeBase, + 'push_notifications_enabled': instance.pushNotificationsEnabled, + 'is_incompatible': instance.isIncompatible, + 'email_auth_enabled': instance.emailAuthEnabled, + 'require_email_format_usernames': instance.requireEmailFormatUsernames, + 'realm_uri': instance.realmUrl.toString(), + 'realm_name': instance.realmName, + 'realm_icon': instance.realmIcon, + 'realm_description': instance.realmDescription, + 'realm_web_public_access_enabled': instance.realmWebPublicAccessEnabled, +}; ExternalAuthenticationMethod _$ExternalAuthenticationMethodFromJson( - Map json) => - ExternalAuthenticationMethod( - name: json['name'] as String, - displayName: json['display_name'] as String, - displayIcon: json['display_icon'] as String?, - loginUrl: json['login_url'] as String, - signupUrl: json['signup_url'] as String, - ); + Map json, +) => ExternalAuthenticationMethod( + name: json['name'] as String, + displayName: json['display_name'] as String, + displayIcon: json['display_icon'] as String?, + loginUrl: json['login_url'] as String, + signupUrl: json['signup_url'] as String, +); Map _$ExternalAuthenticationMethodToJson( - ExternalAuthenticationMethod instance) => - { - 'name': instance.name, - 'display_name': instance.displayName, - 'display_icon': instance.displayIcon, - 'login_url': instance.loginUrl, - 'signup_url': instance.signupUrl, - }; + ExternalAuthenticationMethod instance, +) => { + 'name': instance.name, + 'display_name': instance.displayName, + 'display_icon': instance.displayIcon, + 'login_url': instance.loginUrl, + 'signup_url': instance.signupUrl, +}; ServerEmojiData _$ServerEmojiDataFromJson(Map json) => ServerEmojiData( @@ -82,6 +84,4 @@ ServerEmojiData _$ServerEmojiDataFromJson(Map json) => ); Map _$ServerEmojiDataToJson(ServerEmojiData instance) => - { - 'code_to_names': instance.codeToNames, - }; + {'code_to_names': instance.codeToNames}; diff --git a/lib/api/route/saved_snippets.dart b/lib/api/route/saved_snippets.dart new file mode 100644 index 0000000000..047a35051e --- /dev/null +++ b/lib/api/route/saved_snippets.dart @@ -0,0 +1,31 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../core.dart'; + +part 'saved_snippets.g.dart'; + +/// https://zulip.com/api/create-saved-snippet +Future createSavedSnippet(ApiConnection connection, { + required String title, + required String content, +}) { + assert(connection.zulipFeatureLevel! >= 297); // TODO(server-10) + return connection.post('createSavedSnippet', CreateSavedSnippetResult.fromJson, 'saved_snippets', { + 'title': RawParameter(title), + 'content': RawParameter(content), + }); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class CreateSavedSnippetResult { + final int savedSnippetId; + + CreateSavedSnippetResult({ + required this.savedSnippetId, + }); + + factory CreateSavedSnippetResult.fromJson(Map json) => + _$CreateSavedSnippetResultFromJson(json); + + Map toJson() => _$CreateSavedSnippetResultToJson(this); +} diff --git a/lib/api/route/saved_snippets.g.dart b/lib/api/route/saved_snippets.g.dart new file mode 100644 index 0000000000..aeb3c2a6c5 --- /dev/null +++ b/lib/api/route/saved_snippets.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'saved_snippets.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CreateSavedSnippetResult _$CreateSavedSnippetResultFromJson( + Map json, +) => CreateSavedSnippetResult( + savedSnippetId: (json['saved_snippet_id'] as num).toInt(), +); + +Map _$CreateSavedSnippetResultToJson( + CreateSavedSnippetResult instance, +) => {'saved_snippet_id': instance.savedSnippetId}; diff --git a/lib/api/route/settings.dart b/lib/api/route/settings.dart new file mode 100644 index 0000000000..4e98140d76 --- /dev/null +++ b/lib/api/route/settings.dart @@ -0,0 +1,30 @@ +import '../core.dart'; +import '../model/model.dart'; + +/// https://zulip.com/api/update-settings +Future updateSettings(ApiConnection connection, { + required Map newSettings, +}) { + final params = {}; + for (final entry in newSettings.entries) { + final name = entry.key; + final valueRaw = entry.value; + final Object? value; + switch (name) { + case UserSettingName.twentyFourHourTime: + final mode = (valueRaw as TwentyFourHourTimeMode); + // TODO(server-future) allow localeDefault for servers that support it + assert(mode != TwentyFourHourTimeMode.localeDefault); + value = mode.toJson(); + case UserSettingName.displayEmojiReactionUsers: + value = valueRaw as bool; + case UserSettingName.emojiset: + value = RawParameter((valueRaw as Emojiset).toJson()); + case UserSettingName.presenceEnabled: + value = valueRaw as bool; + } + params[name.toJson()] = value; + } + + return connection.patch('updateSettings', (_) {}, 'settings', params); +} diff --git a/lib/api/route/users.dart b/lib/api/route/users.dart index 012f14e6b9..4e47d97576 100644 --- a/lib/api/route/users.dart +++ b/lib/api/route/users.dart @@ -1,6 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; +import '../../basic.dart'; import '../core.dart'; +import '../model/model.dart'; part 'users.g.dart'; @@ -32,3 +34,64 @@ class GetOwnUserResult { Map toJson() => _$GetOwnUserResultToJson(this); } + +/// https://zulip.com/api/update-status +Future updateStatus(ApiConnection connection, { + required UserStatusChange change, +}) { + return connection.post('updateStatus', (_) {}, 'users/me/status', { + if (change.text case OptionSome(:var value)) + 'status_text': RawParameter(value ?? ''), + if (change.emoji case OptionSome(:var value)) ...{ + 'emoji_name': RawParameter(value?.emojiName ?? ''), + 'emoji_code': RawParameter(value?.emojiCode ?? ''), + 'reaction_type': RawParameter(value?.reactionType.toJson() ?? ''), + } + }); +} + +/// https://zulip.com/api/update-presence +/// +/// Passes true for `slim_presence` to avoid getting an ancient data format +/// in the response. +// TODO(#1611) Passing `slim_presence` is the old, deprecated way to avoid +// getting an ancient data format. Pass `last_update_id` to new servers to get +// that effect (make lastUpdateId required?) and update the dartdoc. +// (Passing `slim_presence`, for now, shouldn't break things, but we'd like to +// stop; see discussion: +// https://chat.zulip.org/#narrow/channel/378-api-design/topic/presence.20rewrite/near/2201035 ) +Future updatePresence(ApiConnection connection, { + int? lastUpdateId, + int? historyLimitDays, + bool? newUserInput, + bool? pingOnly, + required PresenceStatus status, +}) { + return connection.post('updatePresence', UpdatePresenceResult.fromJson, 'users/me/presence', { + if (lastUpdateId != null) 'last_update_id': lastUpdateId, + if (historyLimitDays != null) 'history_limit_days': historyLimitDays, + if (newUserInput != null) 'new_user_input': newUserInput, + if (pingOnly != null) 'ping_only': pingOnly, + 'status': RawParameter(status.toJson()), + 'slim_presence': true, + }); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class UpdatePresenceResult { + final int? presenceLastUpdateId; // TODO(server-9.0) new in FL 263 + final double? serverTimestamp; // 1656958539.6287155 in the example response + final Map? presences; + // final bool zephyrMirrorActive; // deprecated, ignore + + UpdatePresenceResult({ + required this.presenceLastUpdateId, + required this.serverTimestamp, + required this.presences, + }); + + factory UpdatePresenceResult.fromJson(Map json) => + _$UpdatePresenceResultFromJson(json); + + Map toJson() => _$UpdatePresenceResultToJson(this); +} diff --git a/lib/api/route/users.g.dart b/lib/api/route/users.g.dart index a8b41f65dd..dab0e32189 100644 --- a/lib/api/route/users.g.dart +++ b/lib/api/route/users.g.dart @@ -9,11 +9,28 @@ part of 'users.dart'; // ************************************************************************** GetOwnUserResult _$GetOwnUserResultFromJson(Map json) => - GetOwnUserResult( - userId: (json['user_id'] as num).toInt(), - ); + GetOwnUserResult(userId: (json['user_id'] as num).toInt()); Map _$GetOwnUserResultToJson(GetOwnUserResult instance) => - { - 'user_id': instance.userId, - }; + {'user_id': instance.userId}; + +UpdatePresenceResult _$UpdatePresenceResultFromJson( + Map json, +) => UpdatePresenceResult( + presenceLastUpdateId: (json['presence_last_update_id'] as num?)?.toInt(), + serverTimestamp: (json['server_timestamp'] as num?)?.toDouble(), + presences: (json['presences'] as Map?)?.map( + (k, e) => MapEntry( + int.parse(k), + PerUserPresence.fromJson(e as Map), + ), + ), +); + +Map _$UpdatePresenceResultToJson( + UpdatePresenceResult instance, +) => { + 'presence_last_update_id': instance.presenceLastUpdateId, + 'server_timestamp': instance.serverTimestamp, + 'presences': instance.presences?.map((k, e) => MapEntry(k.toString(), e)), +}; diff --git a/lib/basic.dart b/lib/basic.dart new file mode 100644 index 0000000000..28d27df999 --- /dev/null +++ b/lib/basic.dart @@ -0,0 +1,68 @@ + +/// Either a value, or the absence of a value. +/// +/// An `Option` is either an `OptionSome` representing a `T` value, +/// or an `OptionNone` representing the absence of a value. +/// +/// When `T` is non-nullable, this is the same information that is +/// normally represented as a `T?`. +/// This class is useful when T is nullable (or might be nullable). +/// In that case `null` is already a T value, +/// and so can't also be used to represent the absence of a T value, +/// but `OptionNone()` is a different value from `OptionSome(null)`. +/// +/// This interface is small because members are added lazily when needed. +/// If adding another member, consider borrowing the naming from Rust: +/// https://doc.rust-lang.org/std/option/enum.Option.html +sealed class Option { + const Option(); + + /// The value contained in this option, if any; else the given value. + T or(T optb); + + /// The value contained in this option, if any; + /// else the value returned by [fn]. + /// + /// [fn] is called only if its return value is needed. + T orElse(T Function() fn); +} + +class OptionNone extends Option { + const OptionNone(); + + @override + T or(T optb) => optb; + + @override + T orElse(T Function() fn) => fn(); + + @override + bool operator ==(Object other) => other is OptionNone; + + @override + int get hashCode => 'OptionNone'.hashCode; + + @override + String toString() => 'OptionNone'; +} + +class OptionSome extends Option { + const OptionSome(this.value); + + final T value; + + @override + T or(T optb) => value; + + @override + T orElse(T Function() fn) => value; + + @override + bool operator ==(Object other) => other is OptionSome && value == other.value; + + @override + int get hashCode => Object.hash('OptionSome', value); + + @override + String toString() => 'OptionSome($value)'; +} diff --git a/lib/example/sticky_header.dart b/lib/example/sticky_header.dart new file mode 100644 index 0000000000..5c96cb6a00 --- /dev/null +++ b/lib/example/sticky_header.dart @@ -0,0 +1,466 @@ +/// 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, + required this.headerPlacement, + required this.topSliverGrowsUpward, + }); + + final String title; + // final bool reverse; + final HeaderPlacement headerPlacement; + final bool topSliverGrowsUpward; + + @override + Widget build(BuildContext context) { + const numSections = 4; + const numBottomSections = 2; + const numTopSections = numSections - numBottomSections; + const numPerSection = 10; + + final headerAtBottom = switch (headerPlacement) { + HeaderPlacement.scrollingStart => false, + HeaderPlacement.scrollingEnd => true, + }; + + final centerKey = topSliverGrowsUpward ? + const ValueKey('bottom') : const ValueKey('top'); + + return Scaffold( + appBar: AppBar(title: Text(title)), + body: CustomScrollView( + semanticChildCount: numSections, + center: centerKey, + paintOrder: headerAtBottom ? + SliverPaintOrder.lastIsTop : SliverPaintOrder.firstIsTop, + slivers: [ + SliverStickyHeaderList( + key: const ValueKey('top'), + headerPlacement: headerPlacement, + delegate: SliverChildBuilderDelegate( + childCount: numSections - numBottomSections, + (context, i) { + final ii = numBottomSections + + (topSliverGrowsUpward ? i : numTopSections - 1 - i); + return StickyHeaderItem( + allowOverflow: true, + header: WideHeader(i: ii), + child: Column( + verticalDirection: headerAtBottom + ? VerticalDirection.up : VerticalDirection.down, + children: List.generate(numPerSection + 1, (j) { + if (j == 0) return WideHeader(i: ii); + return WideItem(i: ii, j: j-1); + }))); + })), + SliverStickyHeaderList( + key: const ValueKey('bottom'), + headerPlacement: headerPlacement, + delegate: SliverChildBuilderDelegate( + childCount: numBottomSections, + (context, i) { + final ii = numBottomSections - 1 - i; + return StickyHeaderItem( + allowOverflow: true, + header: WideHeader(i: ii), + child: Column( + verticalDirection: headerAtBottom + ? VerticalDirection.up : VerticalDirection.down, + 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( + onTap: () {}, // nop, but non-null so the ink splash appears + 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( + onTap: () {}, // nop, but non-null so the ink splash appears + 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, headers at top', + page: ExampleVerticalDouble( + title: 'Double slivers, headers at top', + topSliverGrowsUpward: false, + headerPlacement: HeaderPlacement.scrollingStart)), + _buildButton(context, + title: 'Split slivers, headers at top', + page: ExampleVerticalDouble( + title: 'Split slivers, headers at top', + topSliverGrowsUpward: true, + headerPlacement: HeaderPlacement.scrollingStart)), + _buildButton(context, + title: 'Split slivers, headers at bottom', + page: ExampleVerticalDouble( + title: 'Split slivers, headers at bottom', + topSliverGrowsUpward: true, + headerPlacement: HeaderPlacement.scrollingEnd)), + ]; + 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 b6fbb70769..238d336f39 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -6,12 +6,18 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:intl/intl.dart' as intl; import 'zulip_localizations_ar.dart'; +import 'zulip_localizations_de.dart'; import 'zulip_localizations_en.dart'; +import 'zulip_localizations_fr.dart'; +import 'zulip_localizations_it.dart'; import 'zulip_localizations_ja.dart'; import 'zulip_localizations_nb.dart'; import 'zulip_localizations_pl.dart'; import 'zulip_localizations_ru.dart'; import 'zulip_localizations_sk.dart'; +import 'zulip_localizations_sl.dart'; +import 'zulip_localizations_uk.dart'; +import 'zulip_localizations_zh.dart'; // ignore_for_file: type=lint @@ -67,7 +73,8 @@ import 'zulip_localizations_sk.dart'; /// be consistent with the languages listed in the ZulipLocalizations.supportedLocales /// property. abstract class ZulipLocalizations { - ZulipLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + ZulipLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; @@ -75,7 +82,8 @@ abstract class ZulipLocalizations { return Localizations.of(context, ZulipLocalizations)!; } - static const LocalizationsDelegate delegate = _ZulipLocalizationsDelegate(); + static const LocalizationsDelegate delegate = + _ZulipLocalizationsDelegate(); /// A list of this localizations delegate along with the default localizations /// delegates. @@ -87,22 +95,40 @@ abstract class ZulipLocalizations { /// Additional delegates can be added by appending to this list in /// MaterialApp. This list does not have to be used at all if a custom list /// of delegates is preferred or required. - static const List> localizationsDelegates = >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ Locale('en'), Locale('ar'), + Locale('de'), + Locale('en', 'GB'), + Locale('fr'), + Locale('it'), Locale('ja'), Locale('nb'), Locale('pl'), Locale('ru'), - Locale('sk') + Locale('sk'), + Locale('sl'), + Locale('uk'), + Locale('zh'), + Locale.fromSubtags( + languageCode: 'zh', + countryCode: 'CN', + scriptCode: 'Hans', + ), + Locale.fromSubtags( + languageCode: 'zh', + countryCode: 'TW', + scriptCode: 'Hant', + ), ]; /// Title for About Zulip page. @@ -129,12 +155,42 @@ abstract class ZulipLocalizations { /// **'Tap to view'** String get aboutPageTapToView; + /// Title for dialog shown on first upgrade from the legacy Zulip app. + /// + /// In en, this message translates to: + /// **'Welcome to the new Zulip app!'** + String get upgradeWelcomeDialogTitle; + + /// Message text for dialog shown on first upgrade from the legacy Zulip app. + /// + /// In en, this message translates to: + /// **'You’ll find a familiar experience in a faster, sleeker package.'** + String get upgradeWelcomeDialogMessage; + + /// Text of link in dialog shown on first upgrade from the legacy Zulip app. + /// + /// In en, this message translates to: + /// **'Check out the announcement blog post!'** + String get upgradeWelcomeDialogLinkText; + + /// Label for button dismissing dialog shown on first upgrade from the legacy Zulip app. + /// + /// In en, this message translates to: + /// **'Let\'s go'** + String get upgradeWelcomeDialogDismiss; + /// Title for the page to choose between Zulip accounts. /// /// In en, this message translates to: /// **'Choose account'** String get chooseAccountPageTitle; + /// Title for the settings page. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settingsPageTitle; + /// Label for main-menu button leading to the choose-account page. /// /// In en, this message translates to: @@ -219,6 +275,72 @@ abstract class ZulipLocalizations { /// **'To upload files, please grant Zulip additional permissions in Settings.'** String get permissionsDeniedReadExternalStorage; + /// Label in the channel action sheet for subscribing to the channel. + /// + /// In en, this message translates to: + /// **'Subscribe'** + String get actionSheetOptionSubscribe; + + /// Error title when subscribing to a channel failed. + /// + /// In en, this message translates to: + /// **'Failed to subscribe'** + String get subscribeFailedTitle; + + /// Label for marking a channel as read. + /// + /// In en, this message translates to: + /// **'Mark channel as read'** + String get actionSheetOptionMarkChannelAsRead; + + /// Label for copy channel link button on action sheet. + /// + /// In en, this message translates to: + /// **'Copy link to channel'** + String get actionSheetOptionCopyChannelLink; + + /// Label for navigating to a channel's topic-list page. + /// + /// In en, this message translates to: + /// **'List of topics'** + String get actionSheetOptionListOfTopics; + + /// Label for navigating to a channel's channel-feed page. + /// + /// In en, this message translates to: + /// **'Channel feed'** + String get actionSheetOptionChannelFeed; + + /// Label in the channel action sheet for unsubscribing from the channel. + /// + /// In en, this message translates to: + /// **'Unsubscribe'** + String get actionSheetOptionUnsubscribe; + + /// Title for a confirmation dialog for unsubscribing from a channel. + /// + /// In en, this message translates to: + /// **'Unsubscribe from {channelName}?'** + String unsubscribeConfirmationDialogTitle(String channelName); + + /// Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe. + /// + /// In en, this message translates to: + /// **'Once you leave this channel, you might not be able to rejoin.'** + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe; + + /// Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel. + /// + /// In en, this message translates to: + /// **'Unsubscribe'** + String get unsubscribeConfirmationDialogConfirmButton; + + /// Error title when unsubscribing from a channel failed. + /// + /// In en, this message translates to: + /// **'Failed to unsubscribe'** + String get unsubscribeFailedTitle; + /// Label for muting a topic on action sheet. /// /// In en, this message translates to: @@ -243,6 +365,90 @@ 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 the 'See who reacted' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'See who reacted'** + String get actionSheetOptionSeeWhoReacted; + + /// Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened). + /// + /// In en, this message translates to: + /// **'This message has no reactions.'** + String get seeWhoReactedSheetNoReactions; + + /// In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.) + /// + /// In en, this message translates to: + /// **'Emoji reactions ({num} total)'** + String seeWhoReactedSheetHeaderLabel(int num); + + /// In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.) + /// + /// In en, this message translates to: + /// **'{emojiName}: {num, plural, =1{1 vote} other{{num} votes}}'** + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num); + + /// In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.) + /// + /// In en, this message translates to: + /// **'Votes for {emojiName} ({num})'** + String seeWhoReactedSheetUserListLabel(String emojiName, int num); + + /// Label for the 'View read receipts' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'View read receipts'** + String get actionSheetOptionViewReadReceipts; + + /// Title for the "Read receipts" bottom sheet. + /// + /// In en, this message translates to: + /// **'Read receipts'** + String get actionSheetReadReceipts; + + /// Label in the "Read receipts" bottom sheet when one or more people have read the message. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{This message has been read by {count} person:} other{This message has been read by {count} people:}}'** + String actionSheetReadReceiptsReadCount(int count); + + /// Label in the "Read receipts" bottom sheet when no one has read the message. + /// + /// In en, this message translates to: + /// **'No one has read this message yet.'** + String get actionSheetReadReceiptsZeroReadCount; + + /// Label in the "Read receipts" bottom sheet when loading read receipts failed. + /// + /// In en, this message translates to: + /// **'Failed to load read receipts.'** + String get actionSheetReadReceiptsErrorReadCount; + /// Label for copy message text button on action sheet. /// /// In en, this message translates to: @@ -261,17 +467,23 @@ abstract class ZulipLocalizations { /// **'Mark as unread from here'** String get actionSheetOptionMarkAsUnread; + /// Label for hide muted message again button on action sheet. + /// + /// In en, this message translates to: + /// **'Hide muted message again'** + String get actionSheetOptionHideMutedMessage; + /// Label for share button on action sheet. /// /// In en, this message translates to: /// **'Share'** String get actionSheetOptionShare; - /// Label for Quote and reply button on action sheet. + /// Label for the 'Quote message' button in the message action sheet. /// /// In en, this message translates to: - /// **'Quote and reply'** - String get actionSheetOptionQuoteAndReply; + /// **'Quote message'** + String get actionSheetOptionQuoteMessage; /// Label for star button on action sheet. /// @@ -285,6 +497,24 @@ abstract class ZulipLocalizations { /// **'Unstar message'** String get actionSheetOptionUnstarMessage; + /// Label for the 'Edit message' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'Edit message'** + String get actionSheetOptionEditMessage; + + /// Option to mark a specific topic as read in the action sheet. + /// + /// In en, this message translates to: + /// **'Mark topic as read'** + String get actionSheetOptionMarkTopicAsRead; + + /// Label for copy topic link button in action sheet. + /// + /// In en, this message translates to: + /// **'Copy link to topic'** + String get actionSheetOptionCopyTopicLink; + /// Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials). /// /// In en, this message translates to: @@ -312,7 +542,7 @@ abstract class ZulipLocalizations { /// Error message when the source of a message could not be fetched. /// /// In en, this message translates to: - /// **'Could not fetch message source'** + /// **'Could not fetch message source.'** String get errorCouldNotFetchMessageSource; /// Error message when copying the text of a message to the user's system clipboard failed. @@ -327,11 +557,21 @@ 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: /// **'{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}'** - String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage); + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ); /// Error title when attached files are too large in size. /// @@ -357,6 +597,12 @@ abstract class ZulipLocalizations { /// **'Message not sent'** String get errorMessageNotSent; + /// Error message for compose box when a message edit could not be saved. + /// + /// In en, this message translates to: + /// **'Message not saved'** + String get errorMessageEditNotSaved; + /// Error message when the app could not connect to the server. /// /// In en, this message translates to: @@ -367,7 +613,7 @@ abstract class ZulipLocalizations { /// /// In en, this message translates to: /// **'Could not connect'** - String get errorLoginCouldNotConnectTitle; + String get errorCouldNotConnectTitle; /// Error message when loading a message that does not exist. /// @@ -409,7 +655,11 @@ abstract class ZulipLocalizations { /// /// In en, this message translates to: /// **'Error handling a Zulip event from {serverUrl}; will retry.\n\nError: {error}\n\nEvent: {event}'** - String errorHandlingEventDetails(String serverUrl, String error, String event); + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ); /// Error title when opening a link failed. /// @@ -465,6 +715,12 @@ abstract class ZulipLocalizations { /// **'Failed to unstar message'** String get errorUnstarMessageFailedTitle; + /// Error title when an exception prevented us from opening the compose box for editing a message. + /// + /// In en, this message translates to: + /// **'Could not edit message'** + String get errorCouldNotEditMessageTitle; + /// Success message after copy link action completed. /// /// In en, this message translates to: @@ -483,6 +739,18 @@ abstract class ZulipLocalizations { /// **'Message link copied'** String get successMessageLinkCopied; + /// Message when link of a topic was copied to the user's system clipboard. + /// + /// In en, this message translates to: + /// **'Topic link copied'** + String get successTopicLinkCopied; + + /// Message when link of a channel was copied to the user's system clipboard. + /// + /// In en, this message translates to: + /// **'Channel link copied'** + String get successChannelLinkCopied; + /// Label text for error banner when sending a message to one or multiple deactivated users. /// /// In en, this message translates to: @@ -495,6 +763,72 @@ abstract class ZulipLocalizations { /// **'You do not have permission to post in this channel.'** String get errorBannerCannotPostInChannelLabel; + /// Label text for the compose-box banner when you are editing a message. + /// + /// In en, this message translates to: + /// **'Edit message'** + String get composeBoxBannerLabelEditMessage; + + /// Label text for the 'Cancel' button in the compose-box banner when you are editing a message. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get composeBoxBannerButtonCancel; + + /// Label text for the 'Save' button in the compose-box banner when you are editing a message. + /// + /// In en, this message translates to: + /// **'Save'** + String get composeBoxBannerButtonSave; + + /// Error title when a message edit cannot be saved because there is another edit already in progress. + /// + /// In en, this message translates to: + /// **'Cannot edit message'** + String get editAlreadyInProgressTitle; + + /// Error message when a message edit cannot be saved because there is another edit already in progress. + /// + /// In en, this message translates to: + /// **'An edit is already in progress. Please wait for it to complete.'** + String get editAlreadyInProgressMessage; + + /// Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'SAVING EDIT…'** + String get savingMessageEditLabel; + + /// Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'EDIT NOT SAVED'** + String get savingMessageEditFailedLabel; + + /// Title for a confirmation dialog for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'Discard the message you’re writing?'** + String get discardDraftConfirmationDialogTitle; + + /// Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message. + /// + /// In en, this message translates to: + /// **'When you edit a message, the content that was previously in the compose box is discarded.'** + String get discardDraftForEditConfirmationDialogMessage; + + /// Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'When you restore an unsent message, the content that was previously in the compose box is discarded.'** + String get discardDraftForOutboxConfirmationDialogMessage; + + /// Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'Discard'** + String get discardDraftConfirmationDialogConfirmButton; + /// Tooltip for compose box icon to attach a file to the message. /// /// In en, this message translates to: @@ -519,6 +853,42 @@ abstract class ZulipLocalizations { /// **'Type a message'** String get composeBoxGenericContentHint; + /// Label for the compose button in the new DM sheet that starts composing a message to the selected users. + /// + /// In en, this message translates to: + /// **'Compose'** + String get newDmSheetComposeButtonLabel; + + /// Title displayed at the top of the new DM screen. + /// + /// In en, this message translates to: + /// **'New DM'** + String get newDmSheetScreenTitle; + + /// Label for the floating action button (FAB) that opens the new DM sheet. + /// + /// In en, this message translates to: + /// **'New DM'** + String get newDmFabButtonLabel; + + /// Hint text for the search bar when no users are selected + /// + /// In en, this message translates to: + /// **'Add one or more users'** + String get newDmSheetSearchHintEmpty; + + /// Hint text for the search bar when at least one user is selected. + /// + /// In en, this message translates to: + /// **'Add another user…'** + String get newDmSheetSearchHintSomeSelected; + + /// Message shown in the new DM sheet when no users match the search. + /// + /// In en, this message translates to: + /// **'No users found'** + String get newDmSheetNoUsersFound; + /// Hint text for content input when sending a message to one other person. /// /// In en, this message translates to: @@ -537,11 +907,17 @@ abstract class ZulipLocalizations { /// **'Jot down something'** String get composeBoxSelfDmContentHint; - /// Hint text for content input when sending a message to a channel + /// Hint text for content input when sending a message to a channel. /// /// In en, this message translates to: - /// **'Message #{channel} > {topic}'** - String composeBoxChannelContentHint(String channel, String topic); + /// **'Message {destination}'** + String composeBoxChannelContentHint(String destination); + + /// Hint text for content input when the compose box is preparing to edit a message. + /// + /// In en, this message translates to: + /// **'Preparing…'** + String get preparingEditMessageContentInput; /// Tooltip for send button in compose box. /// @@ -561,6 +937,12 @@ abstract class ZulipLocalizations { /// **'Topic'** String get composeBoxTopicHintText; + /// Hint text for topic input widget in compose box when topics are optional. + /// + /// In en, this message translates to: + /// **'Enter a topic (skip for “{defaultTopicName}”)'** + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName); + /// Placeholder in compose box showing the specified file is currently uploading. /// /// In en, this message translates to: @@ -597,10 +979,22 @@ abstract class ZulipLocalizations { /// **'DMs with {others}'** String dmsWithOthersPageTitle(String others); + /// Placeholder for some message-list pages when there are no messages. + /// + /// In en, this message translates to: + /// **'There are no messages here.'** + String get emptyMessageList; + + /// Placeholder for the 'Search' page when there are no messages. + /// + /// In en, this message translates to: + /// **'No search results.'** + String get emptyMessageListSearch; + /// Message list recipient header for a DM group that only includes yourself. /// /// In en, this message translates to: - /// **'You with yourself'** + /// **'Messages with yourself'** String get messageListGroupYouWithYourself; /// Content validation error message when the message is too long. @@ -645,6 +1039,12 @@ abstract class ZulipLocalizations { /// **'Close'** String get dialogClose; + /// Button label in error dialogs to open a web page with more information. + /// + /// In en, this message translates to: + /// **'Learn more'** + String get errorDialogLearnMore; + /// Button label in error dialogs to acknowledge the error and close the dialog. /// /// In en, this message translates to: @@ -711,11 +1111,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. /// @@ -771,10 +1171,38 @@ abstract class ZulipLocalizations { /// **'Topics are required in this organization.'** String get topicValidationErrorMandatoryButEmpty; + /// Title for error dialog when an attempt to insert rich content failed. + /// + /// In en, this message translates to: + /// **'Content not inserted'** + String get errorContentNotInsertedTitle; + + /// Error message when the rich content to be inserted is empty or cannot be accessed. + /// + /// In en, this message translates to: + /// **'The file to be inserted is empty or cannot be accessed.'** + String get errorContentToInsertIsEmpty; + + /// Error message in the dialog for when the Zulip Server version is unsupported. + /// + /// In en, this message translates to: + /// **'{url} is running Zulip Server {zulipVersion}, which is unsupported. The minimum supported version is Zulip Server {minSupportedZulipVersion}.'** + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ); + + /// Error message in the dialog for invalid API key. + /// + /// In en, this message translates to: + /// **'Your account at {url} could not be authenticated. Please try logging in again or use another account.'** + String errorInvalidApiKeyMessage(String url); + /// Error message when an API call returned an invalid response. /// /// In en, this message translates to: - /// **'The server sent an invalid response'** + /// **'The server sent an invalid response.'** String get errorInvalidResponse; /// Error message when a network request fails. @@ -804,7 +1232,7 @@ abstract class ZulipLocalizations { /// Error message when a video fails to play. /// /// In en, this message translates to: - /// **'Unable to play the video'** + /// **'Unable to play the video.'** String get errorVideoPlayerFailed; /// Error message when URL is empty @@ -891,6 +1319,74 @@ abstract class ZulipLocalizations { /// **'Yesterday'** String get yesterday; + /// Indicates a user is currently active on Zulip (not idle or offline) + /// + /// In en, this message translates to: + /// **'Active now'** + String get userActiveNow; + + /// Indicates a user is currently idle on Zulip (not active, but not offline) + /// + /// In en, this message translates to: + /// **'Idle'** + String get userIdle; + + /// Indicates when a user was last active on Zulip (who is currently offline) + /// + /// In en, this message translates to: + /// **'Active {minutes, plural, =1{1 minute} other{{minutes} minutes}} ago'** + String userActiveMinutesAgo(int minutes); + + /// Indicates when a user was last active on Zulip (who is currently offline) + /// + /// In en, this message translates to: + /// **'Active {hours, plural, =1{1 hour} other{{hours} hours}} ago'** + String userActiveHoursAgo(int hours); + + /// Indicates when a user was last active on Zulip (who is currently offline) + /// + /// In en, this message translates to: + /// **'Active yesterday'** + String get userActiveYesterday; + + /// Indicates when a user was last active on Zulip (who is currently offline) + /// + /// In en, this message translates to: + /// **'Active {days, plural, =1{1 day} other{{days} days}} ago'** + String userActiveDaysAgo(int days); + + /// Indicates the date when a user was last active on Zulip (who is currently offline). + /// + /// The date might be day and month if recent, or day, month, and year if less recent. + /// + /// In en, this message translates to: + /// **'Active {date}'** + String userActiveDate(String date); + + /// Indicates when a user was last active on Zulip (who is currently offline) + /// + /// In en, this message translates to: + /// **'Not active in the last year'** + String get userNotActiveInYear; + + /// Label for the 'Invisible mode' switch on the profile page. + /// + /// In en, this message translates to: + /// **'Invisible mode'** + String get invisibleMode; + + /// Error title when turning on invisible mode failed. + /// + /// In en, this message translates to: + /// **'Error turning on invisible mode. Please try again.'** + String get turnOnInvisibleModeErrorTitle; + + /// Error title when turning off invisible mode failed. + /// + /// In en, this message translates to: + /// **'Error turning off invisible mode. Please try again.'** + String get turnOffInvisibleModeErrorTitle; + /// Label for UserRole.owner /// /// In en, this message translates to: @@ -927,12 +1423,126 @@ abstract class ZulipLocalizations { /// **'Unknown'** String get userRoleUnknown; + /// The status button label in self-user profile page when status is set. + /// + /// In en, this message translates to: + /// **'Status'** + String get statusButtonLabelStatusSet; + + /// The status button label in self-user profile page when status is not set. + /// + /// In en, this message translates to: + /// **'Set status'** + String get statusButtonLabelStatusUnset; + + /// The text part of the status button sub-label in self-user profile page when status text is not set. + /// + /// In en, this message translates to: + /// **'No status text'** + String get noStatusText; + + /// Title for the 'Set status' page. + /// + /// In en, this message translates to: + /// **'Set status'** + String get setStatusPageTitle; + + /// Label for the button that clears the user status, in 'Set status' page. + /// + /// In en, this message translates to: + /// **'Clear'** + String get statusClearButtonLabel; + + /// Label for the button that saves the user status, in 'Set status' page. + /// + /// In en, this message translates to: + /// **'Save'** + String get statusSaveButtonLabel; + + /// Hint text for the status text input field in 'Set status' page. + /// + /// In en, this message translates to: + /// **'Your status'** + String get statusTextHint; + + /// A suggested user status text, 'Busy'. + /// + /// In en, this message translates to: + /// **'Busy'** + String get userStatusBusy; + + /// A suggested user status text, 'In a meeting'. + /// + /// In en, this message translates to: + /// **'In a meeting'** + String get userStatusInAMeeting; + + /// A suggested user status text, 'Commuting'. + /// + /// In en, this message translates to: + /// **'Commuting'** + String get userStatusCommuting; + + /// A suggested user status text, 'Out sick'. + /// + /// In en, this message translates to: + /// **'Out sick'** + String get userStatusOutSick; + + /// A suggested user status text, 'Vacationing'. + /// + /// In en, this message translates to: + /// **'Vacationing'** + String get userStatusVacationing; + + /// A suggested user status text, 'Working remotely'. + /// + /// In en, this message translates to: + /// **'Working remotely'** + String get userStatusWorkingRemotely; + + /// A suggested user status text, 'At the office'. + /// + /// In en, this message translates to: + /// **'At the office'** + String get userStatusAtTheOffice; + + /// Error title when updating user status failed. + /// + /// In en, this message translates to: + /// **'Error updating user status. Please try again.'** + String get updateStatusErrorTitle; + + /// Page title for the 'Search' message view. + /// + /// In en, this message translates to: + /// **'Search'** + String get searchMessagesPageTitle; + + /// Hint text for the message search text field. + /// + /// In en, this message translates to: + /// **'Search'** + String get searchMessagesHintText; + + /// Tooltip for the 'x' button in the search text field. + /// + /// In en, this message translates to: + /// **'Clear'** + String get searchMessagesClearButtonTooltip; + /// Title for the page with unreads. /// /// In en, this message translates to: /// **'Inbox'** String get inboxPageTitle; + /// Centered text on the 'Inbox' page saying that there is no content to show. + /// + /// In en, this message translates to: + /// **'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'** + String get inboxEmptyPlaceholder; + /// Title for the page with a list of DM conversations. /// /// In en, this message translates to: @@ -945,6 +1555,12 @@ abstract class ZulipLocalizations { /// **'Direct messages'** String get recentDmConversationsSectionHeader; + /// Centered text on the 'Direct messages' page saying that there is no content to show. + /// + /// In en, this message translates to: + /// **'You have no direct messages yet! Why not start the conversation?'** + String get recentDmConversationsEmptyPlaceholder; + /// Page title for the 'Combined feed' message view. /// /// In en, this message translates to: @@ -969,12 +1585,30 @@ abstract class ZulipLocalizations { /// **'Channels'** String get channelsPageTitle; + /// Centered text on the 'Channels' page saying that there is no content to show. + /// + /// In en, this message translates to: + /// **'You are not subscribed to any channels yet.'** + String get channelsEmptyPlaceholder; + + /// Title for the page about sharing content received from other apps. + /// + /// In en, this message translates to: + /// **'Share'** + String get sharePageTitle; + /// Label for main-menu button leading to the user's own profile. /// /// In en, this message translates to: /// **'My profile'** String get mainMenuMyProfile; + /// Tooltip for button to navigate to topic-list page. + /// + /// In en, this message translates to: + /// **'Topics'** + String get topicsButtonTooltip; + /// Tooltip for button to navigate to a given channel's feed /// /// In en, this message translates to: @@ -999,12 +1633,6 @@ abstract class ZulipLocalizations { /// **'Unpinned'** String get unpinnedSubscriptionsLabel; - /// Text to display on subscribed-channels page when there are no subscribed channels. - /// - /// In en, this message translates to: - /// **'No channels found'** - String get subscriptionListNoChannels; - /// Display name for the user themself, to show after replying in an Android notification /// /// In en, this message translates to: @@ -1017,6 +1645,24 @@ abstract class ZulipLocalizations { /// **'You'** String get reactedEmojiSelfUser; + /// Text identifying the container of reaction chips on a message. (An accessibility label for assistive technology.) + /// + /// In en, this message translates to: + /// **'Reactions'** + String get reactionChipsLabel; + + /// Text describing a reaction chip, with the emoji name and a list or number of votes. (An accessibility label for assistive technology.) + /// + /// In en, this message translates to: + /// **'{emojiName}: {votes}'** + String reactionChipLabel(String emojiName, String votes); + + /// The number of votes on a reaction chip, where the self-user and at least one other user has voted. (An accessibility label for assistive technology.) + /// + /// In en, this message translates to: + /// **'{otherUsersCount, plural, =1{You and 1 other} other{You and {otherUsersCount} others}}'** + String reactionChipVotesYouAndOthers(int otherUsersCount); + /// Text to display when there is one user typing. /// /// In en, this message translates to: @@ -1101,12 +1747,48 @@ abstract class ZulipLocalizations { /// **'MOVED'** String get messageIsMovedLabel; + /// Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'MESSAGE NOT SENT'** + String get messageNotSentLabel; + /// The list of people who voted for a poll option, wrapped in parentheses. /// /// In en, this message translates to: /// **'({voterNames})'** String pollVoterNames(String voterNames); + /// Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'THEME'** + String get themeSettingTitle; + + /// Label for dark theme setting. + /// + /// In en, this message translates to: + /// **'Dark'** + String get themeSettingDark; + + /// Label for light theme setting. + /// + /// In en, this message translates to: + /// **'Light'** + String get themeSettingLight; + + /// Label for system theme setting. + /// + /// In en, this message translates to: + /// **'System'** + String get themeSettingSystem; + + /// Label for toggling setting to open links with in-app browser + /// + /// In en, this message translates to: + /// **'Open links with in-app browser'** + String get openLinksWithInAppBrowser; + /// Text to display for a poll when the question is missing /// /// In en, this message translates to: @@ -1119,17 +1801,95 @@ abstract class ZulipLocalizations { /// **'This poll has no options yet.'** String get pollWidgetOptionsMissing; + /// Title of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'Open message feeds at'** + String get initialAnchorSettingTitle; + + /// Description of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'You can choose whether message feeds open at your first unread message or at the newest messages.'** + String get initialAnchorSettingDescription; + + /// Label for a value of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'First unread message'** + String get initialAnchorSettingFirstUnreadAlways; + + /// Label for a value of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'First unread message in conversation views, newest message elsewhere'** + String get initialAnchorSettingFirstUnreadConversations; + + /// Label for a value of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'Newest message'** + String get initialAnchorSettingNewestAlways; + + /// Title of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Mark messages as read on scroll'** + String get markReadOnScrollSettingTitle; + + /// Description of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'When scrolling through messages, should they automatically be marked as read?'** + String get markReadOnScrollSettingDescription; + + /// Label for a value of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Always'** + String get markReadOnScrollSettingAlways; + + /// Label for a value of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Never'** + String get markReadOnScrollSettingNever; + + /// Label for a value of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Only in conversation views'** + String get markReadOnScrollSettingConversations; + + /// Description for a value of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'** + String get markReadOnScrollSettingConversationsDescription; + + /// Title of settings page for experimental, in-development features + /// + /// In en, this message translates to: + /// **'Experimental features'** + String get experimentalFeatureSettingsPageTitle; + + /// Warning text on settings page for experimental, in-development features + /// + /// In en, this message translates to: + /// **'These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.'** + String get experimentalFeatureSettingsWarning; + /// Error title when notification opening fails /// /// In en, this message translates to: /// **'Failed to open notification'** String get errorNotificationOpenTitle; - /// Error message when the account associated with the notification is not found + /// Error message when the account associated with the notification could not be found /// /// In en, this message translates to: - /// **'The account associated with this notification no longer exists.'** - String get errorNotificationOpenAccountMissing; + /// **'The account associated with this notification could not be found.'** + String get errorNotificationOpenAccountNotFound; /// Error title when adding a message reaction fails /// @@ -1143,6 +1903,18 @@ abstract class ZulipLocalizations { /// **'Removing reaction failed'** String get errorReactionRemovingFailedTitle; + /// Error title when sharing content received from other apps fails + /// + /// In en, this message translates to: + /// **'Failed to share content'** + String get errorSharingTitle; + + /// Error title when sharing content received from other apps fails, when there is no account logged in + /// + /// In en, this message translates to: + /// **'There is no account logged in. Please log in to an account and try again.'** + String get errorSharingAccountNotLoggedIn; + /// Label for a button opening the emoji picker. /// /// In en, this message translates to: @@ -1161,46 +1933,124 @@ abstract class ZulipLocalizations { /// **'No earlier messages'** String get noEarlierMessages; + /// Label for the button revealing hidden message from a muted sender in message list. + /// + /// In en, this message translates to: + /// **'Reveal message'** + String get revealButtonLabel; + + /// Text to display in place of a muted user's name. + /// + /// In en, this message translates to: + /// **'Muted user'** + String get mutedUser; + /// Tooltip for button to scroll to bottom. /// /// In en, this message translates to: /// **'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 { +class _ZulipLocalizationsDelegate + extends LocalizationsDelegate { const _ZulipLocalizationsDelegate(); @override Future load(Locale locale) { - return SynchronousFuture(lookupZulipLocalizations(locale)); + return SynchronousFuture( + lookupZulipLocalizations(locale), + ); } @override - bool isSupported(Locale locale) => ['ar', 'en', 'ja', 'nb', 'pl', 'ru', 'sk'].contains(locale.languageCode); + bool isSupported(Locale locale) => [ + 'ar', + 'de', + 'en', + 'fr', + 'it', + 'ja', + 'nb', + 'pl', + 'ru', + 'sk', + 'sl', + 'uk', + 'zh', + ].contains(locale.languageCode); @override bool shouldReload(_ZulipLocalizationsDelegate old) => false; } ZulipLocalizations lookupZulipLocalizations(Locale locale) { + // Lookup logic when language+script+country codes are specified. + switch (locale.toString()) { + case 'zh_Hans_CN': + return ZulipLocalizationsZhHansCn(); + case 'zh_Hant_TW': + return ZulipLocalizationsZhHantTw(); + } + // Lookup logic when language+country codes are specified. + switch (locale.languageCode) { + case 'en': + { + switch (locale.countryCode) { + case 'GB': + return ZulipLocalizationsEnGb(); + } + break; + } + } // Lookup logic when only language code is specified. switch (locale.languageCode) { - case 'ar': return ZulipLocalizationsAr(); - case 'en': return ZulipLocalizationsEn(); - case 'ja': return ZulipLocalizationsJa(); - case 'nb': return ZulipLocalizationsNb(); - case 'pl': return ZulipLocalizationsPl(); - case 'ru': return ZulipLocalizationsRu(); - case 'sk': return ZulipLocalizationsSk(); + case 'ar': + return ZulipLocalizationsAr(); + case 'de': + return ZulipLocalizationsDe(); + case 'en': + return ZulipLocalizationsEn(); + case 'fr': + return ZulipLocalizationsFr(); + case 'it': + return ZulipLocalizationsIt(); + case 'ja': + return ZulipLocalizationsJa(); + case 'nb': + return ZulipLocalizationsNb(); + case 'pl': + return ZulipLocalizationsPl(); + case 'ru': + return ZulipLocalizationsRu(); + case 'sk': + return ZulipLocalizationsSk(); + case 'sl': + return ZulipLocalizationsSl(); + case 'uk': + return ZulipLocalizationsUk(); + case 'zh': + return ZulipLocalizationsZh(); } throw FlutterError( 'ZulipLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' 'an issue with the localizations generation tool. Please file an issue ' 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.' + 'that was used.', ); } diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 025b4b1444..0ff35c4b5b 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -20,9 +20,26 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get aboutPageTapToView => 'Tap to view'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Choose account'; + @override + String get settingsPageTitle => 'Settings'; + @override String get switchAccountButton => 'Switch account'; @@ -41,7 +58,8 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get logOutConfirmationDialogTitle => 'Log out?'; @override - String get logOutConfirmationDialogMessage => 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; + String get logOutConfirmationDialogMessage => + 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; @override String get logOutConfirmationDialogConfirmButton => 'Log out'; @@ -62,10 +80,48 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get permissionsNeededOpenSettings => 'Open settings'; @override - String get permissionsDeniedCameraAccess => 'To upload an image, please grant Zulip additional permissions in Settings.'; + String get permissionsDeniedCameraAccess => + 'To upload an image, please grant Zulip additional permissions in Settings.'; @override - String get permissionsDeniedReadExternalStorage => 'To upload files, please grant Zulip additional permissions in Settings.'; + String get permissionsDeniedReadExternalStorage => + 'To upload files, please grant Zulip additional permissions in Settings.'; + + @override + String get actionSheetOptionSubscribe => 'Subscribe'; + + @override + String get subscribeFailedTitle => 'Failed to subscribe'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + + @override + String get actionSheetOptionCopyChannelLink => 'Copy link to channel'; + + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + + @override + String get actionSheetOptionUnsubscribe => 'Unsubscribe'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return 'Unsubscribe from $channelName?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + 'Once you leave this channel, you might not be able to rejoin.'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe'; + + @override + String get unsubscribeFailedTitle => 'Failed to unsubscribe'; @override String get actionSheetOptionMuteTopic => 'Mute topic'; @@ -79,6 +135,71 @@ 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 actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num total)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; @@ -88,11 +209,14 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; @@ -100,6 +224,15 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; @@ -115,7 +248,8 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String get errorCouldNotFetchMessageSource => 'Could not fetch message source'; + String get errorCouldNotFetchMessageSource => + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -126,7 +260,16 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, @@ -156,16 +299,20 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorMessageNotSent => 'Message not sent'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Failed to connect to server:\n$url'; } @override - String get errorLoginCouldNotConnectTitle => 'Could not connect'; + String get errorCouldNotConnectTitle => 'Could not connect'; @override - String get errorMessageDoesNotSeemToExist => 'That message does not seem to exist.'; + String get errorMessageDoesNotSeemToExist => + 'That message does not seem to exist.'; @override String get errorQuotationFailed => 'Quotation failed'; @@ -176,7 +323,8 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String get errorConnectingToServerShort => 'Error connecting to Zulip. Retrying…'; + String get errorConnectingToServerShort => + 'Error connecting to Zulip. Retrying…'; @override String errorConnectingToServerDetails(String serverUrl, String error) { @@ -184,10 +332,15 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String get errorHandlingEventTitle => 'Error handling a Zulip event. Retrying connection…'; + String get errorHandlingEventTitle => + 'Error handling a Zulip event. Retrying connection…'; @override - String errorHandlingEventDetails(String serverUrl, String error, String event) { + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; } @@ -220,6 +373,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -230,10 +386,55 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get successMessageLinkCopied => 'Message link copied'; @override - String get errorBannerDeactivatedDmLabel => 'You cannot send messages to deactivated users.'; + String get successTopicLinkCopied => 'Topic link copied'; + + @override + String get successChannelLinkCopied => 'Channel link copied'; + + @override + String get errorBannerDeactivatedDmLabel => + 'You cannot send messages to deactivated users.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'You do not have permission to post in this channel.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; @override - String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + String get discardDraftForEditConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -247,6 +448,24 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetComposeButtonLabel => 'Compose'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -259,10 +478,13 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get composeBoxSelfDmContentHint => 'Jot down something'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Message #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; @@ -272,6 +494,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; @@ -299,19 +526,28 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String get messageListGroupYouWithYourself => 'You with yourself'; + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; @override - String get contentValidationErrorTooLong => 'Message length shouldn\'t be greater than 10000 characters.'; + String get messageListGroupYouWithYourself => 'Messages with yourself'; + + @override + String get contentValidationErrorTooLong => + 'Message length shouldn\'t be greater than 10000 characters.'; @override String get contentValidationErrorEmpty => 'You have nothing to send!'; @override - String get contentValidationErrorQuoteAndReplyInProgress => 'Please wait for the quotation to complete.'; + String get contentValidationErrorQuoteAndReplyInProgress => + 'Please wait for the quotation to complete.'; @override - String get contentValidationErrorUploadInProgress => 'Please wait for the upload to complete.'; + String get contentValidationErrorUploadInProgress => + 'Please wait for the upload to complete.'; @override String get dialogCancel => 'Cancel'; @@ -322,6 +558,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get dialogClose => 'Close'; + @override + String get errorDialogLearnMore => 'Learn more'; + @override String get errorDialogContinue => 'OK'; @@ -358,7 +597,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'; @@ -382,13 +621,36 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get loginErrorMissingUsername => 'Please enter your username.'; @override - String get topicValidationErrorTooLong => 'Topic length shouldn\'t be greater than 60 characters.'; + String get topicValidationErrorTooLong => + 'Topic length shouldn\'t be greater than 60 characters.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Topics are required in this organization.'; + + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => + 'The file to be inserted is empty or cannot be accessed.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } @override - String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -409,7 +671,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; @@ -418,10 +680,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get serverUrlValidationErrorInvalidUrl => 'Please enter a valid URL.'; @override - String get serverUrlValidationErrorNoUseEmail => 'Please enter the server URL, not your email.'; + String get serverUrlValidationErrorNoUseEmail => + 'Please enter the server URL, not your email.'; @override - String get serverUrlValidationErrorUnsupportedScheme => 'The server URL must start with http:// or https://.'; + String get serverUrlValidationErrorUnsupportedScheme => + 'The server URL must start with http:// or https://.'; @override String get spoilerDefaultHeaderText => 'Spoiler'; @@ -469,6 +733,67 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get yesterday => 'Yesterday'; + @override + String get userActiveNow => 'Active now'; + + @override + String get userIdle => 'Idle'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes', + one: '1 minute', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours hours', + one: '1 hour', + ); + return 'Active $_temp0 ago'; + } + + @override + String get userActiveYesterday => 'Active yesterday'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days days', + one: '1 day', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveDate(String date) { + return 'Active $date'; + } + + @override + String get userNotActiveInYear => 'Not active in the last year'; + + @override + String get invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + @override String get userRoleOwner => 'Owner'; @@ -487,15 +812,78 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -508,9 +896,19 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + + @override + String get sharePageTitle => 'Share'; + @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonTooltip => 'Topics'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -531,15 +929,31 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; @override String get reactedEmojiSelfUser => 'You'; + @override + String get reactionChipsLabel => 'Reactions'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } + + @override + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: 'You and $otherUsersCount others', + one: 'You and 1 other', + ); + return '$_temp0'; + } + @override String onePersonTyping(String typist) { return '$typist is typing…'; @@ -586,22 +1000,86 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; } + @override + String get themeSettingTitle => 'THEME'; + + @override + String get themeSettingDark => 'Dark'; + + @override + String get themeSettingLight => 'Light'; + + @override + String get themeSettingSystem => 'System'; + + @override + String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override String get pollWidgetQuestionMissing => 'No question.'; @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in conversation views, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Experimental features'; + + @override + String get experimentalFeatureSettingsWarning => + 'These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.'; + @override String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; @@ -609,6 +1087,13 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; @@ -618,6 +1103,18 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get revealButtonLabel => 'Reveal message'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart new file mode 100644 index 0000000000..a22b46f863 --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -0,0 +1,1150 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for German (`de`). +class ZulipLocalizationsDe extends ZulipLocalizations { + ZulipLocalizationsDe([String locale = 'de']) : super(locale); + + @override + String get aboutPageTitle => 'Über Zulip'; + + @override + String get aboutPageAppVersion => 'App-Version'; + + @override + String get aboutPageOpenSourceLicenses => 'Open-Source-Lizenzen'; + + @override + String get aboutPageTapToView => 'Antippen zum Ansehen'; + + @override + String get upgradeWelcomeDialogTitle => 'Willkommen in der neuen Zulip-App!'; + + @override + String get upgradeWelcomeDialogMessage => + 'Du wirst ein vertrautes Erlebnis in einer schnelleren, schlankeren App erleben.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Sieh dir den Ankündigungs-Blogpost an!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Los gehts'; + + @override + String get chooseAccountPageTitle => 'Konto auswählen'; + + @override + String get settingsPageTitle => 'Einstellungen'; + + @override + String get switchAccountButton => 'Konto wechseln'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Dein Account bei $url benötigt einige Zeit zum Laden.'; + } + + @override + String get tryAnotherAccountButton => 'Anderen Account ausprobieren'; + + @override + String get chooseAccountPageLogOutButton => 'Abmelden'; + + @override + String get logOutConfirmationDialogTitle => 'Abmelden?'; + + @override + String get logOutConfirmationDialogMessage => + 'Um diesen Account in Zukunft zu verwenden, musst du die URL deiner Organisation und deine Account-Informationen erneut eingeben.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Abmelden'; + + @override + String get chooseAccountButtonAddAnAccount => 'Account hinzufügen'; + + @override + String get profileButtonSendDirectMessage => 'Direktnachricht senden'; + + @override + String get errorCouldNotShowUserProfile => + 'Nutzerprofil kann nicht angezeigt werden.'; + + @override + String get permissionsNeededTitle => 'Berechtigungen erforderlich'; + + @override + String get permissionsNeededOpenSettings => 'Einstellungen öffnen'; + + @override + String get permissionsDeniedCameraAccess => + 'Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um ein Bild hochzuladen.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um Dateien hochzuladen.'; + + @override + String get actionSheetOptionSubscribe => 'Abonnieren'; + + @override + String get subscribeFailedTitle => 'Konnte nicht abonnieren'; + + @override + String get actionSheetOptionMarkChannelAsRead => + 'Kanal als gelesen markieren'; + + @override + String get actionSheetOptionCopyChannelLink => 'Link zum Kanal kopieren'; + + @override + String get actionSheetOptionListOfTopics => 'Themenliste'; + + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + + @override + String get actionSheetOptionUnsubscribe => 'Deabonnieren'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return '$channelName deabonnieren?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + 'Wenn du diesen Kanal verlässt, kannst du sich vielleicht nicht wieder beitreten.'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => 'Deabonnieren'; + + @override + String get unsubscribeFailedTitle => 'Konnte nicht deabonnieren'; + + @override + String get actionSheetOptionMuteTopic => 'Thema stummschalten'; + + @override + String get actionSheetOptionUnmuteTopic => 'Thema lautschalten'; + + @override + String get actionSheetOptionFollowTopic => 'Thema folgen'; + + @override + String get actionSheetOptionUnfollowTopic => 'Thema entfolgen'; + + @override + String get actionSheetOptionResolveTopic => 'Als gelöst markieren'; + + @override + String get actionSheetOptionUnresolveTopic => 'Als ungelöst markieren'; + + @override + String get errorResolveTopicFailedTitle => + 'Thema konnte nicht als gelöst markiert werden'; + + @override + String get errorUnresolveTopicFailedTitle => + 'Thema konnte nicht als ungelöst markiert werden'; + + @override + String get actionSheetOptionSeeWhoReacted => 'Wer hat reagiert'; + + @override + String get seeWhoReactedSheetNoReactions => + 'Diese Nachricht hat keine Reaktionen.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji-Reaktionen (insgesamt $num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num Stimmen', + one: '1 Stimme', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Stimmen für $emojiName ($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => + 'Empfangsbestätigungen ansehen'; + + @override + String get actionSheetReadReceipts => 'Empfangsbestätigungen'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + 'Diese Nachricht wurde von $count Personen gelesen:', + one: 'Diese Nachricht wurde von einer Person gelesen:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'Niemand hat diese Nachricht bisher gelesen.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Laden von Empfangsbestätigungen fehlgeschlagen.'; + + @override + String get actionSheetOptionCopyMessageText => 'Nachrichtentext kopieren'; + + @override + String get actionSheetOptionCopyMessageLink => 'Link zur Nachricht kopieren'; + + @override + String get actionSheetOptionMarkAsUnread => 'Ab hier als ungelesen markieren'; + + @override + String get actionSheetOptionHideMutedMessage => + 'Stummgeschaltete Nachricht wieder ausblenden'; + + @override + String get actionSheetOptionShare => 'Teilen'; + + @override + String get actionSheetOptionQuoteMessage => 'Nachricht zitieren'; + + @override + String get actionSheetOptionStarMessage => 'Nachricht markieren'; + + @override + String get actionSheetOptionUnstarMessage => 'Markierung aufheben'; + + @override + String get actionSheetOptionEditMessage => 'Nachricht bearbeiten'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Thema als gelesen markieren'; + + @override + String get actionSheetOptionCopyTopicLink => 'Link zum Thema kopieren'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Etwas ist schiefgelaufen'; + + @override + String get errorWebAuthOperationalError => + 'Ein unerwarteter Fehler ist aufgetreten.'; + + @override + String get errorAccountLoggedInTitle => 'Account bereits angemeldet'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'Der Account $email auf $server ist bereits in deiner Account-Liste.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Konnte Nachrichtenquelle nicht abrufen.'; + + @override + String get errorCopyingFailed => 'Kopieren fehlgeschlagen'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return 'Fehler beim Upload der Datei: $filename'; + } + + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num Dateien sind', + one: 'Datei ist', + ); + String _temp1 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num werden', + one: 'wird', + ); + return '$_temp0 größer als das Serverlimit von $maxFileUploadSizeMib MiB und $_temp1 nicht hochgeladen:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'Dateien', + one: 'Datei', + ); + return '$_temp0 zu groß'; + } + + @override + String get errorLoginInvalidInputTitle => 'Ungültige Eingabe'; + + @override + String get errorLoginFailedTitle => 'Anmeldung fehlgeschlagen'; + + @override + String get errorMessageNotSent => 'Nachricht nicht versendet'; + + @override + String get errorMessageEditNotSaved => 'Nachricht nicht gespeichert'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'Verbindung zu Server fehlgeschlagen:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => 'Konnte nicht verbinden'; + + @override + String get errorMessageDoesNotSeemToExist => + 'Diese Nachricht scheint nicht zu existieren.'; + + @override + String get errorQuotationFailed => 'Zitat fehlgeschlagen'; + + @override + String errorServerMessage(String message) { + return 'Der Server sagte:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Fehler beim Verbinden mit Zulip. Wiederhole…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Fehler beim Verbinden mit Zulip auf $serverUrl. Wird wiederholt:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Fehler beim Verarbeiten eines Zulip-Ereignisses. Wiederhole Verbindung…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Fehler beim Verarbeiten eines Zulip-Ereignisses von $serverUrl; Wird wiederholt.\n\nFehler: $error\n\nEreignis: $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Link kann nicht geöffnet werden'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Link konnte nicht geöffnet werden: $url'; + } + + @override + String get errorMuteTopicFailed => 'Konnte Thema nicht stummschalten'; + + @override + String get errorUnmuteTopicFailed => 'Konnte Thema nicht lautschalten'; + + @override + String get errorFollowTopicFailed => 'Konnte Thema nicht folgen'; + + @override + String get errorUnfollowTopicFailed => 'Konnte Thema nicht entfolgen'; + + @override + String get errorSharingFailed => 'Teilen fehlgeschlagen'; + + @override + String get errorStarMessageFailedTitle => 'Konnte Nachricht nicht markieren'; + + @override + String get errorUnstarMessageFailedTitle => + 'Konnte Markierung nicht von der Nachricht entfernen'; + + @override + String get errorCouldNotEditMessageTitle => + 'Konnte Nachricht nicht bearbeiten'; + + @override + String get successLinkCopied => 'Link kopiert'; + + @override + String get successMessageTextCopied => 'Nachrichtentext kopiert'; + + @override + String get successMessageLinkCopied => 'Nachrichtenlink kopiert'; + + @override + String get successTopicLinkCopied => 'Link zum Thema kopiert'; + + @override + String get successChannelLinkCopied => 'Kanallink kopiert'; + + @override + String get errorBannerDeactivatedDmLabel => + 'Du kannst keine Nachrichten an deaktivierte Nutzer:innen senden.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'Du hast keine Berechtigung in diesen Kanal zu schreiben.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Nachricht bearbeiten'; + + @override + String get composeBoxBannerButtonCancel => 'Abbrechen'; + + @override + String get composeBoxBannerButtonSave => 'Speichern'; + + @override + String get editAlreadyInProgressTitle => 'Kann Nachricht nicht bearbeiten'; + + @override + String get editAlreadyInProgressMessage => + 'Eine Bearbeitung läuft gerade. Bitte warte bis sie abgeschlossen ist.'; + + @override + String get savingMessageEditLabel => 'SPEICHERE BEARBEITUNG…'; + + @override + String get savingMessageEditFailedLabel => 'BEARBEITUNG NICHT GESPEICHERT'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Die Nachricht, die du schreibst, verwerfen?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'Wenn du eine Nachricht bearbeitest, wird der vorherige Inhalt der Nachrichteneingabe verworfen.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'Wenn du eine nicht gesendete Nachricht wiederherstellst, wird der vorherige Inhalt der Nachrichteneingabe verworfen.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Verwerfen'; + + @override + String get composeBoxAttachFilesTooltip => 'Dateien anhängen'; + + @override + String get composeBoxAttachMediaTooltip => 'Bilder oder Videos anhängen'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Ein Foto aufnehmen'; + + @override + String get composeBoxGenericContentHint => 'Eine Nachricht eingeben'; + + @override + String get newDmSheetComposeButtonLabel => 'Verfassen'; + + @override + String get newDmSheetScreenTitle => 'Neue DN'; + + @override + String get newDmFabButtonLabel => 'Neue DN'; + + @override + String get newDmSheetSearchHintEmpty => + 'Füge ein oder mehrere Nutzer:innen hinzu'; + + @override + String get newDmSheetSearchHintSomeSelected => + 'Füge weitere Nutzer:in hinzu…'; + + @override + String get newDmSheetNoUsersFound => 'Keine Nutzer:innen gefunden'; + + @override + String composeBoxDmContentHint(String user) { + return 'Nachricht an @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Nachricht an Gruppe'; + + @override + String get composeBoxSelfDmContentHint => 'Schreibe etwas'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Nachricht an $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Bereite vor…'; + + @override + String get composeBoxSendTooltip => 'Senden'; + + @override + String get unknownChannelName => '(unbekannter Kanal)'; + + @override + String get composeBoxTopicHintText => 'Thema'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Gib ein Thema ein (leer lassen für “$defaultTopicName”)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Lade $filename hoch…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(lade Nachricht $messageId)'; + } + + @override + String get unknownUserName => '(Nutzer:in unbekannt)'; + + @override + String get dmsWithYourselfPageTitle => 'DNs mit dir selbst'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'Du und $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'DNs mit $others'; + } + + @override + String get emptyMessageList => 'Hier gibt es keine Nachrichten.'; + + @override + String get emptyMessageListSearch => 'Keine Suchergebnisse.'; + + @override + String get messageListGroupYouWithYourself => 'Nachrichten mit dir selbst'; + + @override + String get contentValidationErrorTooLong => + 'Nachrichtenlänge sollte nicht größer als 10000 Zeichen sein.'; + + @override + String get contentValidationErrorEmpty => 'Du hast nichts zum Senden!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Bitte warte bis das Zitat abgeschlossen ist.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Bitte warte bis das Hochladen abgeschlossen ist.'; + + @override + String get dialogCancel => 'Abbrechen'; + + @override + String get dialogContinue => 'Fortsetzen'; + + @override + String get dialogClose => 'Schließen'; + + @override + String get errorDialogLearnMore => 'Mehr erfahren'; + + @override + String get errorDialogContinue => 'OK'; + + @override + String get errorDialogTitle => 'Fehler'; + + @override + String get snackBarDetails => 'Details'; + + @override + String get lightboxCopyLinkTooltip => 'Link kopieren'; + + @override + String get lightboxVideoCurrentPosition => 'Aktuelle Position'; + + @override + String get lightboxVideoDuration => 'Videolänge'; + + @override + String get loginPageTitle => 'Anmelden'; + + @override + String get loginFormSubmitLabel => 'Anmelden'; + + @override + String get loginMethodDivider => 'ODER'; + + @override + String signInWithFoo(String method) { + return 'Anmelden mit $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Account hinzufügen'; + + @override + String get loginServerUrlLabel => 'Deine Zulip Server URL'; + + @override + String get loginHidePassword => 'Passwort verstecken'; + + @override + String get loginEmailLabel => 'E-Mail-Adresse'; + + @override + String get loginErrorMissingEmail => 'Bitte gib deine E-Mail ein.'; + + @override + String get loginPasswordLabel => 'Passwort'; + + @override + String get loginErrorMissingPassword => 'Bitte gib dein Passwort ein.'; + + @override + String get loginUsernameLabel => 'Benutzername'; + + @override + String get loginErrorMissingUsername => 'Bitte gib deinen Benutzernamen ein.'; + + @override + String get topicValidationErrorTooLong => + 'Länge des Themas sollte 60 Zeichen nicht überschreiten.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Themen sind in dieser Organisation erforderlich.'; + + @override + String get errorContentNotInsertedTitle => 'Inhalt nicht eingefügt'; + + @override + String get errorContentToInsertIsEmpty => + 'Die einzufügende Datei ist leer oder kann nicht geöffnet werden.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url nutzt Zulip Server $zulipVersion, welche nicht unterstützt wird. Die unterstützte Mindestversion ist Zulip Server $minSupportedZulipVersion.'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return 'Dein Account bei $url konnte nicht authentifiziert werden. Bitte wiederhole die Anmeldung oder verwende einen anderen Account.'; + } + + @override + String get errorInvalidResponse => + 'Der Server hat eine ungültige Antwort gesendet.'; + + @override + String get errorNetworkRequestFailed => 'Netzwerkanfrage fehlgeschlagen'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Server lieferte fehlerhafte Antwort; HTTP Status $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Server lieferte fehlerhafte Antwort; HTTP Status $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Netzwerkanfrage fehlgeschlagen: HTTP Status $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => + 'Video konnte nicht wiedergegeben werden.'; + + @override + String get serverUrlValidationErrorEmpty => 'Bitte gib eine URL ein.'; + + @override + String get serverUrlValidationErrorInvalidUrl => + 'Bitte gib eine gültige URL ein.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Bitte gib die Server-URL ein, nicht deine E-Mail-Adresse.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'Die Server-URL muss mit http:// oder https:// beginnen.'; + + @override + String get spoilerDefaultHeaderText => 'Spoiler'; + + @override + String get markAllAsReadLabel => 'Alle Nachrichten als gelesen markieren'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num Nachrichten', + one: 'Eine Nachricht', + ); + return '$_temp0 als gelesen markiert.'; + } + + @override + String get markAsReadInProgress => 'Nachrichten werden als gelesen markiert…'; + + @override + String get errorMarkAsReadFailedTitle => + 'Als gelesen markieren fehlgeschlagen'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num Nachrichten', + one: 'Eine Nachricht', + ); + return '$_temp0 als ungelesen markiert.'; + } + + @override + String get markAsUnreadInProgress => + 'Nachrichten werden als ungelesen markiert…'; + + @override + String get errorMarkAsUnreadFailedTitle => + 'Als ungelesen markieren fehlgeschlagen'; + + @override + String get today => 'Heute'; + + @override + String get yesterday => 'Gestern'; + + @override + String get userActiveNow => 'Gerade aktiv'; + + @override + String get userIdle => 'Untätig'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes Minuten', + one: 'einer Minute', + ); + return 'Aktiv vor $_temp0'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours Stunden', + one: 'einer Stunde', + ); + return 'Aktiv vor $_temp0'; + } + + @override + String get userActiveYesterday => 'Gestern aktiv'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days Tagen', + one: 'einem Tag', + ); + return 'Aktiv vor $_temp0'; + } + + @override + String userActiveDate(String date) { + return 'Aktiv $date'; + } + + @override + String get userNotActiveInYear => 'Im letzten Jahr nicht aktiv'; + + @override + String get invisibleMode => 'Unsichtbarer Modus'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Fehler beim Einschalten des unsichtbaren Modus. Bitte versuche es erneut.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Fehler beim Ausschalten des unsichtbaren Modus. Bitte versuche es erneut.'; + + @override + String get userRoleOwner => 'Besitzer'; + + @override + String get userRoleAdministrator => 'Administrator'; + + @override + String get userRoleModerator => 'Moderator'; + + @override + String get userRoleMember => 'Mitglied'; + + @override + String get userRoleGuest => 'Gast'; + + @override + String get userRoleUnknown => 'Unbekannt'; + + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Status setzen'; + + @override + String get noStatusText => 'Kein Statustext'; + + @override + String get setStatusPageTitle => 'Status setzen'; + + @override + String get statusClearButtonLabel => 'Leeren'; + + @override + String get statusSaveButtonLabel => 'Speichern'; + + @override + String get statusTextHint => 'Dein Status'; + + @override + String get userStatusBusy => 'Beschäftigt'; + + @override + String get userStatusInAMeeting => 'In einem Meeting'; + + @override + String get userStatusCommuting => 'Unterwegs'; + + @override + String get userStatusOutSick => 'Krankgemeldet'; + + @override + String get userStatusVacationing => 'Im Urlaub'; + + @override + String get userStatusWorkingRemotely => 'Arbeitet von zu Hause'; + + @override + String get userStatusAtTheOffice => 'Im Büro'; + + @override + String get updateStatusErrorTitle => + 'Fehler beim Update des Benutzerstatus. Bitte versuche es nochmal.'; + + @override + String get searchMessagesPageTitle => 'Suche'; + + @override + String get searchMessagesHintText => 'Suche'; + + @override + String get searchMessagesClearButtonTooltip => 'Leeren'; + + @override + String get inboxPageTitle => 'Eingang'; + + @override + String get inboxEmptyPlaceholder => + 'Es sind keine ungelesenen Nachrichten in deinem Eingang. Verwende die Buttons unten, um den kombinierten Feed oder die Kanalliste anzusehen.'; + + @override + String get recentDmConversationsPageTitle => 'Direktnachrichten'; + + @override + String get recentDmConversationsSectionHeader => 'Direktnachrichten'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'Du hast noch keine Direktnachrichten! Warum nicht die Unterhaltung beginnen?'; + + @override + String get combinedFeedPageTitle => 'Kombinierter Feed'; + + @override + String get mentionsPageTitle => 'Erwähnungen'; + + @override + String get starredMessagesPageTitle => 'Markierte Nachrichten'; + + @override + String get channelsPageTitle => 'Kanäle'; + + @override + String get channelsEmptyPlaceholder => 'Du hast noch keine Kanäle abonniert.'; + + @override + String get sharePageTitle => 'Teilen'; + + @override + String get mainMenuMyProfile => 'Mein Profil'; + + @override + String get topicsButtonTooltip => 'Themen'; + + @override + String get channelFeedButtonTooltip => 'Kanal-Feed'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers weitere', + one: '1 weitere:n', + ); + return '$senderFullName an dich und $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Angeheftet'; + + @override + String get unpinnedSubscriptionsLabel => 'Nicht angeheftet'; + + @override + String get notifSelfUser => 'Du'; + + @override + String get reactedEmojiSelfUser => 'Du'; + + @override + String get reactionChipsLabel => 'Reaktionen'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } + + @override + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: 'Du und $otherUsersCount weitere', + one: 'Du und ein weiterer', + ); + return '$_temp0'; + } + + @override + String onePersonTyping(String typist) { + return '$typist tippt…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist und $otherTypist tippen…'; + } + + @override + String get manyPeopleTyping => 'Mehrere Leute tippen…'; + + @override + String get wildcardMentionAll => 'alle'; + + @override + String get wildcardMentionEveryone => 'jeder'; + + @override + String get wildcardMentionChannel => 'Kanal'; + + @override + String get wildcardMentionStream => 'Stream'; + + @override + String get wildcardMentionTopic => 'Thema'; + + @override + String get wildcardMentionChannelDescription => 'Kanal benachrichtigen'; + + @override + String get wildcardMentionStreamDescription => 'Stream benachrichtigen'; + + @override + String get wildcardMentionAllDmDescription => 'Empfänger benachrichtigen'; + + @override + String get wildcardMentionTopicDescription => 'Thema benachrichtigen'; + + @override + String get messageIsEditedLabel => 'BEARBEITET'; + + @override + String get messageIsMovedLabel => 'VERSCHOBEN'; + + @override + String get messageNotSentLabel => 'NACHRICHT NICHT GESENDET'; + + @override + String pollVoterNames(String voterNames) { + return '$voterNames'; + } + + @override + String get themeSettingTitle => 'THEMA'; + + @override + String get themeSettingDark => 'Dunkel'; + + @override + String get themeSettingLight => 'Hell'; + + @override + String get themeSettingSystem => 'System'; + + @override + String get openLinksWithInAppBrowser => 'Links mit In-App-Browser öffnen'; + + @override + String get pollWidgetQuestionMissing => 'Keine Frage.'; + + @override + String get pollWidgetOptionsMissing => + 'Diese Umfrage hat noch keine Optionen.'; + + @override + String get initialAnchorSettingTitle => 'Nachrichten-Feed öffnen bei'; + + @override + String get initialAnchorSettingDescription => + 'Du kannst auswählen ob Nachrichten-Feeds bei deiner ersten ungelesenen oder bei den neuesten Nachrichten geöffnet werden.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => + 'Erste ungelesene Nachricht'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'Erste ungelesene Nachricht in Unterhaltungsansicht, sonst neueste Nachricht'; + + @override + String get initialAnchorSettingNewestAlways => 'Neueste Nachricht'; + + @override + String get markReadOnScrollSettingTitle => + 'Nachrichten beim Scrollen als gelesen markieren'; + + @override + String get markReadOnScrollSettingDescription => + 'Sollen Nachrichten automatisch als gelesen markiert werden, wenn du sie durchscrollst?'; + + @override + String get markReadOnScrollSettingAlways => 'Immer'; + + @override + String get markReadOnScrollSettingNever => 'Nie'; + + @override + String get markReadOnScrollSettingConversations => + 'Nur in Unterhaltungsansichten'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Nachrichten werden nur beim Ansehen einzelner Themen oder Direktnachrichten automatisch als gelesen markiert.'; + + @override + String get experimentalFeatureSettingsPageTitle => + 'Experimentelle Funktionen'; + + @override + String get experimentalFeatureSettingsWarning => + 'Diese Optionen aktivieren Funktionen, die noch in Entwicklung und nicht bereit sind. Sie funktionieren möglicherweise nicht und können Problem in anderen Bereichen der App verursachen.\n\nDer Zweck dieser Einstellungen ist das Experimentieren der Leute, die an der Entwicklung von Zulip arbeiten.'; + + @override + String get errorNotificationOpenTitle => + 'Fehler beim Öffnen der Benachrichtigung'; + + @override + String get errorNotificationOpenAccountNotFound => + 'Der Account, der mit dieser Benachrichtigung verknüpft ist, konnte nicht gefunden werden.'; + + @override + String get errorReactionAddingFailedTitle => + 'Hinzufügen der Reaktion fehlgeschlagen'; + + @override + String get errorReactionRemovingFailedTitle => + 'Entfernen der Reaktion fehlgeschlagen'; + + @override + String get errorSharingTitle => 'Teilen des Inhalts fehlgeschlagen'; + + @override + String get errorSharingAccountNotLoggedIn => + 'Es ist kein Konto angemeldet. Bitte logge dich in ein Konto ein und versuche es erneut.'; + + @override + String get emojiReactionsMore => 'mehr'; + + @override + String get emojiPickerSearchEmoji => 'Emoji suchen'; + + @override + String get noEarlierMessages => 'Keine früheren Nachrichten'; + + @override + String get revealButtonLabel => 'Nachricht anzeigen'; + + @override + String get mutedUser => 'Stummgeschaltete:r Nutzer:in'; + + @override + String get scrollToBottomTooltip => 'Nach unten Scrollen'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; +} diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 9467d33428..2e94965a87 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -20,9 +20,26 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get aboutPageTapToView => 'Tap to view'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Choose account'; + @override + String get settingsPageTitle => 'Settings'; + @override String get switchAccountButton => 'Switch account'; @@ -41,7 +58,8 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get logOutConfirmationDialogTitle => 'Log out?'; @override - String get logOutConfirmationDialogMessage => 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; + String get logOutConfirmationDialogMessage => + 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; @override String get logOutConfirmationDialogConfirmButton => 'Log out'; @@ -62,10 +80,48 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get permissionsNeededOpenSettings => 'Open settings'; @override - String get permissionsDeniedCameraAccess => 'To upload an image, please grant Zulip additional permissions in Settings.'; + String get permissionsDeniedCameraAccess => + 'To upload an image, please grant Zulip additional permissions in Settings.'; @override - String get permissionsDeniedReadExternalStorage => 'To upload files, please grant Zulip additional permissions in Settings.'; + String get permissionsDeniedReadExternalStorage => + 'To upload files, please grant Zulip additional permissions in Settings.'; + + @override + String get actionSheetOptionSubscribe => 'Subscribe'; + + @override + String get subscribeFailedTitle => 'Failed to subscribe'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + + @override + String get actionSheetOptionCopyChannelLink => 'Copy link to channel'; + + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + + @override + String get actionSheetOptionUnsubscribe => 'Unsubscribe'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return 'Unsubscribe from $channelName?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + 'Once you leave this channel, you might not be able to rejoin.'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe'; + + @override + String get unsubscribeFailedTitle => 'Failed to unsubscribe'; @override String get actionSheetOptionMuteTopic => 'Mute topic'; @@ -79,6 +135,71 @@ 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 actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num total)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; @@ -88,11 +209,14 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; @@ -100,6 +224,15 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; @@ -115,7 +248,8 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String get errorCouldNotFetchMessageSource => 'Could not fetch message source'; + String get errorCouldNotFetchMessageSource => + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -126,7 +260,16 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, @@ -156,16 +299,20 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorMessageNotSent => 'Message not sent'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Failed to connect to server:\n$url'; } @override - String get errorLoginCouldNotConnectTitle => 'Could not connect'; + String get errorCouldNotConnectTitle => 'Could not connect'; @override - String get errorMessageDoesNotSeemToExist => 'That message does not seem to exist.'; + String get errorMessageDoesNotSeemToExist => + 'That message does not seem to exist.'; @override String get errorQuotationFailed => 'Quotation failed'; @@ -176,7 +323,8 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String get errorConnectingToServerShort => 'Error connecting to Zulip. Retrying…'; + String get errorConnectingToServerShort => + 'Error connecting to Zulip. Retrying…'; @override String errorConnectingToServerDetails(String serverUrl, String error) { @@ -184,10 +332,15 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String get errorHandlingEventTitle => 'Error handling a Zulip event. Retrying connection…'; + String get errorHandlingEventTitle => + 'Error handling a Zulip event. Retrying connection…'; @override - String errorHandlingEventDetails(String serverUrl, String error, String event) { + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; } @@ -220,6 +373,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -230,10 +386,55 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get successMessageLinkCopied => 'Message link copied'; @override - String get errorBannerDeactivatedDmLabel => 'You cannot send messages to deactivated users.'; + String get successTopicLinkCopied => 'Topic link copied'; + + @override + String get successChannelLinkCopied => 'Channel link copied'; + + @override + String get errorBannerDeactivatedDmLabel => + 'You cannot send messages to deactivated users.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'You do not have permission to post in this channel.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -247,6 +448,24 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetComposeButtonLabel => 'Compose'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -259,10 +478,13 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get composeBoxSelfDmContentHint => 'Jot down something'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Message #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; @@ -272,6 +494,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; @@ -299,19 +526,28 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String get messageListGroupYouWithYourself => 'You with yourself'; + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + + @override + String get messageListGroupYouWithYourself => 'Messages with yourself'; @override - String get contentValidationErrorTooLong => 'Message length shouldn\'t be greater than 10000 characters.'; + String get contentValidationErrorTooLong => + 'Message length shouldn\'t be greater than 10000 characters.'; @override String get contentValidationErrorEmpty => 'You have nothing to send!'; @override - String get contentValidationErrorQuoteAndReplyInProgress => 'Please wait for the quotation to complete.'; + String get contentValidationErrorQuoteAndReplyInProgress => + 'Please wait for the quotation to complete.'; @override - String get contentValidationErrorUploadInProgress => 'Please wait for the upload to complete.'; + String get contentValidationErrorUploadInProgress => + 'Please wait for the upload to complete.'; @override String get dialogCancel => 'Cancel'; @@ -322,6 +558,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get dialogClose => 'Close'; + @override + String get errorDialogLearnMore => 'Learn more'; + @override String get errorDialogContinue => 'OK'; @@ -358,7 +597,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'; @@ -382,13 +621,36 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get loginErrorMissingUsername => 'Please enter your username.'; @override - String get topicValidationErrorTooLong => 'Topic length shouldn\'t be greater than 60 characters.'; + String get topicValidationErrorTooLong => + 'Topic length shouldn\'t be greater than 60 characters.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Topics are required in this organization.'; @override - String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + String get errorContentNotInsertedTitle => 'Content not inserted'; @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorContentToInsertIsEmpty => + 'The file to be inserted is empty or cannot be accessed.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } + + @override + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -409,7 +671,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; @@ -418,10 +680,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get serverUrlValidationErrorInvalidUrl => 'Please enter a valid URL.'; @override - String get serverUrlValidationErrorNoUseEmail => 'Please enter the server URL, not your email.'; + String get serverUrlValidationErrorNoUseEmail => + 'Please enter the server URL, not your email.'; @override - String get serverUrlValidationErrorUnsupportedScheme => 'The server URL must start with http:// or https://.'; + String get serverUrlValidationErrorUnsupportedScheme => + 'The server URL must start with http:// or https://.'; @override String get spoilerDefaultHeaderText => 'Spoiler'; @@ -469,6 +733,67 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get yesterday => 'Yesterday'; + @override + String get userActiveNow => 'Active now'; + + @override + String get userIdle => 'Idle'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes', + one: '1 minute', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours hours', + one: '1 hour', + ); + return 'Active $_temp0 ago'; + } + + @override + String get userActiveYesterday => 'Active yesterday'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days days', + one: '1 day', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveDate(String date) { + return 'Active $date'; + } + + @override + String get userNotActiveInYear => 'Not active in the last year'; + + @override + String get invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + @override String get userRoleOwner => 'Owner'; @@ -487,15 +812,78 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -508,9 +896,19 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + + @override + String get sharePageTitle => 'Share'; + @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonTooltip => 'Topics'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -531,15 +929,31 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; @override String get reactedEmojiSelfUser => 'You'; + @override + String get reactionChipsLabel => 'Reactions'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } + + @override + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: 'You and $otherUsersCount others', + one: 'You and 1 other', + ); + return '$_temp0'; + } + @override String onePersonTyping(String typist) { return '$typist is typing…'; @@ -586,22 +1000,86 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; } + @override + String get themeSettingTitle => 'THEME'; + + @override + String get themeSettingDark => 'Dark'; + + @override + String get themeSettingLight => 'Light'; + + @override + String get themeSettingSystem => 'System'; + + @override + String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override String get pollWidgetQuestionMissing => 'No question.'; @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in conversation views, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Experimental features'; + + @override + String get experimentalFeatureSettingsWarning => + 'These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.'; + @override String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; @@ -609,6 +1087,13 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; @@ -618,6 +1103,27 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get revealButtonLabel => 'Reveal message'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; +} + +/// The translations for English, as used in the United Kingdom (`en_GB`). +class ZulipLocalizationsEnGb extends ZulipLocalizationsEn { + ZulipLocalizationsEnGb() : super('en_GB'); + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Topics are required in this organisation.'; } diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart new file mode 100644 index 0000000000..3766c7051e --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -0,0 +1,1134 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for French (`fr`). +class ZulipLocalizationsFr extends ZulipLocalizations { + ZulipLocalizationsFr([String locale = 'fr']) : super(locale); + + @override + String get aboutPageTitle => 'À propos de Zulip'; + + @override + String get aboutPageAppVersion => 'Version de l\'application'; + + @override + String get aboutPageOpenSourceLicenses => 'Licences de logiciel libre'; + + @override + String get aboutPageTapToView => 'Toucher pour voir'; + + @override + String get upgradeWelcomeDialogTitle => + 'Bienvenue dans la nouvelle application Zulip !'; + + @override + String get upgradeWelcomeDialogMessage => + 'Vous retrouverez une expérience familière dans un logiciel plus rapide et plus élégant.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Allez voir les articles sur le blog des annonces !'; + + @override + String get upgradeWelcomeDialogDismiss => 'Allons-y'; + + @override + String get chooseAccountPageTitle => 'Choisir un compte'; + + @override + String get settingsPageTitle => 'Paramètres'; + + @override + String get switchAccountButton => 'Changer de compte'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Votre compte à $url prend du temps à se charger.'; + } + + @override + String get tryAnotherAccountButton => 'Essayer un autre compte'; + + @override + String get chooseAccountPageLogOutButton => 'Déconnexion'; + + @override + String get logOutConfirmationDialogTitle => 'Se déconnecter?'; + + @override + String get logOutConfirmationDialogMessage => + 'Pour utiliser ce compte à l\'avenir, vous devrez ré-entrer l\'adresse pour votre organisation et les informations de votre compte.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Déconnexion'; + + @override + String get chooseAccountButtonAddAnAccount => 'Ajouter un compte'; + + @override + String get profileButtonSendDirectMessage => 'Envoyer un message direct'; + + @override + String get errorCouldNotShowUserProfile => + 'Impossible de montrer le profil de l\'utilisateur.'; + + @override + String get permissionsNeededTitle => 'Permissions requises'; + + @override + String get permissionsNeededOpenSettings => 'Ouvrir les préférences'; + + @override + String get permissionsDeniedCameraAccess => + 'Pour charger une image, merci d\'accorder des autorisations supplémentaires à Zulip, dans les préférences.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'Pour charger des fichiers, merci d\'accorder des autorisations supplémentaires à Zulip, dans les préférences.'; + + @override + String get actionSheetOptionSubscribe => 'Subscribe'; + + @override + String get subscribeFailedTitle => 'Failed to subscribe'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Marquer le canal comme lu'; + + @override + String get actionSheetOptionCopyChannelLink => 'Copier le lien du canal'; + + @override + String get actionSheetOptionListOfTopics => 'Liste des sujets'; + + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + + @override + String get actionSheetOptionUnsubscribe => 'Unsubscribe'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return 'Unsubscribe from $channelName?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + 'Once you leave this channel, you might not be able to rejoin.'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe'; + + @override + String get unsubscribeFailedTitle => 'Failed to unsubscribe'; + + @override + String get actionSheetOptionMuteTopic => 'Rendre le sujet silencieux'; + + @override + String get actionSheetOptionUnmuteTopic => 'Rendre le sujet non silencieux'; + + @override + String get actionSheetOptionFollowTopic => 'Suivre le sujet'; + + @override + String get actionSheetOptionUnfollowTopic => 'Ne plus suivre le sujet'; + + @override + String get actionSheetOptionResolveTopic => 'Marquer comme résolu'; + + @override + String get actionSheetOptionUnresolveTopic => 'Marquer comme non résolu'; + + @override + String get errorResolveTopicFailedTitle => + 'Impossible de marquer le sujet comme résolu'; + + @override + String get errorUnresolveTopicFailedTitle => + 'Impossible de marquer le sujet comme non résolu'; + + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num total)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + + @override + String get actionSheetOptionCopyMessageText => 'Copier le contenu du message'; + + @override + String get actionSheetOptionCopyMessageLink => 'Copier le lien au message'; + + @override + String get actionSheetOptionMarkAsUnread => 'Marquer non lu à partir d\'ici'; + + @override + String get actionSheetOptionHideMutedMessage => + 'Cacher à nouveau le message silencieux'; + + @override + String get actionSheetOptionShare => 'Partager'; + + @override + String get actionSheetOptionQuoteMessage => 'Citer le message'; + + @override + String get actionSheetOptionStarMessage => 'Mettre le message en favori'; + + @override + String get actionSheetOptionUnstarMessage => + 'Retirer ce message de la liste des favoris'; + + @override + String get actionSheetOptionEditMessage => 'Modifier le message'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Marquer le sujet comme lu'; + + @override + String get actionSheetOptionCopyTopicLink => 'Copier le lien sur le sujet'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Une erreur s\'est produite'; + + @override + String get errorWebAuthOperationalError => + 'Oups, une erreur s\'est produite.'; + + @override + String get errorAccountLoggedInTitle => + 'Vous êtes déjà connecté à ce compte.'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'Le compte $email at $server figure déjà dans votre liste de comptes.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Impossible d\'atteindre le message source.'; + + @override + String get errorCopyingFailed => 'Échec de la copie'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return 'Impossible de charger le fichier $filename'; + } + + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename : $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num fichiers sont', + one: 'Fichier est', + ); + return '$_temp0 plus gros que la limite de capacité du serveur ($maxFileUploadSizeMib MO) et ne peu(ven)t pas être chargé(s) :\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'Les fichier sont trop lourds', + one: 'Le fichier est trop lourd', + ); + return '$_temp0'; + } + + @override + String get errorLoginInvalidInputTitle => 'Identifiant incorrect'; + + @override + String get errorLoginFailedTitle => 'La connexion a échoué.'; + + @override + String get errorMessageNotSent => 'Le message n\'a pas pu être envoyé.'; + + @override + String get errorMessageEditNotSaved => + 'Le message n\'a pas pu être sauvegardé.'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'La connexion au serveur a échoué :\n$url'; + } + + @override + String get errorCouldNotConnectTitle => + 'Impossible de se connecter au serveur'; + + @override + String get errorMessageDoesNotSeemToExist => 'Ce message est introuvable.'; + + @override + String get errorQuotationFailed => 'Échec de la citation'; + + @override + String errorServerMessage(String message) { + return 'Message d\'erreur du serveur :\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Une erreur s\'est produite lors de la connexion au serveur. Nouvelle tentative en cours…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Une erreur s\'est produite lors de la connexion à Zulip sur $serverUrl. Nouvelle tentative imminente :\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Une erreur s\'est produite sur le serveur. Reconnexion en cours…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Une erreur s\'est produite sur le serveur $serverUrl ; tentative de reconnexion imminente.\n\nErreur : $error\n\nÉvénement : $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Impossible d\'ouvrir le lien'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Le lien suivant n\'a pas pu être ouvert : $url'; + } + + @override + String get errorMuteTopicFailed => + 'Le sujet n\'a pas pu être rendu silencieux'; + + @override + String get errorUnmuteTopicFailed => + 'Impossible de ne plus mettre le sujet en sourdine'; + + @override + String get errorFollowTopicFailed => 'Échec du suivi du sujet'; + + @override + String get errorUnfollowTopicFailed => + 'Échec de la tentative de ne plus suivre le sujet'; + + @override + String get errorSharingFailed => 'Échec du partage'; + + @override + String get errorStarMessageFailedTitle => + 'Échec de marquage du message en favori'; + + @override + String get errorUnstarMessageFailedTitle => + 'Échec de la tentative d\'enlever le message des favoris'; + + @override + String get errorCouldNotEditMessageTitle => + 'Le message n\'a pas pu être modifié'; + + @override + String get successLinkCopied => 'Lien copié'; + + @override + String get successMessageTextCopied => 'Texte du message copié'; + + @override + String get successMessageLinkCopied => 'Lien sur le message copié'; + + @override + String get successTopicLinkCopied => 'Lien sur le sujet copié'; + + @override + String get successChannelLinkCopied => 'Lien sur le canal copié'; + + @override + String get errorBannerDeactivatedDmLabel => + 'Vous ne pouvez pas envoyer de messages aux utilisateurs désactivés.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'Vous n\'avez pas l\'autorisation de poster sur ce canal.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Editer le message'; + + @override + String get composeBoxBannerButtonCancel => 'Annuler'; + + @override + String get composeBoxBannerButtonSave => 'Sauvegarder'; + + @override + String get editAlreadyInProgressTitle => 'Impossible de modifier le message'; + + @override + String get editAlreadyInProgressMessage => + 'Une modification est déjà en cours. Merci d\'attendre qu\'elle soit terminée.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + + @override + String get composeBoxAttachFilesTooltip => 'Attach files'; + + @override + String get composeBoxAttachMediaTooltip => 'Attach images or videos'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + + @override + String get composeBoxGenericContentHint => 'Type a message'; + + @override + String get newDmSheetComposeButtonLabel => 'Compose'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + + @override + String composeBoxDmContentHint(String user) { + return 'Message @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Message group'; + + @override + String get composeBoxSelfDmContentHint => 'Jot down something'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Preparing…'; + + @override + String get composeBoxSendTooltip => 'Send'; + + @override + String get unknownChannelName => '(unknown channel)'; + + @override + String get composeBoxTopicHintText => 'Topic'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Uploading $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(loading message $messageId)'; + } + + @override + String get unknownUserName => '(unknown user)'; + + @override + String get dmsWithYourselfPageTitle => 'DMs with yourself'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'You and $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'DMs with $others'; + } + + @override + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + + @override + String get messageListGroupYouWithYourself => 'Messages with yourself'; + + @override + String get contentValidationErrorTooLong => + 'Message length shouldn\'t be greater than 10000 characters.'; + + @override + String get contentValidationErrorEmpty => 'You have nothing to send!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Please wait for the quotation to complete.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Please wait for the upload to complete.'; + + @override + String get dialogCancel => 'Cancel'; + + @override + String get dialogContinue => 'Continue'; + + @override + String get dialogClose => 'Close'; + + @override + String get errorDialogLearnMore => 'Learn more'; + + @override + String get errorDialogContinue => 'OK'; + + @override + String get errorDialogTitle => 'Error'; + + @override + String get snackBarDetails => 'Details'; + + @override + String get lightboxCopyLinkTooltip => 'Copy link'; + + @override + String get lightboxVideoCurrentPosition => 'Current position'; + + @override + String get lightboxVideoDuration => 'Video duration'; + + @override + String get loginPageTitle => 'Log in'; + + @override + String get loginFormSubmitLabel => 'Log in'; + + @override + String get loginMethodDivider => 'OR'; + + @override + String signInWithFoo(String method) { + return 'Sign in with $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Add an account'; + + @override + String get loginServerUrlLabel => 'Your Zulip server URL'; + + @override + String get loginHidePassword => 'Hide password'; + + @override + String get loginEmailLabel => 'Email address'; + + @override + String get loginErrorMissingEmail => 'Please enter your email.'; + + @override + String get loginPasswordLabel => 'Password'; + + @override + String get loginErrorMissingPassword => 'Please enter your password.'; + + @override + String get loginUsernameLabel => 'Username'; + + @override + String get loginErrorMissingUsername => 'Please enter your username.'; + + @override + String get topicValidationErrorTooLong => + 'Topic length shouldn\'t be greater than 60 characters.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Topics are required in this organization.'; + + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => + 'The file to be inserted is empty or cannot be accessed.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } + + @override + String get errorInvalidResponse => 'The server sent an invalid response.'; + + @override + String get errorNetworkRequestFailed => 'Network request failed'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Server gave malformed response; HTTP status $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Server gave malformed response; HTTP status $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Network request failed: HTTP status $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => 'Unable to play the video.'; + + @override + String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; + + @override + String get serverUrlValidationErrorInvalidUrl => 'Please enter a valid URL.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Please enter the server URL, not your email.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'The server URL must start with http:// or https://.'; + + @override + String get spoilerDefaultHeaderText => 'Spoiler'; + + @override + String get markAllAsReadLabel => 'Mark all messages as read'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messages', + one: '1 message', + ); + return 'Marked $_temp0 as read.'; + } + + @override + String get markAsReadInProgress => 'Marking messages as read…'; + + @override + String get errorMarkAsReadFailedTitle => 'Mark as read failed'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messages', + one: '1 message', + ); + return 'Marked $_temp0 as unread.'; + } + + @override + String get markAsUnreadInProgress => 'Marking messages as unread…'; + + @override + String get errorMarkAsUnreadFailedTitle => 'Mark as unread failed'; + + @override + String get today => 'Today'; + + @override + String get yesterday => 'Yesterday'; + + @override + String get userActiveNow => 'Active now'; + + @override + String get userIdle => 'Idle'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes', + one: '1 minute', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours hours', + one: '1 hour', + ); + return 'Active $_temp0 ago'; + } + + @override + String get userActiveYesterday => 'Active yesterday'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days days', + one: '1 day', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveDate(String date) { + return 'Active $date'; + } + + @override + String get userNotActiveInYear => 'Not active in the last year'; + + @override + String get invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + + @override + String get userRoleOwner => 'Owner'; + + @override + String get userRoleAdministrator => 'Administrator'; + + @override + String get userRoleModerator => 'Moderator'; + + @override + String get userRoleMember => 'Member'; + + @override + String get userRoleGuest => 'Guest'; + + @override + String get userRoleUnknown => 'Unknown'; + + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + + @override + String get inboxPageTitle => 'Inbox'; + + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + + @override + String get recentDmConversationsPageTitle => 'Direct messages'; + + @override + String get recentDmConversationsSectionHeader => 'Direct messages'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + + @override + String get combinedFeedPageTitle => 'Combined feed'; + + @override + String get mentionsPageTitle => 'Mentions'; + + @override + String get starredMessagesPageTitle => 'Starred messages'; + + @override + String get channelsPageTitle => 'Channels'; + + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + + @override + String get sharePageTitle => 'Share'; + + @override + String get mainMenuMyProfile => 'My profile'; + + @override + String get topicsButtonTooltip => 'Topics'; + + @override + String get channelFeedButtonTooltip => 'Channel feed'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers others', + one: '1 other', + ); + return '$senderFullName to you and $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Pinned'; + + @override + String get unpinnedSubscriptionsLabel => 'Unpinned'; + + @override + String get notifSelfUser => 'You'; + + @override + String get reactedEmojiSelfUser => 'You'; + + @override + String get reactionChipsLabel => 'Reactions'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } + + @override + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: 'You and $otherUsersCount others', + one: 'You and 1 other', + ); + return '$_temp0'; + } + + @override + String onePersonTyping(String typist) { + return '$typist is typing…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist and $otherTypist are typing…'; + } + + @override + String get manyPeopleTyping => 'Several people are typing…'; + + @override + String get wildcardMentionAll => 'all'; + + @override + String get wildcardMentionEveryone => 'everyone'; + + @override + String get wildcardMentionChannel => 'channel'; + + @override + String get wildcardMentionStream => 'stream'; + + @override + String get wildcardMentionTopic => 'topic'; + + @override + String get wildcardMentionChannelDescription => 'Notify channel'; + + @override + String get wildcardMentionStreamDescription => 'Notify stream'; + + @override + String get wildcardMentionAllDmDescription => 'Notify recipients'; + + @override + String get wildcardMentionTopicDescription => 'Notify topic'; + + @override + String get messageIsEditedLabel => 'EDITED'; + + @override + String get messageIsMovedLabel => 'MOVED'; + + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => 'THEME'; + + @override + String get themeSettingDark => 'Dark'; + + @override + String get themeSettingLight => 'Light'; + + @override + String get themeSettingSystem => 'System'; + + @override + String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + + @override + String get pollWidgetQuestionMissing => 'No question.'; + + @override + String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in conversation views, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Experimental features'; + + @override + String get experimentalFeatureSettingsWarning => + 'These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.'; + + @override + String get errorNotificationOpenTitle => 'Failed to open notification'; + + @override + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; + + @override + String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + + @override + String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + + @override + String get emojiReactionsMore => 'more'; + + @override + String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get noEarlierMessages => 'No earlier messages'; + + @override + String get revealButtonLabel => 'Reveal message'; + + @override + String get mutedUser => 'Muted user'; + + @override + String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; +} diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart new file mode 100644 index 0000000000..028f8680fb --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -0,0 +1,1142 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Italian (`it`). +class ZulipLocalizationsIt extends ZulipLocalizations { + ZulipLocalizationsIt([String locale = 'it']) : super(locale); + + @override + String get aboutPageTitle => 'Su Zulip'; + + @override + String get aboutPageAppVersion => 'Versione app'; + + @override + String get aboutPageOpenSourceLicenses => 'Licenze open-source'; + + @override + String get aboutPageTapToView => 'Tap per visualizzare'; + + @override + String get upgradeWelcomeDialogTitle => 'Benvenuti alla nuova app Zulip!'; + + @override + String get upgradeWelcomeDialogMessage => + 'Troverai un\'esperienza familiare in un pacchetto più veloce ed elegante.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Date un\'occhiata al post dell\'annuncio sul blog!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Andiamo'; + + @override + String get chooseAccountPageTitle => 'Scegli account'; + + @override + String get settingsPageTitle => 'Impostazioni'; + + @override + String get switchAccountButton => 'Cambia account'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Il caricamento dell\'account su $url sta richiedendo un po\' di tempo.'; + } + + @override + String get tryAnotherAccountButton => 'Prova un altro account'; + + @override + String get chooseAccountPageLogOutButton => 'Esci'; + + @override + String get logOutConfirmationDialogTitle => 'Disconnettersi?'; + + @override + String get logOutConfirmationDialogMessage => + 'Per utilizzare questo account in futuro, bisognerà reinserire l\'URL della propria organizzazione e le informazioni del proprio account.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Esci'; + + @override + String get chooseAccountButtonAddAnAccount => 'Aggiungi un account'; + + @override + String get profileButtonSendDirectMessage => 'Invia un messaggio diretto'; + + @override + String get errorCouldNotShowUserProfile => + 'Impossibile mostrare il profilo utente.'; + + @override + String get permissionsNeededTitle => 'Permessi necessari'; + + @override + String get permissionsNeededOpenSettings => 'Apri le impostazioni'; + + @override + String get permissionsDeniedCameraAccess => + 'Per caricare un\'immagine, bisogna concedere a Zulip autorizzazioni aggiuntive nelle Impostazioni.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'Per caricare file, bisogna concedere a Zulip autorizzazioni aggiuntive nelle Impostazioni.'; + + @override + String get actionSheetOptionSubscribe => 'Subscribe'; + + @override + String get subscribeFailedTitle => 'Failed to subscribe'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Segna il canale come letto'; + + @override + String get actionSheetOptionCopyChannelLink => 'Copy link to channel'; + + @override + String get actionSheetOptionListOfTopics => 'Elenco degli argomenti'; + + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + + @override + String get actionSheetOptionUnsubscribe => 'Unsubscribe'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return 'Unsubscribe from $channelName?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + 'Once you leave this channel, you might not be able to rejoin.'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe'; + + @override + String get unsubscribeFailedTitle => 'Failed to unsubscribe'; + + @override + String get actionSheetOptionMuteTopic => 'Silenzia argomento'; + + @override + String get actionSheetOptionUnmuteTopic => 'Riattiva argomento'; + + @override + String get actionSheetOptionFollowTopic => 'Segui argomento'; + + @override + String get actionSheetOptionUnfollowTopic => 'Non seguire più l\'argomento'; + + @override + String get actionSheetOptionResolveTopic => 'Segna come risolto'; + + @override + String get actionSheetOptionUnresolveTopic => 'Segna come irrisolto'; + + @override + String get errorResolveTopicFailedTitle => + 'Impossibile contrassegnare l\'argomento come risolto'; + + @override + String get errorUnresolveTopicFailedTitle => + 'Impossibile contrassegnare l\'argomento come irrisolto'; + + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num total)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + + @override + String get actionSheetOptionCopyMessageText => 'Copia il testo del messaggio'; + + @override + String get actionSheetOptionCopyMessageLink => + 'Copia il collegamento al messaggio'; + + @override + String get actionSheetOptionMarkAsUnread => 'Segna come non letto da qui'; + + @override + String get actionSheetOptionHideMutedMessage => + 'Nascondi nuovamente il messaggio disattivato'; + + @override + String get actionSheetOptionShare => 'Condividi'; + + @override + String get actionSheetOptionQuoteMessage => 'Cita messaggio'; + + @override + String get actionSheetOptionStarMessage => 'Messaggio speciale'; + + @override + String get actionSheetOptionUnstarMessage => 'Messaggio normale'; + + @override + String get actionSheetOptionEditMessage => 'Modifica messaggio'; + + @override + String get actionSheetOptionMarkTopicAsRead => + 'Segna l\'argomento come letto'; + + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Qualcosa è andato storto'; + + @override + String get errorWebAuthOperationalError => + 'Si è verificato un errore imprevisto.'; + + @override + String get errorAccountLoggedInTitle => 'Account già registrato'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'L\'account $email su $server è già presente nell\'elenco account.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Impossibile recuperare l\'origine del messaggio.'; + + @override + String get errorCopyingFailed => 'Copia non riuscita'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return 'Impossibile caricare il file: $filename'; + } + + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num file sono', + one: 'file è', + ); + return '$_temp0 più grande/i del limite del server di $maxFileUploadSizeMib MiB e non verrà/anno caricato/i:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'File', + one: 'File', + ); + return '$_temp0 troppo grande/i'; + } + + @override + String get errorLoginInvalidInputTitle => 'Ingresso non valido'; + + @override + String get errorLoginFailedTitle => 'Accesso non riuscito'; + + @override + String get errorMessageNotSent => 'Messaggio non inviato'; + + @override + String get errorMessageEditNotSaved => 'Messaggio non salvato'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'Impossibile connettersi al server:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => 'Impossibile connettersi'; + + @override + String get errorMessageDoesNotSeemToExist => + 'Quel messaggio sembra non esistere.'; + + @override + String get errorQuotationFailed => 'Citazione non riuscita'; + + @override + String errorServerMessage(String message) { + return 'Il server ha detto:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Errore di connessione a Zulip. Nuovo tentativo…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Errore durante la connessione a Zulip su $serverUrl. Verrà effettuato un nuovo tentativo:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Errore nella gestione di un evento Zulip. Nuovo tentativo di connessione…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Errore nella gestione di un evento Zulip da $serverUrl; verrà effettuato un nuovo tentativo.\n\nErrore: $error\n\nEvento: $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Impossibile aprire il collegamento'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Impossibile aprire il collegamento: $url'; + } + + @override + String get errorMuteTopicFailed => 'Impossibile silenziare l\'argomento'; + + @override + String get errorUnmuteTopicFailed => 'Impossibile de-silenziare l\'argomento'; + + @override + String get errorFollowTopicFailed => 'Impossibile seguire l\'argomento'; + + @override + String get errorUnfollowTopicFailed => + 'Impossibile smettere di seguire l\'argomento'; + + @override + String get errorSharingFailed => 'Condivisione fallita'; + + @override + String get errorStarMessageFailedTitle => + 'Impossibile contrassegnare il messaggio come speciale'; + + @override + String get errorUnstarMessageFailedTitle => + 'Impossibile contrassegnare il messaggio come normale'; + + @override + String get errorCouldNotEditMessageTitle => + 'Impossibile modificare il messaggio'; + + @override + String get successLinkCopied => 'Collegamento copiato'; + + @override + String get successMessageTextCopied => 'Testo messaggio copiato'; + + @override + String get successMessageLinkCopied => 'Collegamento messaggio copiato'; + + @override + String get successTopicLinkCopied => 'Topic link copied'; + + @override + String get successChannelLinkCopied => 'Channel link copied'; + + @override + String get errorBannerDeactivatedDmLabel => + 'Non è possibile inviare messaggi agli utenti disattivati.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'Non hai l\'autorizzazione per postare su questo canale.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Modifica messaggio'; + + @override + String get composeBoxBannerButtonCancel => 'Annulla'; + + @override + String get composeBoxBannerButtonSave => 'Salva'; + + @override + String get editAlreadyInProgressTitle => + 'Impossibile modificare il messaggio'; + + @override + String get editAlreadyInProgressMessage => + 'Una modifica è già in corso. Attendere il completamento.'; + + @override + String get savingMessageEditLabel => 'SALVATAGGIO MODIFICA…'; + + @override + String get savingMessageEditFailedLabel => 'MODIFICA NON SALVATA'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Scartare il messaggio che si sta scrivendo?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'Quando si modifica un messaggio, il contenuto precedentemente presente nella casella di composizione viene ignorato.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'Quando si recupera un messaggio non inviato, il contenuto precedentemente presente nella casella di composizione viene ignorato.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Abbandona'; + + @override + String get composeBoxAttachFilesTooltip => 'Allega file'; + + @override + String get composeBoxAttachMediaTooltip => 'Allega immagini o video'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Fai una foto'; + + @override + String get composeBoxGenericContentHint => 'Batti un messaggio'; + + @override + String get newDmSheetComposeButtonLabel => 'Componi'; + + @override + String get newDmSheetScreenTitle => 'Nuovo MD'; + + @override + String get newDmFabButtonLabel => 'Nuovo MD'; + + @override + String get newDmSheetSearchHintEmpty => 'Aggiungi uno o più utenti'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Aggiungi un altro utente…'; + + @override + String get newDmSheetNoUsersFound => 'Nessun utente trovato'; + + @override + String composeBoxDmContentHint(String user) { + return 'Messaggia @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Gruppo di messaggi'; + + @override + String get composeBoxSelfDmContentHint => 'Annota qualcosa'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Messaggia $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Preparazione…'; + + @override + String get composeBoxSendTooltip => 'Invia'; + + @override + String get unknownChannelName => '(canale sconosciuto)'; + + @override + String get composeBoxTopicHintText => 'Argomento'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Inserisci un argomento (salta per \"$defaultTopicName\")'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Caricamento $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(caricamento messaggio $messageId)'; + } + + @override + String get unknownUserName => '(utente sconosciuto)'; + + @override + String get dmsWithYourselfPageTitle => 'MD con te stesso'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'Tu e $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'MD con $others'; + } + + @override + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + + @override + String get messageListGroupYouWithYourself => 'Messaggi con te stesso'; + + @override + String get contentValidationErrorTooLong => + 'La lunghezza del messaggio non deve essere superiore a 10.000 caratteri.'; + + @override + String get contentValidationErrorEmpty => 'Non devi inviare nulla!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Attendere il completamento del commento.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Attendere il completamento del caricamento.'; + + @override + String get dialogCancel => 'Annulla'; + + @override + String get dialogContinue => 'Continua'; + + @override + String get dialogClose => 'Chiudi'; + + @override + String get errorDialogLearnMore => 'Scopri di più'; + + @override + String get errorDialogContinue => 'Ok'; + + @override + String get errorDialogTitle => 'Errore'; + + @override + String get snackBarDetails => 'Dettagli'; + + @override + String get lightboxCopyLinkTooltip => 'Copia collegamento'; + + @override + String get lightboxVideoCurrentPosition => 'Posizione corrente'; + + @override + String get lightboxVideoDuration => 'Durata video'; + + @override + String get loginPageTitle => 'Accesso'; + + @override + String get loginFormSubmitLabel => 'Accesso'; + + @override + String get loginMethodDivider => 'O'; + + @override + String signInWithFoo(String method) { + return 'Accedi con $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Aggiungi account'; + + @override + String get loginServerUrlLabel => 'URL del server Zulip'; + + @override + String get loginHidePassword => 'Nascondi password'; + + @override + String get loginEmailLabel => 'Indirizzo email'; + + @override + String get loginErrorMissingEmail => 'Inserire l\'email.'; + + @override + String get loginPasswordLabel => 'Password'; + + @override + String get loginErrorMissingPassword => 'Inserire la propria password.'; + + @override + String get loginUsernameLabel => 'Nomeutente'; + + @override + String get loginErrorMissingUsername => 'Inserire il proprio nomeutente.'; + + @override + String get topicValidationErrorTooLong => + 'La lunghezza dell\'argomento non deve superare i 60 caratteri.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'In questa organizzazione sono richiesti degli argomenti.'; + + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => + 'The file to be inserted is empty or cannot be accessed.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url sta usando Zulip Server $zulipVersion, che non è supportato. La versione minima supportata è Zulip Server $minSupportedZulipVersion.'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return 'L\'account su $url non è stato autenticato. Riprovare ad accedere o provare a usare un altro account.'; + } + + @override + String get errorInvalidResponse => + 'Il server ha inviato una risposta non valida.'; + + @override + String get errorNetworkRequestFailed => 'Richiesta di rete non riuscita'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Il server ha fornito una risposta non valida; stato HTTP $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Il server ha fornito una risposta non valida; stato HTTP $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Richiesta di rete non riuscita: stato HTTP $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => 'Impossibile riprodurre il video.'; + + @override + String get serverUrlValidationErrorEmpty => 'Inserire un URL.'; + + @override + String get serverUrlValidationErrorInvalidUrl => 'Inserire un URL valido.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Inserire l\'URL del server, non il proprio indirizzo email.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'L\'URL del server deve iniziare con http:// o https://.'; + + @override + String get spoilerDefaultHeaderText => 'Spoiler'; + + @override + String get markAllAsReadLabel => 'Segna tutti i messaggi come letti'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messagei', + one: '1 messaggio', + ); + return 'Segnato/i $_temp0 come letto/i.'; + } + + @override + String get markAsReadInProgress => 'Contrassegno dei messaggi come letti…'; + + @override + String get errorMarkAsReadFailedTitle => + 'Contrassegno come letto non riuscito'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messagi', + one: '1 messaggio', + ); + return 'Segnato/i $_temp0 come non letto/i.'; + } + + @override + String get markAsUnreadInProgress => + 'Contrassegno dei messaggi come non letti…'; + + @override + String get errorMarkAsUnreadFailedTitle => + 'Contrassegno come non letti non riuscito'; + + @override + String get today => 'Oggi'; + + @override + String get yesterday => 'Ieri'; + + @override + String get userActiveNow => 'Active now'; + + @override + String get userIdle => 'Idle'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes', + one: '1 minute', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours hours', + one: '1 hour', + ); + return 'Active $_temp0 ago'; + } + + @override + String get userActiveYesterday => 'Active yesterday'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days days', + one: '1 day', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveDate(String date) { + return 'Active $date'; + } + + @override + String get userNotActiveInYear => 'Not active in the last year'; + + @override + String get invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + + @override + String get userRoleOwner => 'Proprietario'; + + @override + String get userRoleAdministrator => 'Amministratore'; + + @override + String get userRoleModerator => 'Moderatore'; + + @override + String get userRoleMember => 'Membro'; + + @override + String get userRoleGuest => 'Ospite'; + + @override + String get userRoleUnknown => 'Sconosciuto'; + + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + + @override + String get inboxPageTitle => 'Inbox'; + + @override + String get inboxEmptyPlaceholder => + 'Non ci sono messaggi non letti nella posta in arrivo. Usare i pulsanti sotto per visualizzare il feed combinato o l\'elenco dei canali.'; + + @override + String get recentDmConversationsPageTitle => 'Messaggi diretti'; + + @override + String get recentDmConversationsSectionHeader => 'Messaggi diretti'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'Non ci sono ancora messaggi diretti! Perché non iniziare la conversazione?'; + + @override + String get combinedFeedPageTitle => 'Feed combinato'; + + @override + String get mentionsPageTitle => 'Menzioni'; + + @override + String get starredMessagesPageTitle => 'Messaggi speciali'; + + @override + String get channelsPageTitle => 'Canali'; + + @override + String get channelsEmptyPlaceholder => + 'Non sei ancora iscritto ad alcun canale.'; + + @override + String get sharePageTitle => 'Share'; + + @override + String get mainMenuMyProfile => 'Il mio profilo'; + + @override + String get topicsButtonTooltip => 'Argomenti'; + + @override + String get channelFeedButtonTooltip => 'Feed del canale'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers altri', + one: '1 altro', + ); + return '$senderFullName a te e $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Bloccato'; + + @override + String get unpinnedSubscriptionsLabel => 'Non bloccato'; + + @override + String get notifSelfUser => 'Tu'; + + @override + String get reactedEmojiSelfUser => 'Tu'; + + @override + String get reactionChipsLabel => 'Reactions'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } + + @override + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: 'You and $otherUsersCount others', + one: 'You and 1 other', + ); + return '$_temp0'; + } + + @override + String onePersonTyping(String typist) { + return '$typist sta scrivendo…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist e $otherTypist stanno scrivendo…'; + } + + @override + String get manyPeopleTyping => 'Molte persone stanno scrivendo…'; + + @override + String get wildcardMentionAll => 'tutti'; + + @override + String get wildcardMentionEveryone => 'ognuno'; + + @override + String get wildcardMentionChannel => 'canale'; + + @override + String get wildcardMentionStream => 'flusso'; + + @override + String get wildcardMentionTopic => 'argomento'; + + @override + String get wildcardMentionChannelDescription => 'Notifica canale'; + + @override + String get wildcardMentionStreamDescription => 'Notifica flusso'; + + @override + String get wildcardMentionAllDmDescription => 'Notifica destinatari'; + + @override + String get wildcardMentionTopicDescription => 'Notifica argomento'; + + @override + String get messageIsEditedLabel => 'MODIFICATO'; + + @override + String get messageIsMovedLabel => 'SPOSTATO'; + + @override + String get messageNotSentLabel => 'MESSAGGIO NON INVIATO'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => 'TEMA'; + + @override + String get themeSettingDark => 'Scuro'; + + @override + String get themeSettingLight => 'Chiaro'; + + @override + String get themeSettingSystem => 'Sistema'; + + @override + String get openLinksWithInAppBrowser => + 'Apri i collegamenti con il browser in-app'; + + @override + String get pollWidgetQuestionMissing => 'Nessuna domanda.'; + + @override + String get pollWidgetOptionsMissing => + 'Questo sondaggio non ha ancora opzioni.'; + + @override + String get initialAnchorSettingTitle => 'Apri i feed dei messaggi su'; + + @override + String get initialAnchorSettingDescription => + 'È possibile scegliere se i feed dei messaggi devono aprirsi al primo messaggio non letto oppure ai messaggi più recenti.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => + 'Primo messaggio non letto'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'Primo messaggio non letto nelle singole conversazioni, messaggio più recente altrove'; + + @override + String get initialAnchorSettingNewestAlways => 'Messaggio più recente'; + + @override + String get markReadOnScrollSettingTitle => + 'Segna i messaggi come letti durante lo scorrimento'; + + @override + String get markReadOnScrollSettingDescription => + 'Quando si scorrono i messaggi, questi devono essere contrassegnati automaticamente come letti?'; + + @override + String get markReadOnScrollSettingAlways => 'Sempre'; + + @override + String get markReadOnScrollSettingNever => 'Mai'; + + @override + String get markReadOnScrollSettingConversations => + 'Solo nelle visualizzazioni delle conversazioni'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'I messaggi verranno automaticamente contrassegnati come in sola lettura quando si visualizza un singolo argomento o una conversazione in un messaggio diretto.'; + + @override + String get experimentalFeatureSettingsPageTitle => + 'Caratteristiche sperimentali'; + + @override + String get experimentalFeatureSettingsWarning => + 'Queste opzioni abilitano funzionalità ancora in fase di sviluppo e non ancora pronte. Potrebbero non funzionare e causare problemi in altre aree dell\'app.\n\nQueste impostazioni sono pensate per la sperimentazione da parte di chi lavora allo sviluppo di Zulip.'; + + @override + String get errorNotificationOpenTitle => 'Impossibile aprire la notifica'; + + @override + String get errorNotificationOpenAccountNotFound => + 'Impossibile trovare l\'account associato a questa notifica.'; + + @override + String get errorReactionAddingFailedTitle => + 'Aggiunta della reazione non riuscita'; + + @override + String get errorReactionRemovingFailedTitle => + 'Rimozione della reazione non riuscita'; + + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + + @override + String get emojiReactionsMore => 'altro'; + + @override + String get emojiPickerSearchEmoji => 'Cerca emoji'; + + @override + String get noEarlierMessages => 'Nessun messaggio precedente'; + + @override + String get revealButtonLabel => 'Mostra messaggio per mittente silenziato'; + + @override + String get mutedUser => 'Utente silenziato'; + + @override + String get scrollToBottomTooltip => 'Scorri fino in fondo'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; +} diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index f363ee0043..249e42587b 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -9,42 +9,59 @@ class ZulipLocalizationsJa extends ZulipLocalizations { ZulipLocalizationsJa([String locale = 'ja']) : super(locale); @override - String get aboutPageTitle => 'About Zulip'; + String get aboutPageTitle => 'Zulipについて'; @override - String get aboutPageAppVersion => 'App version'; + String get aboutPageAppVersion => 'アプリのバージョン'; @override - String get aboutPageOpenSourceLicenses => 'Open-source licenses'; + String get aboutPageOpenSourceLicenses => 'オープンソースライセンス'; @override - String get aboutPageTapToView => 'Tap to view'; + String get aboutPageTapToView => 'タップして表示'; + + @override + String get upgradeWelcomeDialogTitle => '新しいZulipアプリへようこそ!'; + + @override + String get upgradeWelcomeDialogMessage => + 'より速く、洗練されたデザインで、これまでと同じ使い心地をお楽しみいただけます。'; + + @override + String get upgradeWelcomeDialogLinkText => 'お知らせブログ記事をご確認ください!'; + + @override + String get upgradeWelcomeDialogDismiss => 'はじめよう'; @override String get chooseAccountPageTitle => 'アカウントを選択'; @override - String get switchAccountButton => 'Switch account'; + String get settingsPageTitle => '設定'; + + @override + String get switchAccountButton => 'アカウントを切り替える'; @override String tryAnotherAccountMessage(Object url) { - return 'Your account at $url is taking a while to load.'; + return '$url のアカウントの読み込みに時間がかかっています。'; } @override - String get tryAnotherAccountButton => 'Try another account'; + String get tryAnotherAccountButton => '別のアカウントを試す'; @override - String get chooseAccountPageLogOutButton => 'Log out'; + String get chooseAccountPageLogOutButton => 'ログアウト'; @override - String get logOutConfirmationDialogTitle => 'Log out?'; + String get logOutConfirmationDialogTitle => 'ログアウトしますか?'; @override - String get logOutConfirmationDialogMessage => 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; + String get logOutConfirmationDialogMessage => + '今後このアカウントを使うには、組織のURLとアカウント情報を再度入力する必要があります。'; @override - String get logOutConfirmationDialogConfirmButton => 'Log out'; + String get logOutConfirmationDialogConfirmButton => 'ログアウト'; @override String get chooseAccountButtonAddAnAccount => '新しいアカウントを追加'; @@ -53,87 +70,208 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get profileButtonSendDirectMessage => 'ダイレクトメッセージを送信'; @override - String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + String get errorCouldNotShowUserProfile => 'ユーザープロフィールを表示できませんでした。'; + + @override + String get permissionsNeededTitle => '権限が必要です'; + + @override + String get permissionsNeededOpenSettings => '設定を開く'; + + @override + String get permissionsDeniedCameraAccess => + '画像をアップロードするには、[設定] でZulipに追加の権限を付与してください。'; + + @override + String get permissionsDeniedReadExternalStorage => + 'ファイルをアップロードするには、[設定] でZulipに追加の権限を付与してください。'; + + @override + String get actionSheetOptionSubscribe => 'チャンネルに参加'; + + @override + String get subscribeFailedTitle => 'チャンネルへの参加に失敗しました'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'チャンネルを既読にする'; + + @override + String get actionSheetOptionCopyChannelLink => 'チャンネルのリンクをコピー'; + + @override + String get actionSheetOptionListOfTopics => 'トピック一覧'; + + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + + @override + String get actionSheetOptionUnsubscribe => 'チャンネルから退出'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return '$channelName から退出しますか?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + 'このチャンネルを退出すると、再び参加できない可能性があります。'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => 'チャンネルから退出'; + + @override + String get unsubscribeFailedTitle => 'チャンネルからの退出に失敗しました'; + + @override + String get actionSheetOptionMuteTopic => 'トピックをミュート'; + + @override + String get actionSheetOptionUnmuteTopic => 'トピックのミュートを解除'; + + @override + String get actionSheetOptionFollowTopic => 'トピックをフォロー'; + + @override + String get actionSheetOptionUnfollowTopic => 'トピックのフォローを解除'; + + @override + String get actionSheetOptionResolveTopic => '解決済みにする'; + + @override + String get actionSheetOptionUnresolveTopic => '未解決にする'; + + @override + String get errorResolveTopicFailedTitle => 'トピックを解決済みにできませんでした'; + + @override + String get errorUnresolveTopicFailedTitle => 'トピックを未解決にできませんでした'; + + @override + String get actionSheetOptionSeeWhoReacted => 'リアクションした人を見る'; @override - String get permissionsNeededTitle => 'Permissions needed'; + String get seeWhoReactedSheetNoReactions => 'このメッセージにはリアクションがありません。'; @override - String get permissionsNeededOpenSettings => 'Open settings'; + String seeWhoReactedSheetHeaderLabel(int num) { + return '絵文字リアクション(合計 $num 件)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num件', + one: '1件', + ); + return '$emojiName:$_temp0'; + } @override - String get permissionsDeniedCameraAccess => 'To upload an image, please grant Zulip additional permissions in Settings.'; + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return '$emojiName のリアクション件数($num件)'; + } @override - String get permissionsDeniedReadExternalStorage => 'To upload files, please grant Zulip additional permissions in Settings.'; + String get actionSheetOptionViewReadReceipts => '既読確認を表示'; @override - String get actionSheetOptionMuteTopic => 'Mute topic'; + String get actionSheetReadReceipts => '既読確認'; @override - String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'このメッセージは $count 人に読まれています:', + one: 'このメッセージは $count 人に読まれています:', + ); + return '$_temp0'; + } @override - String get actionSheetOptionFollowTopic => 'Follow topic'; + String get actionSheetReadReceiptsZeroReadCount => 'このメッセージはまだ誰も読んでいません。'; @override - String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + String get actionSheetReadReceiptsErrorReadCount => '既読情報の読み込みに失敗しました。'; @override - String get actionSheetOptionCopyMessageText => 'Copy message text'; + String get actionSheetOptionCopyMessageText => 'メッセージ本文をコピー'; @override - String get actionSheetOptionCopyMessageLink => 'Copy link to message'; + String get actionSheetOptionCopyMessageLink => 'メッセージへのリンクをコピー'; @override - String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + String get actionSheetOptionMarkAsUnread => 'ここから未読にする'; @override - String get actionSheetOptionShare => 'Share'; + String get actionSheetOptionHideMutedMessage => 'ミュートしたメッセージを再び非表示にする'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionShare => '共有'; @override - String get actionSheetOptionStarMessage => 'Star message'; + String get actionSheetOptionQuoteMessage => 'メッセージを引用'; @override - String get actionSheetOptionUnstarMessage => 'Unstar message'; + String get actionSheetOptionStarMessage => 'メッセージにスターを付ける'; @override - String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; + String get actionSheetOptionUnstarMessage => 'メッセージのスターを外す'; @override - String get errorWebAuthOperationalError => 'An unexpected error occurred.'; + String get actionSheetOptionEditMessage => 'メッセージを編集'; @override - String get errorAccountLoggedInTitle => 'Account already logged in'; + String get actionSheetOptionMarkTopicAsRead => 'トピックを既読にする'; + + @override + String get actionSheetOptionCopyTopicLink => 'トピックのリンクをコピー'; + + @override + String get errorWebAuthOperationalErrorTitle => '問題が発生しました'; + + @override + String get errorWebAuthOperationalError => '予期しないエラーが発生しました。'; + + @override + String get errorAccountLoggedInTitle => 'このアカウントはすでにログインしています'; @override String errorAccountLoggedIn(String email, String server) { - return 'The account $email at $server is already in your list of accounts.'; + return '$server の $email アカウントは、すでにアカウント一覧に追加されています。'; } @override - String get errorCouldNotFetchMessageSource => 'Could not fetch message source'; + String get errorCouldNotFetchMessageSource => 'メッセージのソースを取得できませんでした。'; @override - String get errorCopyingFailed => 'Copying failed'; + String get errorCopyingFailed => 'コピーに失敗しました'; @override String errorFailedToUploadFileTitle(String filename) { - return 'Failed to upload file: $filename'; + return 'ファイルのアップロードに失敗しました: $filename'; + } + + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; } @override - String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: '$num files are', - one: 'File is', + other: '添付した $num 個のファイルは', + one: '添付したファイルは', ); - return '$_temp0 larger than the server\'s limit of $maxFileUploadSizeMib MiB and will not be uploaded:\n\n$listMessage'; + return '$_temp0サーバーの上限 $maxFileUploadSizeMib MiB を超えているため、アップロードできません:\n\n$listMessage'; } @override @@ -141,293 +279,402 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: 'Files', - one: 'File', + other: 'ファイルが大きすぎます', + one: 'ファイルが大きすぎます', ); - return '$_temp0 too large'; + return '$_temp0'; } @override - String get errorLoginInvalidInputTitle => 'Invalid input'; + String get errorLoginInvalidInputTitle => '入力が正しくありません'; @override - String get errorLoginFailedTitle => 'Login failed'; + String get errorLoginFailedTitle => 'ログインに失敗しました'; @override - String get errorMessageNotSent => 'Message not sent'; + String get errorMessageNotSent => 'メッセージを送信できませんでした'; + + @override + String get errorMessageEditNotSaved => 'メッセージを保存できませんでした'; @override String errorLoginCouldNotConnect(String url) { - return 'Failed to connect to server:\n$url'; + return 'サーバーに接続できませんでした:\n$url'; } @override - String get errorLoginCouldNotConnectTitle => 'Could not connect'; + String get errorCouldNotConnectTitle => '接続できませんでした'; @override - String get errorMessageDoesNotSeemToExist => 'That message does not seem to exist.'; + String get errorMessageDoesNotSeemToExist => 'そのメッセージは見つかりませんでした。'; @override - String get errorQuotationFailed => 'Quotation failed'; + String get errorQuotationFailed => '引用できませんでした'; @override String errorServerMessage(String message) { - return 'The server said:\n\n$message'; + return 'サーバーからの応答:\n\n$message'; } @override - String get errorConnectingToServerShort => 'Error connecting to Zulip. Retrying…'; + String get errorConnectingToServerShort => 'Zulip への接続でエラーが発生しました。再試行中…'; @override String errorConnectingToServerDetails(String serverUrl, String error) { - return 'Error connecting to Zulip at $serverUrl. Will retry:\n\n$error'; + return 'Zulip($serverUrl)への接続でエラーが発生しました。再試行します:\n\n$error'; } @override - String get errorHandlingEventTitle => 'Error handling a Zulip event. Retrying connection…'; + String get errorHandlingEventTitle => 'Zulip のイベント処理でエラーが発生しました。再接続を試行しています…'; @override - String errorHandlingEventDetails(String serverUrl, String error, String event) { - return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Zulip($serverUrl)からのイベント処理でエラーが発生しました。再試行します。\n\nエラー:$error\n\nイベント:$event'; } @override - String get errorCouldNotOpenLinkTitle => 'Unable to open link'; + String get errorCouldNotOpenLinkTitle => 'リンクを開けませんでした'; @override String errorCouldNotOpenLink(String url) { - return 'Link could not be opened: $url'; + return 'リンクを開けませんでした:$url'; } @override - String get errorMuteTopicFailed => 'Failed to mute topic'; + String get errorMuteTopicFailed => 'トピックをミュートできませんでした'; + + @override + String get errorUnmuteTopicFailed => 'トピックのミュート解除ができませんでした'; + + @override + String get errorFollowTopicFailed => 'トピックをフォローできませんでした'; + + @override + String get errorUnfollowTopicFailed => 'トピックのフォロー解除ができませんでした'; + + @override + String get errorSharingFailed => '共有に失敗しました'; + + @override + String get errorStarMessageFailedTitle => 'メッセージにスターを付けられませんでした'; + + @override + String get errorUnstarMessageFailedTitle => 'メッセージのスターを外せませんでした'; + + @override + String get errorCouldNotEditMessageTitle => 'メッセージを編集できませんでした'; @override - String get errorUnmuteTopicFailed => 'Failed to unmute topic'; + String get successLinkCopied => 'リンクをコピーしました'; @override - String get errorFollowTopicFailed => 'Failed to follow topic'; + String get successMessageTextCopied => 'メッセージ本文をコピーしました'; @override - String get errorUnfollowTopicFailed => 'Failed to unfollow topic'; + String get successMessageLinkCopied => 'メッセージのリンクをコピーしました'; @override - String get errorSharingFailed => 'Sharing failed'; + String get successTopicLinkCopied => 'トピックのリンクをコピーしました'; @override - String get errorStarMessageFailedTitle => 'Failed to star message'; + String get successChannelLinkCopied => 'チャンネルのリンクをコピーしました'; @override - String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + String get errorBannerDeactivatedDmLabel => '無効化されたユーザーにはメッセージを送信できません。'; @override - String get successLinkCopied => 'Link copied'; + String get errorBannerCannotPostInChannelLabel => 'このチャンネルに投稿する権限がありません。'; @override - String get successMessageTextCopied => 'Message text copied'; + String get composeBoxBannerLabelEditMessage => 'メッセージを編集'; @override - String get successMessageLinkCopied => 'Message link copied'; + String get composeBoxBannerButtonCancel => 'キャンセル'; @override - String get errorBannerDeactivatedDmLabel => 'You cannot send messages to deactivated users.'; + String get composeBoxBannerButtonSave => '保存'; @override - String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + String get editAlreadyInProgressTitle => 'メッセージを編集できません'; @override - String get composeBoxAttachFilesTooltip => 'Attach files'; + String get editAlreadyInProgressMessage => '他の編集が進行中です。完了するまでお待ちください。'; @override - String get composeBoxAttachMediaTooltip => 'Attach images or videos'; + String get savingMessageEditLabel => '保存中…'; @override - String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + String get savingMessageEditFailedLabel => '編集未保存'; @override - String get composeBoxGenericContentHint => 'Type a message'; + String get discardDraftConfirmationDialogTitle => '作成中のメッセージを破棄しますか?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'メッセージを編集すると、作成中の内容は破棄されます。'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + '未送信メッセージを復元すると、作成中の内容は破棄されます。'; + + @override + String get discardDraftConfirmationDialogConfirmButton => '破棄'; + + @override + String get composeBoxAttachFilesTooltip => 'ファイルを添付'; + + @override + String get composeBoxAttachMediaTooltip => '画像や動画を添付'; + + @override + String get composeBoxAttachFromCameraTooltip => '写真を撮る'; + + @override + String get composeBoxGenericContentHint => 'メッセージを入力'; + + @override + String get newDmSheetComposeButtonLabel => '作成'; + + @override + String get newDmSheetScreenTitle => '新しいDM'; + + @override + String get newDmFabButtonLabel => '新しいDM'; + + @override + String get newDmSheetSearchHintEmpty => '1人以上のユーザーを追加'; + + @override + String get newDmSheetSearchHintSomeSelected => '別のユーザーを追加…'; + + @override + String get newDmSheetNoUsersFound => 'ユーザーが見つかりません'; @override String composeBoxDmContentHint(String user) { - return 'Message @$user'; + return '@$user さんにメッセージ'; } @override - String get composeBoxGroupDmContentHint => 'Message group'; + String get composeBoxGroupDmContentHint => 'グループにメッセージ'; @override - String get composeBoxSelfDmContentHint => 'Jot down something'; + String get composeBoxSelfDmContentHint => 'メモを書き留める'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Message #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return '$destination にメッセージを送信'; } @override - String get composeBoxSendTooltip => 'Send'; + String get preparingEditMessageContentInput => '準備中…'; @override - String get unknownChannelName => '(unknown channel)'; + String get composeBoxSendTooltip => '送信'; @override - String get composeBoxTopicHintText => 'Topic'; + String get unknownChannelName => '(不明なチャンネル)'; + + @override + String get composeBoxTopicHintText => 'トピック'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'トピックを入力(省略時は「$defaultTopicName」)'; + } @override String composeBoxUploadingFilename(String filename) { - return 'Uploading $filename…'; + return '$filename をアップロード中…'; } @override String composeBoxLoadingMessage(int messageId) { - return '(loading message $messageId)'; + return '(メッセージ $messageId を読み込み中)'; } @override - String get unknownUserName => '(unknown user)'; + String get unknownUserName => '(不明なユーザー)'; @override - String get dmsWithYourselfPageTitle => 'DMs with yourself'; + String get dmsWithYourselfPageTitle => '自分とのDM'; @override String messageListGroupYouAndOthers(String others) { - return 'You and $others'; + return '自分と$others'; } @override String dmsWithOthersPageTitle(String others) { - return 'DMs with $others'; + return '$othersとのDM'; } @override - String get messageListGroupYouWithYourself => 'You with yourself'; + String get emptyMessageList => 'ここにはメッセージがありません。'; @override - String get contentValidationErrorTooLong => 'Message length shouldn\'t be greater than 10000 characters.'; + String get emptyMessageListSearch => '検索結果はありません。'; @override - String get contentValidationErrorEmpty => 'You have nothing to send!'; + String get messageListGroupYouWithYourself => '自分とのメッセージ'; @override - String get contentValidationErrorQuoteAndReplyInProgress => 'Please wait for the quotation to complete.'; + String get contentValidationErrorTooLong => 'メッセージは10000文字以内で入力してください。'; @override - String get contentValidationErrorUploadInProgress => 'Please wait for the upload to complete.'; + String get contentValidationErrorEmpty => 'メッセージが空です!'; @override - String get dialogCancel => 'Cancel'; + String get contentValidationErrorQuoteAndReplyInProgress => + '引用が完了するまでお待ちください。'; @override - String get dialogContinue => 'Continue'; + String get contentValidationErrorUploadInProgress => 'アップロードが完了するまでお待ちください。'; @override - String get dialogClose => 'Close'; + String get dialogCancel => 'キャンセル'; + + @override + String get dialogContinue => '続行'; + + @override + String get dialogClose => '閉じる'; + + @override + String get errorDialogLearnMore => '詳しく見る'; @override String get errorDialogContinue => 'OK'; @override - String get errorDialogTitle => 'Error'; + String get errorDialogTitle => 'エラー'; @override - String get snackBarDetails => 'Details'; + String get snackBarDetails => '詳細'; @override - String get lightboxCopyLinkTooltip => 'Copy link'; + String get lightboxCopyLinkTooltip => 'リンクをコピー'; @override - String get lightboxVideoCurrentPosition => 'Current position'; + String get lightboxVideoCurrentPosition => '再生位置'; @override - String get lightboxVideoDuration => 'Video duration'; + String get lightboxVideoDuration => '再生時間'; @override - String get loginPageTitle => 'Log in'; + String get loginPageTitle => 'ログイン'; @override - String get loginFormSubmitLabel => 'Log in'; + String get loginFormSubmitLabel => 'ログイン'; @override - String get loginMethodDivider => 'OR'; + String get loginMethodDivider => 'または'; @override String signInWithFoo(String method) { - return 'Sign in with $method'; + return '$methodでログイン'; } @override - String get loginAddAnAccountPageTitle => 'Add an account'; + String get loginAddAnAccountPageTitle => 'アカウントを追加'; + + @override + String get loginServerUrlLabel => 'Zulip サーバーのURL'; + + @override + String get loginHidePassword => 'パスワードを非表示'; + + @override + String get loginEmailLabel => 'メールアドレス'; + + @override + String get loginErrorMissingEmail => 'メールアドレスを入力してください。'; @override - String get loginServerUrlInputLabel => 'Your Zulip server URL'; + String get loginPasswordLabel => 'パスワード'; @override - String get loginHidePassword => 'Hide password'; + String get loginErrorMissingPassword => 'パスワードを入力してください。'; @override - String get loginEmailLabel => 'Email address'; + String get loginUsernameLabel => 'ユーザー名'; @override - String get loginErrorMissingEmail => 'Please enter your email.'; + String get loginErrorMissingUsername => 'ユーザー名を入力してください。'; @override - String get loginPasswordLabel => 'Password'; + String get topicValidationErrorTooLong => 'トピックは60文字以内で入力してください。'; @override - String get loginErrorMissingPassword => 'Please enter your password.'; + String get topicValidationErrorMandatoryButEmpty => 'この組織ではトピックの入力が必須です。'; @override - String get loginUsernameLabel => 'Username'; + String get errorContentNotInsertedTitle => 'コンテンツを挿入できませんでした'; @override - String get loginErrorMissingUsername => 'Please enter your username.'; + String get errorContentToInsertIsEmpty => '挿入しようとしたファイルが空、またはアクセスできません。'; @override - String get topicValidationErrorTooLong => 'Topic length shouldn\'t be greater than 60 characters.'; + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url で動作している Zulip Server $zulipVersion はサポート対象外です。サポートされる最小バージョンは Zulip Server $minSupportedZulipVersion です。'; + } @override - String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + String errorInvalidApiKeyMessage(String url) { + return '$url のアカウントを認証できませんでした。もう一度ログインするか、別のアカウントを使用してください。'; + } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'サーバーから無効な応答が返されました。'; @override - String get errorNetworkRequestFailed => 'Network request failed'; + String get errorNetworkRequestFailed => 'ネットワークエラーが発生しました'; @override String errorMalformedResponse(int httpStatus) { - return 'Server gave malformed response; HTTP status $httpStatus'; + return 'サーバーが不正なレスポンスを返しました(HTTPステータス $httpStatus)'; } @override String errorMalformedResponseWithCause(int httpStatus, String details) { - return 'Server gave malformed response; HTTP status $httpStatus; $details'; + return 'サーバーが不正なレスポンスを返しました(HTTPステータス $httpStatus、詳細: $details)'; } @override String errorRequestFailed(int httpStatus) { - return 'Network request failed: HTTP status $httpStatus'; + return 'ネットワークリクエストに失敗しました:HTTP ステータス $httpStatus'; } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => '動画を再生できません。'; @override - String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; + String get serverUrlValidationErrorEmpty => 'URLを入力してください。'; @override - String get serverUrlValidationErrorInvalidUrl => 'Please enter a valid URL.'; + String get serverUrlValidationErrorInvalidUrl => '有効なURLを入力してください。'; @override - String get serverUrlValidationErrorNoUseEmail => 'Please enter the server URL, not your email.'; + String get serverUrlValidationErrorNoUseEmail => + 'メールアドレスではなく、サーバーURLを入力してください。'; @override - String get serverUrlValidationErrorUnsupportedScheme => 'The server URL must start with http:// or https://.'; + String get serverUrlValidationErrorUnsupportedScheme => + 'サーバーURLは http:// または https:// で始まる必要があります。'; @override - String get spoilerDefaultHeaderText => 'Spoiler'; + String get spoilerDefaultHeaderText => '内容を隠す'; @override - String get markAllAsReadLabel => 'Mark all messages as read'; + String get markAllAsReadLabel => 'すべてのメッセージを既読にする'; @override String markAsReadComplete(int num) { @@ -437,14 +684,14 @@ class ZulipLocalizationsJa extends ZulipLocalizations { other: '$num messages', one: '1 message', ); - return 'Marked $_temp0 as read.'; + return '$_temp0件のメッセージを既読にしました。'; } @override - String get markAsReadInProgress => 'Marking messages as read…'; + String get markAsReadInProgress => 'メッセージを既読にしています…'; @override - String get errorMarkAsReadFailedTitle => 'Mark as read failed'; + String get errorMarkAsReadFailedTitle => '既読にできませんでした'; @override String markAsUnreadComplete(int num) { @@ -454,20 +701,79 @@ class ZulipLocalizationsJa extends ZulipLocalizations { other: '$num messages', one: '1 message', ); - return 'Marked $_temp0 as unread.'; + return '$_temp0件のメッセージを未読にしました。'; + } + + @override + String get markAsUnreadInProgress => 'メッセージを未読にしています…'; + + @override + String get errorMarkAsUnreadFailedTitle => '未読にできませんでした'; + + @override + String get today => '今日'; + + @override + String get yesterday => '昨日'; + + @override + String get userActiveNow => 'オンライン'; + + @override + String get userIdle => '退席中'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes', + one: '1 minute', + ); + return '$_temp0分前にオンライン'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours hours', + one: '1 hour', + ); + return '$_temp0時間前にオンライン'; + } + + @override + String get userActiveYesterday => '昨日オンライン'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days days', + one: '1 day', + ); + return '$_temp0日前にオンライン'; + } + + @override + String userActiveDate(String date) { + return '$dateにオンライン'; } @override - String get markAsUnreadInProgress => 'Marking messages as unread…'; + String get userNotActiveInYear => '1年以上活動していません'; @override - String get errorMarkAsUnreadFailedTitle => 'Mark as unread failed'; + String get invisibleMode => 'ステータス非表示'; @override - String get today => 'Today'; + String get turnOnInvisibleModeErrorTitle => '非表示モードを有効にできませんでした。もう一度お試しください。'; @override - String get yesterday => 'Yesterday'; + String get turnOffInvisibleModeErrorTitle => '非表示モードをオフにできません。もう一度お試しください。'; @override String get userRoleOwner => 'オーナー'; @@ -488,103 +794,193 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get userRoleUnknown => '不明'; @override - String get inboxPageTitle => 'Inbox'; + String get statusButtonLabelStatusSet => 'ステータス'; + + @override + String get statusButtonLabelStatusUnset => 'ステータスを設定'; + + @override + String get noStatusText => 'ステータス文なし'; + + @override + String get setStatusPageTitle => 'ステータスの設定'; + + @override + String get statusClearButtonLabel => 'クリア'; + + @override + String get statusSaveButtonLabel => '保存'; + + @override + String get statusTextHint => '自分のステータス'; + + @override + String get userStatusBusy => '取り込み中'; + + @override + String get userStatusInAMeeting => '会議中'; + + @override + String get userStatusCommuting => '移動中'; + + @override + String get userStatusOutSick => '病欠中'; + + @override + String get userStatusVacationing => '休暇中'; + + @override + String get userStatusWorkingRemotely => '在宅勤務中'; + + @override + String get userStatusAtTheOffice => '出社中'; + + @override + String get updateStatusErrorTitle => 'ステータスの更新に失敗しました。もう一度お試しください。'; + + @override + String get searchMessagesPageTitle => '検索'; + + @override + String get searchMessagesHintText => '検索'; + + @override + String get searchMessagesClearButtonTooltip => 'クリア'; @override - String get recentDmConversationsPageTitle => 'Direct messages'; + String get inboxPageTitle => '受信箱'; @override - String get recentDmConversationsSectionHeader => 'Direct messages'; + String get inboxEmptyPlaceholder => + '未読メッセージはありません。下のボタンから、統合フィードまたはチャンネル一覧を表示できます。'; @override - String get combinedFeedPageTitle => 'Combined feed'; + String get recentDmConversationsPageTitle => 'ダイレクトメッセージ'; @override - String get mentionsPageTitle => 'Mentions'; + String get recentDmConversationsSectionHeader => 'ダイレクトメッセージ'; @override - String get starredMessagesPageTitle => 'Starred messages'; + String get recentDmConversationsEmptyPlaceholder => + 'まだダイレクトメッセージはありません!会話を始めてみませんか?'; @override - String get channelsPageTitle => 'Channels'; + String get combinedFeedPageTitle => '統合フィード'; @override - String get mainMenuMyProfile => 'My profile'; + String get mentionsPageTitle => 'メンション'; @override - String get channelFeedButtonTooltip => 'Channel feed'; + String get starredMessagesPageTitle => 'スター付きメッセージ'; + + @override + String get channelsPageTitle => 'チャンネル'; + + @override + String get channelsEmptyPlaceholder => 'まだ参加しているチャンネルはありません。'; + + @override + String get sharePageTitle => '共有'; + + @override + String get mainMenuMyProfile => '自分のプロフィール'; + + @override + String get topicsButtonTooltip => 'トピック'; + + @override + String get channelFeedButtonTooltip => 'チャンネルフィード'; @override String notifGroupDmConversationLabel(String senderFullName, int numOthers) { String _temp0 = intl.Intl.pluralLogic( numOthers, locale: localeName, - other: '$numOthers others', - one: '1 other', + other: 'ほか$numOthers人', + one: 'ほか1人', ); - return '$senderFullName to you and $_temp0'; + return '$senderFullName から 自分と$_temp0へ'; } @override - String get pinnedSubscriptionsLabel => 'Pinned'; + String get pinnedSubscriptionsLabel => 'ピン留め済み'; + + @override + String get unpinnedSubscriptionsLabel => 'ピン留めなし'; @override - String get unpinnedSubscriptionsLabel => 'Unpinned'; + String get notifSelfUser => '自分'; @override - String get subscriptionListNoChannels => 'No channels found'; + String get reactedEmojiSelfUser => '自分'; @override - String get notifSelfUser => 'You'; + String get reactionChipsLabel => 'リアクション'; @override - String get reactedEmojiSelfUser => 'You'; + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes件'; + } + + @override + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: '自分とほか$otherUsersCount人', + one: '自分とほか1人', + ); + return '$_temp0'; + } @override String onePersonTyping(String typist) { - return '$typist is typing…'; + return '$typist さんが入力中…'; } @override String twoPeopleTyping(String typist, String otherTypist) { - return '$typist and $otherTypist are typing…'; + return '$typist さんと $otherTypist さんが入力中…'; } @override - String get manyPeopleTyping => 'Several people are typing…'; + String get manyPeopleTyping => '複数のユーザーが入力中…'; + + @override + String get wildcardMentionAll => '全員'; @override - String get wildcardMentionAll => 'all'; + String get wildcardMentionEveryone => '全員'; @override - String get wildcardMentionEveryone => 'everyone'; + String get wildcardMentionChannel => 'チャンネル'; @override - String get wildcardMentionChannel => 'channel'; + String get wildcardMentionStream => 'チャンネル'; @override - String get wildcardMentionStream => 'stream'; + String get wildcardMentionTopic => 'トピック'; @override - String get wildcardMentionTopic => 'topic'; + String get wildcardMentionChannelDescription => 'チャンネル参加者に通知'; @override - String get wildcardMentionChannelDescription => 'Notify channel'; + String get wildcardMentionStreamDescription => 'ストリーム参加者に通知'; @override - String get wildcardMentionStreamDescription => 'Notify stream'; + String get wildcardMentionAllDmDescription => '受信者に通知'; @override - String get wildcardMentionAllDmDescription => 'Notify recipients'; + String get wildcardMentionTopicDescription => 'トピック参加者に通知'; @override - String get wildcardMentionTopicDescription => 'Notify topic'; + String get messageIsEditedLabel => '編集済み'; @override - String get messageIsEditedLabel => 'EDITED'; + String get messageIsMovedLabel => '移動済み'; @override - String get messageIsMovedLabel => 'MOVED'; + String get messageNotSentLabel => 'メッセージ未送信'; @override String pollVoterNames(String voterNames) { @@ -592,32 +988,111 @@ class ZulipLocalizationsJa extends ZulipLocalizations { } @override - String get pollWidgetQuestionMissing => 'No question.'; + String get themeSettingTitle => 'テーマ'; + + @override + String get themeSettingDark => 'ダークテーマ'; + + @override + String get themeSettingLight => 'ライトテーマ'; + + @override + String get themeSettingSystem => '自動テーマ'; + + @override + String get openLinksWithInAppBrowser => 'リンクをアプリ内ブラウザで開く'; + + @override + String get pollWidgetQuestionMissing => '質問がありません。'; + + @override + String get pollWidgetOptionsMissing => 'この投票にはまだ選択肢がありません。'; + + @override + String get initialAnchorSettingTitle => 'メッセージ一覧の開始位置'; + + @override + String get initialAnchorSettingDescription => + 'メッセージ一覧を、最初の未読メッセージから開くか、最新のメッセージから開くかを選択できます。'; + + @override + String get initialAnchorSettingFirstUnreadAlways => '最初の未読メッセージ'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + '会話ビューでは最初の未読メッセージ、それ以外では最新メッセージ'; + + @override + String get initialAnchorSettingNewestAlways => '最新のメッセージ'; + + @override + String get markReadOnScrollSettingTitle => 'スクロールでメッセージを既読にする'; + + @override + String get markReadOnScrollSettingDescription => + 'メッセージをスクロールしたとき、自動的に既読にしますか?'; + + @override + String get markReadOnScrollSettingAlways => '常に既読にする'; + + @override + String get markReadOnScrollSettingNever => '既読にしない'; + + @override + String get markReadOnScrollSettingConversations => '会話ビューのみ'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'メッセージは、単一のトピックまたはダイレクトメッセージの会話を表示しているときのみ、自動的に既読になります。'; + + @override + String get experimentalFeatureSettingsPageTitle => '実験的機能'; + + @override + String get experimentalFeatureSettingsWarning => + 'これらのオプションは、まだ開発中で未完成の機能を有効にします。正常に動作しない場合や、アプリの他の部分に不具合を引き起こす可能性があります。\n\nこの設定は、Zulip の開発に携わる人が試験的に利用することを目的としています。'; + + @override + String get errorNotificationOpenTitle => '通知を開けませんでした'; + + @override + String get errorNotificationOpenAccountNotFound => + 'この通知に関連付けられたアカウントが見つかりませんでした。'; + + @override + String get errorReactionAddingFailedTitle => 'リアクションを追加できませんでした'; + + @override + String get errorReactionRemovingFailedTitle => 'リアクションを削除できませんでした'; + + @override + String get errorSharingTitle => 'コンテンツを共有できませんでした'; @override - String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + String get errorSharingAccountNotLoggedIn => + 'ログインしていません。アカウントにログインしてから、もう一度お試しください。'; @override - String get errorNotificationOpenTitle => 'Failed to open notification'; + String get emojiReactionsMore => 'その他'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get emojiPickerSearchEmoji => '絵文字を検索'; @override - String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + String get noEarlierMessages => 'これより前のメッセージはありません'; @override - String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + String get revealButtonLabel => 'メッセージを表示'; @override - String get emojiReactionsMore => 'more'; + String get mutedUser => 'ミュート中のユーザー'; @override - String get emojiPickerSearchEmoji => 'Search emoji'; + String get scrollToBottomTooltip => '最下部へ移動'; @override - String get noEarlierMessages => 'No earlier messages'; + String get appVersionUnknownPlaceholder => '(…)'; @override - String get scrollToBottomTooltip => 'Scroll to bottom'; + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 35b3e86fe5..36411b5274 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -9,20 +9,37 @@ 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'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Choose account'; + @override + String get settingsPageTitle => 'Settings'; + @override String get switchAccountButton => 'Switch account'; @@ -41,7 +58,8 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get logOutConfirmationDialogTitle => 'Log out?'; @override - String get logOutConfirmationDialogMessage => 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; + String get logOutConfirmationDialogMessage => + 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; @override String get logOutConfirmationDialogConfirmButton => 'Log out'; @@ -62,10 +80,48 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get permissionsNeededOpenSettings => 'Open settings'; @override - String get permissionsDeniedCameraAccess => 'To upload an image, please grant Zulip additional permissions in Settings.'; + String get permissionsDeniedCameraAccess => + 'To upload an image, please grant Zulip additional permissions in Settings.'; @override - String get permissionsDeniedReadExternalStorage => 'To upload files, please grant Zulip additional permissions in Settings.'; + String get permissionsDeniedReadExternalStorage => + 'To upload files, please grant Zulip additional permissions in Settings.'; + + @override + String get actionSheetOptionSubscribe => 'Subscribe'; + + @override + String get subscribeFailedTitle => 'Failed to subscribe'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + + @override + String get actionSheetOptionCopyChannelLink => 'Copy link to channel'; + + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + + @override + String get actionSheetOptionUnsubscribe => 'Unsubscribe'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return 'Unsubscribe from $channelName?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + 'Once you leave this channel, you might not be able to rejoin.'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe'; + + @override + String get unsubscribeFailedTitle => 'Failed to unsubscribe'; @override String get actionSheetOptionMuteTopic => 'Mute topic'; @@ -79,6 +135,71 @@ 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 actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num total)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; @@ -88,11 +209,14 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; @@ -100,6 +224,15 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; @@ -115,7 +248,8 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String get errorCouldNotFetchMessageSource => 'Could not fetch message source'; + String get errorCouldNotFetchMessageSource => + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -126,7 +260,16 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, @@ -156,16 +299,20 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorMessageNotSent => 'Message not sent'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Failed to connect to server:\n$url'; } @override - String get errorLoginCouldNotConnectTitle => 'Could not connect'; + String get errorCouldNotConnectTitle => 'Could not connect'; @override - String get errorMessageDoesNotSeemToExist => 'That message does not seem to exist.'; + String get errorMessageDoesNotSeemToExist => + 'That message does not seem to exist.'; @override String get errorQuotationFailed => 'Quotation failed'; @@ -176,7 +323,8 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String get errorConnectingToServerShort => 'Error connecting to Zulip. Retrying…'; + String get errorConnectingToServerShort => + 'Error connecting to Zulip. Retrying…'; @override String errorConnectingToServerDetails(String serverUrl, String error) { @@ -184,10 +332,15 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String get errorHandlingEventTitle => 'Error handling a Zulip event. Retrying connection…'; + String get errorHandlingEventTitle => + 'Error handling a Zulip event. Retrying connection…'; @override - String errorHandlingEventDetails(String serverUrl, String error, String event) { + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; } @@ -220,6 +373,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -230,10 +386,55 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get successMessageLinkCopied => 'Message link copied'; @override - String get errorBannerDeactivatedDmLabel => 'You cannot send messages to deactivated users.'; + String get successTopicLinkCopied => 'Topic link copied'; + + @override + String get successChannelLinkCopied => 'Channel link copied'; + + @override + String get errorBannerDeactivatedDmLabel => + 'You cannot send messages to deactivated users.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'You do not have permission to post in this channel.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; @override - String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + String get discardDraftForEditConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -247,6 +448,24 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetComposeButtonLabel => 'Compose'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -259,10 +478,13 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get composeBoxSelfDmContentHint => 'Jot down something'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Message #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; @@ -272,6 +494,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; @@ -299,19 +526,28 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String get messageListGroupYouWithYourself => 'You with yourself'; + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; @override - String get contentValidationErrorTooLong => 'Message length shouldn\'t be greater than 10000 characters.'; + String get messageListGroupYouWithYourself => 'Messages with yourself'; + + @override + String get contentValidationErrorTooLong => + 'Message length shouldn\'t be greater than 10000 characters.'; @override String get contentValidationErrorEmpty => 'You have nothing to send!'; @override - String get contentValidationErrorQuoteAndReplyInProgress => 'Please wait for the quotation to complete.'; + String get contentValidationErrorQuoteAndReplyInProgress => + 'Please wait for the quotation to complete.'; @override - String get contentValidationErrorUploadInProgress => 'Please wait for the upload to complete.'; + String get contentValidationErrorUploadInProgress => + 'Please wait for the upload to complete.'; @override String get dialogCancel => 'Cancel'; @@ -322,6 +558,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get dialogClose => 'Close'; + @override + String get errorDialogLearnMore => 'Learn more'; + @override String get errorDialogContinue => 'OK'; @@ -358,7 +597,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'; @@ -382,13 +621,36 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get loginErrorMissingUsername => 'Please enter your username.'; @override - String get topicValidationErrorTooLong => 'Topic length shouldn\'t be greater than 60 characters.'; + String get topicValidationErrorTooLong => + 'Topic length shouldn\'t be greater than 60 characters.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Topics are required in this organization.'; + + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => + 'The file to be inserted is empty or cannot be accessed.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } @override - String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -409,7 +671,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; @@ -418,10 +680,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get serverUrlValidationErrorInvalidUrl => 'Please enter a valid URL.'; @override - String get serverUrlValidationErrorNoUseEmail => 'Please enter the server URL, not your email.'; + String get serverUrlValidationErrorNoUseEmail => + 'Please enter the server URL, not your email.'; @override - String get serverUrlValidationErrorUnsupportedScheme => 'The server URL must start with http:// or https://.'; + String get serverUrlValidationErrorUnsupportedScheme => + 'The server URL must start with http:// or https://.'; @override String get spoilerDefaultHeaderText => 'Spoiler'; @@ -469,6 +733,67 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get yesterday => 'Yesterday'; + @override + String get userActiveNow => 'Active now'; + + @override + String get userIdle => 'Idle'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes', + one: '1 minute', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours hours', + one: '1 hour', + ); + return 'Active $_temp0 ago'; + } + + @override + String get userActiveYesterday => 'Active yesterday'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days days', + one: '1 day', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveDate(String date) { + return 'Active $date'; + } + + @override + String get userNotActiveInYear => 'Not active in the last year'; + + @override + String get invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + @override String get userRoleOwner => 'Owner'; @@ -487,15 +812,78 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -508,9 +896,19 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + + @override + String get sharePageTitle => 'Share'; + @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonTooltip => 'Topics'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -531,15 +929,31 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; @override String get reactedEmojiSelfUser => 'You'; + @override + String get reactionChipsLabel => 'Reactions'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } + + @override + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: 'You and $otherUsersCount others', + one: 'You and 1 other', + ); + return '$_temp0'; + } + @override String onePersonTyping(String typist) { return '$typist is typing…'; @@ -586,22 +1000,86 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; } + @override + String get themeSettingTitle => 'THEME'; + + @override + String get themeSettingDark => 'Dark'; + + @override + String get themeSettingLight => 'Light'; + + @override + String get themeSettingSystem => 'System'; + + @override + String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override String get pollWidgetQuestionMissing => 'No question.'; @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in conversation views, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Experimental features'; + + @override + String get experimentalFeatureSettingsWarning => + 'These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.'; + @override String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; @@ -609,6 +1087,13 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; @@ -618,6 +1103,18 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get revealButtonLabel => 'Reveal message'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; + + @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 0594722d31..2794614e81 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -20,9 +20,26 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get aboutPageTapToView => 'Dotknij, aby pokazać'; + @override + String get upgradeWelcomeDialogTitle => 'Witaj w nowej apce Zulip!'; + + @override + String get upgradeWelcomeDialogMessage => + 'Napotkasz na znane rozwiązania, które upakowaliśmy w szybszy i elegancki pakiet.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Sprawdź blog pod kątem obwieszczenia!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Zaczynajmy'; + @override String get chooseAccountPageTitle => 'Wybierz konto'; + @override + String get settingsPageTitle => 'Ustawienia'; + @override String get switchAccountButton => 'Przełącz konto'; @@ -32,7 +49,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get tryAnotherAccountButton => 'Sprawdź inne konto'; + String get tryAnotherAccountButton => 'Użyj innego konta'; @override String get chooseAccountPageLogOutButton => 'Wyloguj'; @@ -41,7 +58,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get logOutConfirmationDialogTitle => 'Wylogować?'; @override - String get logOutConfirmationDialogMessage => 'Aby użyć tego konta należy wypełnić URL organizacji oraz dane konta.'; + String get logOutConfirmationDialogMessage => + 'Aby użyć tego konta należy wskazać URL organizacji oraz dane konta.'; @override String get logOutConfirmationDialogConfirmButton => 'Wyloguj'; @@ -53,7 +71,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get profileButtonSendDirectMessage => 'Wyślij wiadomość bezpośrednią'; @override - String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + String get errorCouldNotShowUserProfile => + 'Nie udało się wyświetlić profilu.'; @override String get permissionsNeededTitle => 'Wymagane uprawnienia'; @@ -62,10 +81,49 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get permissionsNeededOpenSettings => 'Otwórz ustawienia'; @override - String get permissionsDeniedCameraAccess => 'Aby odebrać obraz Zulip musi uzyskać dodatkowe uprawnienia w Ustawieniach.'; + String get permissionsDeniedCameraAccess => + 'Aby odebrać obraz Zulip musi uzyskać dodatkowe uprawnienia w Ustawieniach.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'Aby odebrać pliki Zulip musi uzyskać dodatkowe uprawnienia w Ustawieniach.'; + + @override + String get actionSheetOptionSubscribe => 'Subskrybuj'; + + @override + String get subscribeFailedTitle => 'Subskrypcja bez powodzenia'; + + @override + String get actionSheetOptionMarkChannelAsRead => + 'Oznacz kanał jako przeczytany'; @override - String get permissionsDeniedReadExternalStorage => 'Aby odebrać pliki Zulip musi uzyskać dodatkowe uprawnienia w Ustawieniach.'; + String get actionSheetOptionCopyChannelLink => 'Skopiuj odnośnik do kanału'; + + @override + String get actionSheetOptionListOfTopics => 'Lista wątków'; + + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + + @override + String get actionSheetOptionUnsubscribe => 'Odsubskrybuj'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return 'Odsubskrybować z $channelName?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + 'Po opuszczeniu kanału możesz utracić możliwość powrotu.'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => 'Odsubskrybuj'; + + @override + String get unsubscribeFailedTitle => 'Odsubskrybowanie bez powdzenia'; @override String get actionSheetOptionMuteTopic => 'Wycisz wątek'; @@ -79,20 +137,95 @@ 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 actionSheetOptionSeeWhoReacted => 'Pokaż kto zareagował'; + + @override + String get seeWhoReactedSheetNoReactions => 'Brak reakcji na tę wiadomość.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Reakcje emoji (łącznie $num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num głosów', + one: '1 głos', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Głosów $emojiName ($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => + 'Zobacz potwierdzenia odczytu'; + + @override + String get actionSheetReadReceipts => 'Potwierdzenia odczytu'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + 'Ta wiadomość została przeczytana przez $count osób:', + one: + 'Ta wiadomość została przeczytana przez $count osobę:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'Nikt jeszcze nie widział tej wiadomości.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Ładowanie potwierdzeń odczytu bez powodzenia.'; + @override String get actionSheetOptionCopyMessageText => 'Skopiuj tekst wiadomości'; @override - String get actionSheetOptionCopyMessageLink => 'Skopiuj odnośnik do wiadomości'; + String get actionSheetOptionCopyMessageLink => + 'Skopiuj odnośnik do wiadomości'; @override - String get actionSheetOptionMarkAsUnread => 'Odtąd oznacz jako nieprzeczytane'; + String get actionSheetOptionMarkAsUnread => + 'Odtąd oznacz jako nieprzeczytane'; + + @override + String get actionSheetOptionHideMutedMessage => + 'Ukryj ponownie wyciszone wiadomości'; @override String get actionSheetOptionShare => 'Udostępnij'; @override - String get actionSheetOptionQuoteAndReply => 'Odpowiedz cytując'; + String get actionSheetOptionQuoteMessage => 'Cytuj wiadomość'; @override String get actionSheetOptionStarMessage => 'Oznacz gwiazdką'; @@ -100,6 +233,16 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Odbierz gwiazdkę'; + @override + String get actionSheetOptionEditMessage => 'Zmień wiadomość'; + + @override + String get actionSheetOptionMarkTopicAsRead => + 'Oznacz wątek jako przeczytany'; + + @override + String get actionSheetOptionCopyTopicLink => 'Skopiuj odnośnik do wątku'; + @override String get errorWebAuthOperationalErrorTitle => 'Coś poszło nie tak'; @@ -115,7 +258,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get errorCouldNotFetchMessageSource => 'Nie można uzyskać źródłowej wiadomości'; + String get errorCouldNotFetchMessageSource => + 'Nie można uzyskać źródłowej wiadomości.'; @override String get errorCopyingFailed => 'Nie udało się skopiować'; @@ -126,7 +270,16 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, @@ -156,16 +309,20 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get errorMessageNotSent => 'Nie wysłano wiadomości'; + @override + String get errorMessageEditNotSaved => 'Nie zapisano wiadomości'; + @override String errorLoginCouldNotConnect(String url) { return 'Nie udało się połączyć z serwerem:\n$url'; } @override - String get errorLoginCouldNotConnectTitle => 'Nie można połączyć'; + String get errorCouldNotConnectTitle => 'Brak połączenia'; @override - String get errorMessageDoesNotSeemToExist => 'Taka wiadomość raczej nie istnieje.'; + String get errorMessageDoesNotSeemToExist => + 'Taka wiadomość raczej nie istnieje.'; @override String get errorQuotationFailed => 'Cytowanie bez powodzenia'; @@ -176,7 +333,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get errorConnectingToServerShort => 'Błąd połączenia z Zulip. Ponawiam…'; + String get errorConnectingToServerShort => + 'Błąd połączenia z Zulip. Ponawiam…'; @override String errorConnectingToServerDetails(String serverUrl, String error) { @@ -184,19 +342,24 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get errorHandlingEventTitle => 'Błąd obsługi zdarzenia Zulip. Ponnawiam połączenie…'; + String get errorHandlingEventTitle => + 'Błąd obsługi zdarzenia Zulip. Ponnawiam połączenie…'; @override - String errorHandlingEventDetails(String serverUrl, String error, String event) { + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { return 'Błąd zdarzenia Zulip z $serverUrl; ponawiam.\n\nBłąd: $error\n\nZdarzenie: $event'; } @override - String get errorCouldNotOpenLinkTitle => 'Unable to open link'; + String get errorCouldNotOpenLinkTitle => 'Nie udało się otworzyć odnośnika'; @override String errorCouldNotOpenLink(String url) { - return 'Link could not be opened: $url'; + return 'Nie można otworzyć: $url'; } @override @@ -218,7 +381,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorStarMessageFailedTitle => 'Dodanie gwiazdki bez powodzenia'; @override - String get errorUnstarMessageFailedTitle => 'Odebranie gwiazdki bez powodzenia'; + String get errorUnstarMessageFailedTitle => + 'Odebranie gwiazdki bez powodzenia'; + + @override + String get errorCouldNotEditMessageTitle => 'Nie można zmienić wiadomości'; @override String get successLinkCopied => 'Skopiowano odnośnik'; @@ -230,10 +397,55 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get successMessageLinkCopied => 'Skopiowano odnośnik wiadomości'; @override - String get errorBannerDeactivatedDmLabel => 'Nie można wysyłać wiadomości do dezaktywowanych użytkowników.'; + String get successTopicLinkCopied => 'Skopiowano odnośnik do wątku'; + + @override + String get successChannelLinkCopied => 'Skopiowano odnośnik do kanału'; + + @override + String get errorBannerDeactivatedDmLabel => + 'Nie można wysyłać wiadomości do dezaktywowanych użytkowników.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'Nie masz uprawnień do dodawania wpisów w tym kanale.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Zmień wiadomość'; + + @override + String get composeBoxBannerButtonCancel => 'Anuluj'; + + @override + String get composeBoxBannerButtonSave => 'Zapisz'; + + @override + String get editAlreadyInProgressTitle => 'Nie udało się zapisać zmiany'; + + @override + String get editAlreadyInProgressMessage => + 'Operacja zmiany w toku. Zaczekaj na jej zakończenie.'; + + @override + String get savingMessageEditLabel => 'ZAPIS ZMIANY…'; + + @override + String get savingMessageEditFailedLabel => 'NIE ZAPISANO ZMIANY'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Czy chcesz przerwać szykowanie wpisu?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'Przywracając wiadomość, która nie została wysłana, wyczyścisz zawartość kreatora nowej.'; @override - String get errorBannerCannotPostInChannelLabel => 'Nie masz uprawnień do dodawania wpisów w tym kanale.'; + String get discardDraftConfirmationDialogConfirmButton => 'Odrzuć'; @override String get composeBoxAttachFilesTooltip => 'Dołącz pliki'; @@ -247,6 +459,25 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Wpisz wiadomość'; + @override + String get newDmSheetComposeButtonLabel => 'Utwórz'; + + @override + String get newDmSheetScreenTitle => 'Nowa DM'; + + @override + String get newDmFabButtonLabel => 'Nowa DM'; + + @override + String get newDmSheetSearchHintEmpty => + 'Dodaj jednego lub więcej użytkowników'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Dodaj kolejnego użytkownika…'; + + @override + String get newDmSheetNoUsersFound => 'Nie odnaleziono użytkowników'; + @override String composeBoxDmContentHint(String user) { return 'Napisz do @$user'; @@ -259,19 +490,27 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get composeBoxSelfDmContentHint => 'Zanotuj coś na przyszłość'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Wiadomość #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return 'Wiadomość do $destination'; } + @override + String get preparingEditMessageContentInput => 'Przygotowywanie…'; + @override String get composeBoxSendTooltip => 'Wyślij'; @override - String get unknownChannelName => '(unknown channel)'; + String get unknownChannelName => '(nieznany kanał)'; @override String get composeBoxTopicHintText => 'Wątek'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Wpisz tytuł wątku (pomiń aby uzyskać “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Przekazywanie $filename…'; @@ -279,14 +518,14 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String composeBoxLoadingMessage(int messageId) { - return '(loading message $messageId)'; + return '(ładowanie wiadomości $messageId)'; } @override String get unknownUserName => '(nieznany użytkownik)'; @override - String get dmsWithYourselfPageTitle => 'DMs with yourself'; + String get dmsWithYourselfPageTitle => 'DM do siebie'; @override String messageListGroupYouAndOthers(String others) { @@ -295,23 +534,32 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String dmsWithOthersPageTitle(String others) { - return 'DMs with $others'; + return 'DM z $others'; } @override - String get messageListGroupYouWithYourself => 'Ty ze sobą'; + String get emptyMessageList => 'Póki co brak wiadomości.'; @override - String get contentValidationErrorTooLong => 'Wiadomość nie może być dłuższa niż 10000 znaków.'; + String get emptyMessageListSearch => 'Brak wyników wyszukiwania.'; + + @override + String get messageListGroupYouWithYourself => 'Zapiski na własne konto'; + + @override + String get contentValidationErrorTooLong => + 'Wiadomość nie może być dłuższa niż 10000 znaków.'; @override String get contentValidationErrorEmpty => 'Nie masz nic do wysłania!'; @override - String get contentValidationErrorQuoteAndReplyInProgress => 'Zaczekaj na zakończenie pobierania cytatu.'; + String get contentValidationErrorQuoteAndReplyInProgress => + 'Zaczekaj na zakończenie pobierania cytatu.'; @override - String get contentValidationErrorUploadInProgress => 'Zaczekaj na zakończenie przekazywania.'; + String get contentValidationErrorUploadInProgress => + 'Zaczekaj na zakończenie przekazywania.'; @override String get dialogCancel => 'Anuluj'; @@ -322,6 +570,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get dialogClose => 'Zamknij'; + @override + String get errorDialogLearnMore => 'Dowiedz się więcej'; + @override String get errorDialogContinue => 'OK'; @@ -335,10 +586,10 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get lightboxCopyLinkTooltip => 'Skopiuj odnośnik'; @override - String get lightboxVideoCurrentPosition => 'Current position'; + String get lightboxVideoCurrentPosition => 'Obecna pozycja'; @override - String get lightboxVideoDuration => 'Video duration'; + String get lightboxVideoDuration => 'Długość wideo'; @override String get loginPageTitle => 'Zaloguj'; @@ -358,7 +609,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'; @@ -382,13 +633,37 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get loginErrorMissingUsername => 'Proszę podaj nazwę użytkownika.'; @override - String get topicValidationErrorTooLong => 'Tytuł nie może być dłuższy niż 60 znaków.'; + String get topicValidationErrorTooLong => + 'Tytuł nie może być dłuższy niż 60 znaków.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Wątki są wymagane przez tę organizację.'; + + @override + String get errorContentNotInsertedTitle => + 'Dodanie zawartości bez powodzenia'; + + @override + String get errorContentToInsertIsEmpty => + 'Plik do dodania jest pusty lub nie ma do niego dostępu.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url uruchamia Zulip Server $zulipVersion, który nie jest obsługiwany. Minimalna obsługiwana wersja to Zulip Server $minSupportedZulipVersion.'; + } @override - String get topicValidationErrorMandatoryButEmpty => 'Wątki są wymagane przez tę organizację.'; + String errorInvalidApiKeyMessage(String url) { + return 'Konto w ramach $url nie zostało przyjęte. Spróbuj ponownie lub skorzystaj z innego konta.'; + } @override - String get errorInvalidResponse => 'Nieprawidłowa odpowiedź serwera'; + String get errorInvalidResponse => 'Nieprawidłowa odpowiedź serwera.'; @override String get errorNetworkRequestFailed => 'Dostęp do sieci bez powodzenia'; @@ -409,7 +684,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Nie da rady odtworzyć wideo'; + String get errorVideoPlayerFailed => 'Nie da rady odtworzyć wideo.'; @override String get serverUrlValidationErrorEmpty => 'Proszę podaj URL.'; @@ -418,10 +693,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get serverUrlValidationErrorInvalidUrl => 'Proszę podaj poprawny URL.'; @override - String get serverUrlValidationErrorNoUseEmail => 'Proszę podaj adres URL serwera a nie swój email.'; + String get serverUrlValidationErrorNoUseEmail => + 'Proszę podaj adres URL serwera a nie swój email.'; @override - String get serverUrlValidationErrorUnsupportedScheme => 'Adres URL serwera musi zaczynać się od http:// or https://.'; + String get serverUrlValidationErrorUnsupportedScheme => + 'Adres URL serwera musi zaczynać się od http:// or https://.'; @override String get spoilerDefaultHeaderText => 'Spoiler'; @@ -444,7 +721,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get markAsReadInProgress => 'Oznaczanie wiadomości jako przeczytane…'; @override - String get errorMarkAsReadFailedTitle => 'Oznaczanie jako przeczytane bez powodzenia'; + String get errorMarkAsReadFailedTitle => + 'Oznaczanie jako przeczytane bez powodzenia'; @override String markAsUnreadComplete(int num) { @@ -461,7 +739,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get markAsUnreadInProgress => 'Oznaczanie jako nieprzeczytane…'; @override - String get errorMarkAsUnreadFailedTitle => 'Oznaczanie jako nieprzeczytane bez powodzenia'; + String get errorMarkAsUnreadFailedTitle => + 'Oznaczanie jako nieprzeczytane bez powodzenia'; @override String get today => 'Dzisiaj'; @@ -469,6 +748,67 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get yesterday => 'Wczoraj'; + @override + String get userActiveNow => 'Dostępny'; + + @override + String get userIdle => 'Bezczynny'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minut', + one: '1 minutę', + ); + return 'Aktywny $_temp0 temu'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours godzin', + one: '1 godzinę', + ); + return 'Aktywny $_temp0 temu'; + } + + @override + String get userActiveYesterday => 'Aktywny wczoraj'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days dni', + one: '1 dzień', + ); + return 'Aktywny $_temp0 temu'; + } + + @override + String userActiveDate(String date) { + return 'Aktywny $date'; + } + + @override + String get userNotActiveInYear => 'Brak aktywności za ostatni rok'; + + @override + String get invisibleMode => 'Tryb ukrycia'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Problem z włączeniem trybu ukrycia. Spróbuj ponownie.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Problem z wyłączeniem trybu ukrycia. Spróbuj ponownie.'; + @override String get userRoleOwner => 'Właściciel'; @@ -487,14 +827,77 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get userRoleUnknown => 'Nieznany'; + @override + String get statusButtonLabelStatusSet => 'Stan'; + + @override + String get statusButtonLabelStatusUnset => 'Ustaw stan'; + + @override + String get noStatusText => 'Brak tekstu stanu'; + + @override + String get setStatusPageTitle => 'Ustaw stan'; + + @override + String get statusClearButtonLabel => 'Wyczyść'; + + @override + String get statusSaveButtonLabel => 'Zapisz'; + + @override + String get statusTextHint => 'Twój stan'; + + @override + String get userStatusBusy => 'Zajęty'; + + @override + String get userStatusInAMeeting => 'Na spotkaniu'; + + @override + String get userStatusCommuting => 'W drodze'; + + @override + String get userStatusOutSick => 'Chorobowe'; + + @override + String get userStatusVacationing => 'Na urlopie'; + + @override + String get userStatusWorkingRemotely => 'Praca zdalna'; + + @override + String get userStatusAtTheOffice => 'W biurze'; + + @override + String get updateStatusErrorTitle => + 'Błąd aktualizacji stanu. Spróbuj ponownie.'; + + @override + String get searchMessagesPageTitle => 'Szukaj'; + + @override + String get searchMessagesHintText => 'Szukaj'; + + @override + String get searchMessagesClearButtonTooltip => 'Wyczyść'; + @override String get inboxPageTitle => 'Odebrane'; + @override + String get inboxEmptyPlaceholder => + 'Obecnie brak nowych wiadomości. Skorzystaj z przycisków u dołu ekranu aby przejść do widoku mieszanego lub listy kanałów.'; + @override String get recentDmConversationsPageTitle => 'Wiadomości bezpośrednie'; @override - String get recentDmConversationsSectionHeader => 'Direct messages'; + String get recentDmConversationsSectionHeader => 'Wiadomości bezpośrednie'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'Brak wiadomości w archiwum! Może warto rozpocząć dyskusję?'; @override String get combinedFeedPageTitle => 'Mieszany widok'; @@ -508,9 +911,18 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanały'; + @override + String get channelsEmptyPlaceholder => 'Nie śledzisz żadnego z kanałów.'; + + @override + String get sharePageTitle => 'Udostępnij'; + @override String get mainMenuMyProfile => 'Mój profil'; + @override + String get topicsButtonTooltip => 'Wątki'; + @override String get channelFeedButtonTooltip => 'Strumień kanału'; @@ -526,19 +938,35 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get pinnedSubscriptionsLabel => 'Pinned'; + String get pinnedSubscriptionsLabel => 'Przypięte'; @override - String get unpinnedSubscriptionsLabel => 'Unpinned'; + String get unpinnedSubscriptionsLabel => 'Odpięte'; @override - String get subscriptionListNoChannels => 'No channels found'; + String get notifSelfUser => 'Ty'; @override - String get notifSelfUser => 'Ty'; + String get reactedEmojiSelfUser => 'Ty'; + + @override + String get reactionChipsLabel => 'Reakcje'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } @override - String get reactedEmojiSelfUser => 'You'; + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: 'Ty i $otherUsersCount innych', + one: 'Ty i 1 inny', + ); + return '$_temp0'; + } @override String onePersonTyping(String typist) { @@ -554,31 +982,31 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get manyPeopleTyping => 'Wielu ludzi coś pisze…'; @override - String get wildcardMentionAll => 'all'; + String get wildcardMentionAll => 'wszyscy'; @override - String get wildcardMentionEveryone => 'everyone'; + String get wildcardMentionEveryone => 'każdy'; @override - String get wildcardMentionChannel => 'channel'; + String get wildcardMentionChannel => 'kanał'; @override - String get wildcardMentionStream => 'stream'; + String get wildcardMentionStream => 'strumień'; @override - String get wildcardMentionTopic => 'topic'; + String get wildcardMentionTopic => 'wątek'; @override - String get wildcardMentionChannelDescription => 'Notify channel'; + String get wildcardMentionChannelDescription => 'Powiadom w kanale'; @override - String get wildcardMentionStreamDescription => 'Notify stream'; + String get wildcardMentionStreamDescription => 'Powiadom w strumieniu'; @override - String get wildcardMentionAllDmDescription => 'Notify recipients'; + String get wildcardMentionAllDmDescription => 'Powiadom zainteresowanych'; @override - String get wildcardMentionTopicDescription => 'Notify topic'; + String get wildcardMentionTopicDescription => 'Powiadom w wątku'; @override String get messageIsEditedLabel => 'ZMIENIONO'; @@ -586,11 +1014,29 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get messageIsMovedLabel => 'PRZENIESIONO'; + @override + String get messageNotSentLabel => 'NIE WYSŁANO WIADOMOŚCI'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; } + @override + String get themeSettingTitle => 'WYSTRÓJ'; + + @override + String get themeSettingDark => 'Ciemny'; + + @override + String get themeSettingLight => 'Jasny'; + + @override + String get themeSettingSystem => 'Systemowy'; + + @override + String get openLinksWithInAppBrowser => 'Otwieraj odnośniki w aplikacji'; + @override String get pollWidgetQuestionMissing => 'Brak pytania.'; @@ -598,16 +1044,72 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get pollWidgetOptionsMissing => 'Ta sonda nie ma opcji do wyboru.'; @override - String get errorNotificationOpenTitle => 'Otwieranie powiadomienia bez powodzenia'; + String get initialAnchorSettingTitle => 'Pokaż wiadomości w porządku'; + + @override + String get initialAnchorSettingDescription => + 'Możesz wybrać czy bardziej odpowiada Ci odczyt nieprzeczytanych lub najnowszych wiadomości.'; @override - String get errorNotificationOpenAccountMissing => 'Konto związane z tym powiadomieniem już nie istnieje.'; + String get initialAnchorSettingFirstUnreadAlways => + 'Pierwsza nieprzeczytana wiadomość'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'Pierwsza nieprzeczytana wiadomość w widoku dyskusji, wszędzie indziej najnowsza wiadomość'; + + @override + String get initialAnchorSettingNewestAlways => 'Najnowsza wiadomość'; + + @override + String get markReadOnScrollSettingTitle => + 'Oznacz wiadomości jako przeczytane przy przwijaniu'; + + @override + String get markReadOnScrollSettingDescription => + 'Czy chcesz z automatu oznaczać wiadomości jako przeczytane przy przewijaniu?'; + + @override + String get markReadOnScrollSettingAlways => 'Zawsze'; + + @override + String get markReadOnScrollSettingNever => 'Nigdy'; + + @override + String get markReadOnScrollSettingConversations => 'Tylko w widoku dyskusji'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Wiadomości zostaną z automatu oznaczone jako przeczytane tylko w pojedyczym wątku lub w wymianie wiadomości bezpośrednich.'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Funkcje eksperymentalne'; + + @override + String get experimentalFeatureSettingsWarning => + 'W ten sposób aktywujesz funkcje, które są w fazie testów. Mogą one nie działać lub powodować problemy z tym co bez nich działa poprawnie.\n\nTo ustawienie przewidziane jest dla tych, którzy pracują nad ulepszeniem aplikacji Zulip.'; + + @override + String get errorNotificationOpenTitle => + 'Otwieranie powiadomienia bez powodzenia'; + + @override + String get errorNotificationOpenAccountNotFound => + 'Nie odnaleziono konta powiązanego z tym powiadomieniem.'; @override String get errorReactionAddingFailedTitle => 'Dodanie reakcji bez powodzenia'; @override - String get errorReactionRemovingFailedTitle => 'Usuwanie reakcji bez powodzenia'; + String get errorReactionRemovingFailedTitle => + 'Usuwanie reakcji bez powodzenia'; + + @override + String get errorSharingTitle => 'Udostępnianie zawartości bez powodzenia'; + + @override + String get errorSharingAccountNotLoggedIn => + 'Brak zalogowanego użytkownika. Proszę zaloguj się i spróbuj ponownie.'; @override String get emojiReactionsMore => 'więcej'; @@ -616,8 +1118,20 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get emojiPickerSearchEmoji => 'Szukaj emoji'; @override - String get noEarlierMessages => 'No earlier messages'; + String get noEarlierMessages => 'Brak historii'; + + @override + String get revealButtonLabel => 'Odsłoń wiadomość'; + + @override + String get mutedUser => 'Wyciszony użytkownik'; + + @override + String get scrollToBottomTooltip => 'Przewiń do dołu'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; @override - String get scrollToBottomTooltip => 'Scroll to bottom'; + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 879559fed4..b07752966c 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -20,9 +20,26 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get aboutPageTapToView => 'Нажмите для просмотра'; + @override + String get upgradeWelcomeDialogTitle => + 'Добро пожаловать в новое приложение Zulip!'; + + @override + String get upgradeWelcomeDialogMessage => + 'Вы найдете привычные возможности в более быстром и легком приложении.'; + + @override + String get upgradeWelcomeDialogLinkText => 'Ознакомьтесь с анонсом в блоге!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Приступим'; + @override String get chooseAccountPageTitle => 'Выберите учетную запись'; + @override + String get settingsPageTitle => 'Настройки'; + @override String get switchAccountButton => 'Сменить учетную запись'; @@ -41,7 +58,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get logOutConfirmationDialogTitle => 'Выйти из системы?'; @override - String get logOutConfirmationDialogMessage => 'Чтобы использовать эту учетную запись в будущем, вам придется заново ввести URL-адрес вашей организации и информацию о вашей учетной записи.'; + String get logOutConfirmationDialogMessage => + 'Чтобы использовать эту учетную запись в будущем, вам придется заново ввести URL-адрес вашей организации и информацию о вашей учетной записи.'; @override String get logOutConfirmationDialogConfirmButton => 'Выйти'; @@ -53,7 +71,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get profileButtonSendDirectMessage => 'Отправить личное сообщение'; @override - String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + String get errorCouldNotShowUserProfile => + 'Не удалось показать профиль пользователя.'; @override String get permissionsNeededTitle => 'Требуются разрешения'; @@ -62,16 +81,55 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get permissionsNeededOpenSettings => 'Открыть настройки'; @override - String get permissionsDeniedCameraAccess => 'Для загрузки изображения, пожалуйста, предоставьте Zulip дополнительные разрешения в настройках.'; + String get permissionsDeniedCameraAccess => + 'Для загрузки изображения, пожалуйста, предоставьте Zulip дополнительные разрешения в настройках.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'Для загрузки файлов, пожалуйста, предоставьте Zulip дополнительные разрешения в настройках.'; + + @override + String get actionSheetOptionSubscribe => 'Подписаться'; + + @override + String get subscribeFailedTitle => 'Подписаться не удалось'; + + @override + String get actionSheetOptionMarkChannelAsRead => + 'Отметить канал как прочитанный'; @override - String get permissionsDeniedReadExternalStorage => 'Для загрузки файлов, пожалуйста, предоставьте Zulip дополнительные разрешения в настройках.'; + String get actionSheetOptionCopyChannelLink => 'Скопировать ссылку на канал'; @override - String get actionSheetOptionMuteTopic => 'Отключить тему'; + String get actionSheetOptionListOfTopics => 'Список тем'; @override - String get actionSheetOptionUnmuteTopic => 'Включить тему'; + String get actionSheetOptionChannelFeed => 'Channel feed'; + + @override + String get actionSheetOptionUnsubscribe => 'Отписаться'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return 'Отменить подписку на $channelName?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + 'Покинув этот канал, возможно, вы не сможете присоединиться вновь.'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => 'Отписаться'; + + @override + String get unsubscribeFailedTitle => 'Не удалось отписаться'; + + @override + String get actionSheetOptionMuteTopic => 'Заглушить тему'; + + @override + String get actionSheetOptionUnmuteTopic => 'Включить оповещения темы'; @override String get actionSheetOptionFollowTopic => 'Отслеживать тему'; @@ -79,20 +137,97 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get actionSheetOptionUnfollowTopic => 'Не отслеживать тему'; + @override + String get actionSheetOptionResolveTopic => 'Поставить отметку \"решено\"'; + + @override + String get actionSheetOptionUnresolveTopic => 'Снять отметку \"решено\"'; + + @override + String get errorResolveTopicFailedTitle => + 'Не удалось отметить тему как решенную'; + + @override + String get errorUnresolveTopicFailedTitle => + 'Не удалось отметить тему как нерешенную'; + + @override + String get actionSheetOptionSeeWhoReacted => 'Посмотреть отреагировавших'; + + @override + String get seeWhoReactedSheetNoReactions => 'На это сообщение нет реакций.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Эмодзи-реакции (всего: $num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num голосов', + many: '$num голосов', + few: '$num голоса', + one: '1 голос', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Голоса за $emojiName ($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => + 'Посмотреть подтверждения прочтения'; + + @override + String get actionSheetReadReceipts => 'Подтверждения прочтения'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + 'Это сообщение было прочитано $count пользователями:', + one: + 'Это сообщение было прочитано $count пользователем:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'Это сообщение еще никто не прочитал.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Не удалось загрузить подтверждения прочтения.'; + @override String get actionSheetOptionCopyMessageText => 'Скопировать текст сообщения'; @override - String get actionSheetOptionCopyMessageLink => 'Скопировать ссылку на сообщение'; + String get actionSheetOptionCopyMessageLink => + 'Скопировать ссылку на сообщение'; @override - String get actionSheetOptionMarkAsUnread => 'Отметить как непрочитанные начиная отсюда'; + String get actionSheetOptionMarkAsUnread => + 'Отметить как непрочитанные начиная отсюда'; + + @override + String get actionSheetOptionHideMutedMessage => + 'Скрыть заглушенное сообщение'; @override String get actionSheetOptionShare => 'Поделиться'; @override - String get actionSheetOptionQuoteAndReply => 'Ответить с цитированием'; + String get actionSheetOptionQuoteMessage => 'Цитировать сообщение'; @override String get actionSheetOptionStarMessage => 'Отметить сообщение'; @@ -100,6 +235,16 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Снять отметку с сообщения'; + @override + String get actionSheetOptionEditMessage => 'Редактировать сообщение'; + + @override + String get actionSheetOptionMarkTopicAsRead => + 'Отметить тему как прочитанную'; + + @override + String get actionSheetOptionCopyTopicLink => 'Скопировать ссылку на тему'; + @override String get errorWebAuthOperationalErrorTitle => 'Что-то пошло не так'; @@ -115,7 +260,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get errorCouldNotFetchMessageSource => 'Не удалось извлечь источник сообщения'; + String get errorCouldNotFetchMessageSource => + 'Не удалось извлечь источник сообщения.'; @override String get errorCopyingFailed => 'Сбой копирования'; @@ -126,7 +272,16 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size МиБ'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, @@ -156,16 +311,20 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get errorMessageNotSent => 'Сообщение не отправлено'; + @override + String get errorMessageEditNotSaved => 'Сообщение не сохранено'; + @override String errorLoginCouldNotConnect(String url) { return 'Не удалось подключиться к серверу:\n$url'; } @override - String get errorLoginCouldNotConnectTitle => 'Не удалось подключиться'; + String get errorCouldNotConnectTitle => 'Нет связи с сервером'; @override - String get errorMessageDoesNotSeemToExist => 'Это сообщение, похоже, отсутствует.'; + String get errorMessageDoesNotSeemToExist => + 'Это сообщение, похоже, отсутствует.'; @override String get errorQuotationFailed => 'Цитирование не удалось'; @@ -176,7 +335,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get errorConnectingToServerShort => 'Ошибка подключения к Zulip. Повторяем попытку…'; + String get errorConnectingToServerShort => + 'Ошибка подключения к Zulip. Повторяем попытку…'; @override String errorConnectingToServerDetails(String serverUrl, String error) { @@ -184,32 +344,38 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get errorHandlingEventTitle => 'Ошибка обработки события Zulip. Повторная попытка соединения…'; + String get errorHandlingEventTitle => + 'Ошибка обработки события Zulip. Повторная попытка соединения…'; @override - String errorHandlingEventDetails(String serverUrl, String error, String event) { + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { return 'Ошибка обработки события Zulip от $serverUrl; повторим попытку.\n\nОшибка: $error\n\nСобытие: $event'; } @override - String get errorCouldNotOpenLinkTitle => 'Unable to open link'; + String get errorCouldNotOpenLinkTitle => 'Не удалось открыть ссылку'; @override String errorCouldNotOpenLink(String url) { - return 'Link could not be opened: $url'; + return 'Не удалось открыть ссылку: $url'; } @override - String get errorMuteTopicFailed => 'Не удалось отключить тему'; + String get errorMuteTopicFailed => 'Не удалось заглушить тему'; @override - String get errorUnmuteTopicFailed => 'Не удалось включить тему'; + String get errorUnmuteTopicFailed => 'Не удалось включить оповещения темы'; @override String get errorFollowTopicFailed => 'Не удалось начать отслеживать тему'; @override - String get errorUnfollowTopicFailed => 'Не удалось прекратить отслеживать тему'; + String get errorUnfollowTopicFailed => + 'Не удалось прекратить отслеживать тему'; @override String get errorSharingFailed => 'Не удалось поделиться'; @@ -218,7 +384,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorStarMessageFailedTitle => 'Не удалось отметить сообщение'; @override - String get errorUnstarMessageFailedTitle => 'Не удалось снять отметку с сообщения'; + String get errorUnstarMessageFailedTitle => + 'Не удалось снять отметку с сообщения'; + + @override + String get errorCouldNotEditMessageTitle => 'Сбой редактирования'; @override String get successLinkCopied => 'Ссылка скопирована'; @@ -230,10 +400,55 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get successMessageLinkCopied => 'Ссылка на сообщение скопирована'; @override - String get errorBannerDeactivatedDmLabel => 'Нельзя отправить сообщение отключенным пользователям.'; + String get successTopicLinkCopied => 'Ссылка на тему скопирована'; + + @override + String get successChannelLinkCopied => 'Ссылка на канал скопирована'; + + @override + String get errorBannerDeactivatedDmLabel => + 'Нельзя отправить сообщение отключенным пользователям.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'У вас нет права писать в этом канале.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Редактирование сообщения'; + + @override + String get composeBoxBannerButtonCancel => 'Отмена'; + + @override + String get composeBoxBannerButtonSave => 'Сохранить'; + + @override + String get editAlreadyInProgressTitle => 'Редактирование недоступно'; + + @override + String get editAlreadyInProgressMessage => + 'Редактирование уже выполняется. Дождитесь завершения.'; + + @override + String get savingMessageEditLabel => 'ЗАПИСЬ ПРАВОК…'; + + @override + String get savingMessageEditFailedLabel => 'ПРАВКИ НЕ СОХРАНЕНЫ'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Отказаться от написанного сообщения?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'При изменении сообщения текст из поля для редактирования удаляется.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'При восстановлении неотправленного сообщения содержимое поля редактирования очищается.'; @override - String get errorBannerCannotPostInChannelLabel => 'У вас нет права писать в этом канале.'; + String get discardDraftConfirmationDialogConfirmButton => 'Сбросить'; @override String get composeBoxAttachFilesTooltip => 'Прикрепить файлы'; @@ -247,6 +462,24 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Ввести сообщение'; + @override + String get newDmSheetComposeButtonLabel => 'Написать'; + + @override + String get newDmSheetScreenTitle => 'Новое ЛС'; + + @override + String get newDmFabButtonLabel => 'Новое ЛС'; + + @override + String get newDmSheetSearchHintEmpty => 'Добавить пользователей'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Добавить ещё…'; + + @override + String get newDmSheetNoUsersFound => 'Никто не найден'; + @override String composeBoxDmContentHint(String user) { return 'Сообщение для @$user'; @@ -259,19 +492,27 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get composeBoxSelfDmContentHint => 'Сделать заметку'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Сообщение для #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return 'Сообщение для $destination'; } + @override + String get preparingEditMessageContentInput => 'Подготовка…'; + @override String get composeBoxSendTooltip => 'Отправить'; @override - String get unknownChannelName => '(unknown channel)'; + String get unknownChannelName => '(неизвестный канал)'; @override String get composeBoxTopicHintText => 'Тема'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Укажите тему (или оставьте “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Загрузка $filename…'; @@ -279,14 +520,14 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String composeBoxLoadingMessage(int messageId) { - return '(loading message $messageId)'; + return '(загрузка сообщения $messageId)'; } @override String get unknownUserName => '(неизвестный пользователь)'; @override - String get dmsWithYourselfPageTitle => 'DMs with yourself'; + String get dmsWithYourselfPageTitle => 'ЛС с собой'; @override String messageListGroupYouAndOthers(String others) { @@ -295,23 +536,32 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String dmsWithOthersPageTitle(String others) { - return 'DMs with $others'; + return 'ЛС с $others'; } @override - String get messageListGroupYouWithYourself => 'Вы с собой'; + String get emptyMessageList => 'Здесь нет сообщений.'; @override - String get contentValidationErrorTooLong => 'Длина сообщения не должна превышать 10000 символов.'; + String get emptyMessageListSearch => 'Ничего не найдено.'; + + @override + String get messageListGroupYouWithYourself => 'Сообщения с собой'; + + @override + String get contentValidationErrorTooLong => + 'Длина сообщения не должна превышать 10000 символов.'; @override String get contentValidationErrorEmpty => 'Нечего отправлять!'; @override - String get contentValidationErrorQuoteAndReplyInProgress => 'Пожалуйста, дождитесь завершения цитирования.'; + String get contentValidationErrorQuoteAndReplyInProgress => + 'Пожалуйста, дождитесь завершения цитирования.'; @override - String get contentValidationErrorUploadInProgress => 'Пожалуйста, дождитесь завершения загрузки.'; + String get contentValidationErrorUploadInProgress => + 'Пожалуйста, дождитесь завершения загрузки.'; @override String get dialogCancel => 'Отмена'; @@ -322,6 +572,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get dialogClose => 'Закрыть'; + @override + String get errorDialogLearnMore => 'Узнать больше'; + @override String get errorDialogContinue => 'OK'; @@ -335,10 +588,10 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get lightboxCopyLinkTooltip => 'Скопировать ссылку'; @override - String get lightboxVideoCurrentPosition => 'Current position'; + String get lightboxVideoCurrentPosition => 'Место воспроизведения'; @override - String get lightboxVideoDuration => 'Video duration'; + String get lightboxVideoDuration => 'Длительность видео'; @override String get loginPageTitle => 'Вход в систему'; @@ -358,7 +611,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Добавление учетной записи'; @override - String get loginServerUrlInputLabel => 'URL вашего сервера Zulip'; + String get loginServerUrlLabel => 'URL вашего сервера Zulip'; @override String get loginHidePassword => 'Скрыть пароль'; @@ -367,7 +620,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get loginEmailLabel => 'Адрес почты'; @override - String get loginErrorMissingEmail => 'Пожалуйста, введите ваш адрес электронной почты.'; + String get loginErrorMissingEmail => + 'Пожалуйста, введите ваш адрес электронной почты.'; @override String get loginPasswordLabel => 'Пароль'; @@ -379,16 +633,40 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get loginUsernameLabel => 'Имя пользователя'; @override - String get loginErrorMissingUsername => 'Пожалуйста, введите ваше имя пользователя.'; + String get loginErrorMissingUsername => + 'Пожалуйста, введите ваше имя пользователя.'; + + @override + String get topicValidationErrorTooLong => + 'Длина темы не должна превышать 60 символов.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Темы обязательны в этой организации.'; + + @override + String get errorContentNotInsertedTitle => 'Содержимое не вставлено'; + + @override + String get errorContentToInsertIsEmpty => + 'Файл для вставки пустой, или к нему нет доступа.'; @override - String get topicValidationErrorTooLong => 'Длина темы не должна превышать 60 символов.'; + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url использует Zulip Server $zulipVersion, который не поддерживается. Минимальная поддерживаемая версия — Zulip Server $minSupportedZulipVersion.'; + } @override - String get topicValidationErrorMandatoryButEmpty => 'Темы обязательны в этой организации.'; + String errorInvalidApiKeyMessage(String url) { + return 'Не удалось войти в вашу учётную запись $url. Попробуйте ещё раз или используйте другую учётную запись.'; + } @override - String get errorInvalidResponse => 'Получен недопустимый ответ сервера'; + String get errorInvalidResponse => 'Сервер отправил недопустимый ответ.'; @override String get errorNetworkRequestFailed => 'Сбой сетевого запроса'; @@ -409,19 +687,22 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Не удается воспроизвести видео'; + String get errorVideoPlayerFailed => 'Не удается воспроизвести видео.'; @override String get serverUrlValidationErrorEmpty => 'Пожалуйста, введите URL-адрес.'; @override - String get serverUrlValidationErrorInvalidUrl => 'Пожалуйста, введите корректный URL-адрес.'; + String get serverUrlValidationErrorInvalidUrl => + 'Пожалуйста, введите корректный URL-адрес.'; @override - String get serverUrlValidationErrorNoUseEmail => 'Пожалуйста, введите URL-адрес сервера, а не свой email.'; + String get serverUrlValidationErrorNoUseEmail => + 'Пожалуйста, введите URL-адрес сервера, а не свой email.'; @override - String get serverUrlValidationErrorUnsupportedScheme => 'URL-адрес сервера должен начинаться с http:// или https://.'; + String get serverUrlValidationErrorUnsupportedScheme => + 'URL-адрес сервера должен начинаться с http:// или https://.'; @override String get spoilerDefaultHeaderText => 'Спойлер'; @@ -434,8 +715,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: '$num шт. сообщений', - one: '1 сообщения', + other: '$num сообщений', + one: '$num сообщения', ); return 'Отметка прочтения установлена для $_temp0.'; } @@ -444,15 +725,16 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get markAsReadInProgress => 'Помечаем сообщения как прочитанные…'; @override - String get errorMarkAsReadFailedTitle => 'Не удалось установить отметку прочтения'; + String get errorMarkAsReadFailedTitle => + 'Не удалось установить отметку прочтения'; @override String markAsUnreadComplete(int num) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: '$num шт. сообщений', - one: '1 сообщения', + other: '$num сообщений', + one: '$num сообщения', ); return 'Отметка прочтения снята для $_temp0.'; } @@ -461,7 +743,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get markAsUnreadInProgress => 'Помечаем сообщения как непрочитанные…'; @override - String get errorMarkAsUnreadFailedTitle => 'Не удалось снять отметку прочтения'; + String get errorMarkAsUnreadFailedTitle => + 'Не удалось снять отметку прочтения'; @override String get today => 'Сегодня'; @@ -469,6 +752,73 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get yesterday => 'Вчера'; + @override + String get userActiveNow => 'На связи'; + + @override + String get userIdle => 'Бездействует'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes минут', + many: '$minutes минут', + few: '$minutes минуты', + one: '$minutes минуту', + ); + return 'Был/а на связи $_temp0 назад'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours часов', + many: '$hours часов', + few: '$hours часа', + one: '$hours час', + ); + return 'Был/а на связи $_temp0 назад'; + } + + @override + String get userActiveYesterday => 'Был/а на связи вчера'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days дней', + many: '$days дней', + few: '$days дня', + one: '$days день', + ); + return 'Был/а на связи $_temp0 назад'; + } + + @override + String userActiveDate(String date) { + return 'Был/а на связи $date'; + } + + @override + String get userNotActiveInYear => 'Не выходил/а на связь за последний год'; + + @override + String get invisibleMode => 'Режим невидимости'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Не удалось включить режим невидимости. Повторите попытку позже.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Не удалось отключить режим невидимости. Повторите попытку позже.'; + @override String get userRoleOwner => 'Владелец'; @@ -487,14 +837,77 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get userRoleUnknown => 'Неизвестно'; + @override + String get statusButtonLabelStatusSet => 'Статус'; + + @override + String get statusButtonLabelStatusUnset => 'Установить статус'; + + @override + String get noStatusText => 'Нет текста статуса'; + + @override + String get setStatusPageTitle => 'Установить статус'; + + @override + String get statusClearButtonLabel => 'Очистить'; + + @override + String get statusSaveButtonLabel => 'Сохранить'; + + @override + String get statusTextHint => 'Ваш статус'; + + @override + String get userStatusBusy => 'Занят/а'; + + @override + String get userStatusInAMeeting => 'На встрече'; + + @override + String get userStatusCommuting => 'В дороге'; + + @override + String get userStatusOutSick => 'Болею'; + + @override + String get userStatusVacationing => 'В отпуске'; + + @override + String get userStatusWorkingRemotely => 'Работаю дистанционно'; + + @override + String get userStatusAtTheOffice => 'В офисе'; + + @override + String get updateStatusErrorTitle => + 'Ошибка обновления статуса пользователя. Попробуйте ещё раз.'; + + @override + String get searchMessagesPageTitle => 'Поиск'; + + @override + String get searchMessagesHintText => 'Поиск'; + + @override + String get searchMessagesClearButtonTooltip => 'Очистить'; + @override String get inboxPageTitle => 'Входящие'; + @override + String get inboxEmptyPlaceholder => + 'Нет непрочитанных входящих сообщений. Используйте кнопки ниже для просмотра объединенной ленты или списка каналов.'; + @override String get recentDmConversationsPageTitle => 'Личные сообщения'; @override - String get recentDmConversationsSectionHeader => 'Direct messages'; + String get recentDmConversationsSectionHeader => 'Личные сообщения'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'У вас пока нет личных сообщений! Почему бы не начать беседу?'; @override String get combinedFeedPageTitle => 'Объединенная лента'; @@ -508,9 +921,19 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get channelsPageTitle => 'Каналы'; + @override + String get channelsEmptyPlaceholder => + 'Вы ещё не подписаны ни на один канал.'; + + @override + String get sharePageTitle => 'Поделиться'; + @override String get mainMenuMyProfile => 'Мой профиль'; + @override + String get topicsButtonTooltip => 'Темы'; + @override String get channelFeedButtonTooltip => 'Лента канала'; @@ -519,26 +942,44 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String _temp0 = intl.Intl.pluralLogic( numOthers, locale: localeName, - other: '$numOthers чел.', - one: '1 чел.', + other: '$numOthers другим', + one: '$numOthers другому', ); - return '$senderFullName вам и еще $_temp0'; + return '$senderFullName вам и ещё $_temp0'; } @override - String get pinnedSubscriptionsLabel => 'Pinned'; + String get pinnedSubscriptionsLabel => 'Закреплены'; @override - String get unpinnedSubscriptionsLabel => 'Unpinned'; + String get unpinnedSubscriptionsLabel => 'Откреплены'; @override - String get subscriptionListNoChannels => 'No channels found'; + String get notifSelfUser => 'Вы'; @override - String get notifSelfUser => 'Вы'; + String get reactedEmojiSelfUser => 'Вы'; + + @override + String get reactionChipsLabel => 'Реакции'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } @override - String get reactedEmojiSelfUser => 'You'; + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: 'Вы и еще $otherUsersCount человек', + many: 'Вы и еще $otherUsersCount человек', + few: 'Вы и еще $otherUsersCount человека', + one: 'Вы и еще $otherUsersCount человек', + ); + return '$_temp0'; + } @override String onePersonTyping(String typist) { @@ -554,31 +995,31 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get manyPeopleTyping => 'Несколько человек набирают сообщения…'; @override - String get wildcardMentionAll => 'all'; + String get wildcardMentionAll => 'все'; @override - String get wildcardMentionEveryone => 'everyone'; + String get wildcardMentionEveryone => 'каждый'; @override - String get wildcardMentionChannel => 'channel'; + String get wildcardMentionChannel => 'канал'; @override - String get wildcardMentionStream => 'stream'; + String get wildcardMentionStream => 'канал'; @override - String get wildcardMentionTopic => 'topic'; + String get wildcardMentionTopic => 'тема'; @override - String get wildcardMentionChannelDescription => 'Notify channel'; + String get wildcardMentionChannelDescription => 'Оповестить канал'; @override - String get wildcardMentionStreamDescription => 'Notify stream'; + String get wildcardMentionStreamDescription => 'Оповестить канал'; @override - String get wildcardMentionAllDmDescription => 'Notify recipients'; + String get wildcardMentionAllDmDescription => 'Оповестить получателей'; @override - String get wildcardMentionTopicDescription => 'Notify topic'; + String get wildcardMentionTopicDescription => 'Оповестить тему'; @override String get messageIsEditedLabel => 'ИЗМЕНЕНО'; @@ -586,22 +1027,89 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get messageIsMovedLabel => 'ПЕРЕМЕЩЕНО'; + @override + String get messageNotSentLabel => 'СООБЩЕНИЕ НЕ ОТПРАВЛЕНО'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; } + @override + String get themeSettingTitle => 'РЕЖИМ'; + + @override + String get themeSettingDark => 'Темный'; + + @override + String get themeSettingLight => 'Светлый'; + + @override + String get themeSettingSystem => 'Системный'; + + @override + String get openLinksWithInAppBrowser => 'Открывать ссылки внутри приложения'; + @override String get pollWidgetQuestionMissing => 'Нет вопроса.'; @override String get pollWidgetOptionsMissing => 'В опросе пока нет вариантов ответа.'; + @override + String get initialAnchorSettingTitle => 'Где открывать ленту сообщений'; + + @override + String get initialAnchorSettingDescription => + 'Можно открывать ленту сообщений на первом непрочитанном сообщении или на самом новом.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => + 'Первое непрочитанное сообщение'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'Первое непрочитанное сообщение при просмотре бесед, самое новое в остальных местах'; + + @override + String get initialAnchorSettingNewestAlways => 'Самое новое сообщение'; + + @override + String get markReadOnScrollSettingTitle => + 'Отмечать сообщения как прочитанные при прокрутке'; + + @override + String get markReadOnScrollSettingDescription => + 'При прокрутке сообщений автоматически отмечать их как прочитанные?'; + + @override + String get markReadOnScrollSettingAlways => 'Всегда'; + + @override + String get markReadOnScrollSettingNever => 'Никогда'; + + @override + String get markReadOnScrollSettingConversations => + 'Только при просмотре бесед'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Сообщения будут автоматически помечаться как прочитанные только при просмотре отдельной темы или личной беседы.'; + + @override + String get experimentalFeatureSettingsPageTitle => + 'Экспериментальные функции'; + + @override + String get experimentalFeatureSettingsWarning => + 'Эти параметры включают возможности, которые все ещё находятся в разработке и не готовы. Они могут не работать и вызывать проблемы в других местах приложения.\n\nЦель этих настроек — экспериментирование людьми, работающими над разработкой Zulip.'; + @override String get errorNotificationOpenTitle => 'Не удалось открыть оповещения'; @override - String get errorNotificationOpenAccountMissing => 'Учетной записи, связанной с этим оповещением, больше нет.'; + String get errorNotificationOpenAccountNotFound => + 'Учетная запись, связанная с этим уведомлением, не найдена.'; @override String get errorReactionAddingFailedTitle => 'Не удалось добавить реакцию'; @@ -610,14 +1118,33 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorReactionRemovingFailedTitle => 'Не удалось удалить реакцию'; @override - String get emojiReactionsMore => 'еще'; + String get errorSharingTitle => 'Не удалось поделиться содержанием'; + + @override + String get errorSharingAccountNotLoggedIn => + 'Не выполнен вход с учетной записью. Пожалуйста, войдите в систему и повторите попытку.'; + + @override + String get emojiReactionsMore => 'ещё'; @override String get emojiPickerSearchEmoji => 'Поиск эмодзи'; @override - String get noEarlierMessages => 'No earlier messages'; + String get noEarlierMessages => 'Предшествующих сообщений нет'; + + @override + String get revealButtonLabel => 'Показать сообщение'; + + @override + String get mutedUser => 'Заглушенный пользователь'; + + @override + String get scrollToBottomTooltip => 'Пролистать вниз'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; @override - String get scrollToBottomTooltip => 'Scroll to bottom'; + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index af87dfd949..26be66502e 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -20,9 +20,26 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get aboutPageTapToView => 'Klepnutím zobraziť'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Zvoliť účet'; + @override + String get settingsPageTitle => 'Settings'; + @override String get switchAccountButton => 'Zmeniť účet'; @@ -41,7 +58,8 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get logOutConfirmationDialogTitle => 'Chcete sa odhlásiť?'; @override - String get logOutConfirmationDialogMessage => 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; + String get logOutConfirmationDialogMessage => + 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; @override String get logOutConfirmationDialogConfirmButton => 'Odhlásiť sa'; @@ -62,10 +80,48 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get permissionsNeededOpenSettings => 'Otvoriť nastavenia'; @override - String get permissionsDeniedCameraAccess => 'To upload an image, please grant Zulip additional permissions in Settings.'; + String get permissionsDeniedCameraAccess => + 'To upload an image, please grant Zulip additional permissions in Settings.'; @override - String get permissionsDeniedReadExternalStorage => 'To upload files, please grant Zulip additional permissions in Settings.'; + String get permissionsDeniedReadExternalStorage => + 'To upload files, please grant Zulip additional permissions in Settings.'; + + @override + String get actionSheetOptionSubscribe => 'Subscribe'; + + @override + String get subscribeFailedTitle => 'Failed to subscribe'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + + @override + String get actionSheetOptionCopyChannelLink => 'Copy link to channel'; + + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + + @override + String get actionSheetOptionUnsubscribe => 'Unsubscribe'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return 'Unsubscribe from $channelName?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + 'Once you leave this channel, you might not be able to rejoin.'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe'; + + @override + String get unsubscribeFailedTitle => 'Failed to unsubscribe'; @override String get actionSheetOptionMuteTopic => 'Stlmiť tému'; @@ -79,6 +135,71 @@ 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 actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num total)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Skopírovať text správy'; @@ -86,13 +207,17 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get actionSheetOptionCopyMessageLink => 'Skopírovať odkaz do správy'; @override - String get actionSheetOptionMarkAsUnread => 'Označiť ako neprečítané od tejto správy'; + String get actionSheetOptionMarkAsUnread => + 'Označiť ako neprečítané od tejto správy'; + + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; @override String get actionSheetOptionShare => 'Zdielať'; @override - String get actionSheetOptionQuoteAndReply => 'Citovať a odpovedať'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Ohviezdičkovať správu'; @@ -100,6 +225,15 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Odhviezdičkovať správu'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Niečo sa pokazilo'; @@ -115,7 +249,8 @@ class ZulipLocalizationsSk extends ZulipLocalizations { } @override - String get errorCouldNotFetchMessageSource => 'Nepodarilo sa nahrať zdroj správy'; + String get errorCouldNotFetchMessageSource => + 'Nepodarilo sa nahrať zdroj správy'; @override String get errorCopyingFailed => 'Kopírovanie zlyhalo'; @@ -126,7 +261,16 @@ class ZulipLocalizationsSk extends ZulipLocalizations { } @override - String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, @@ -156,13 +300,16 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get errorMessageNotSent => 'Správa nebola odoslaná'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Nepodarilo sa pripojiť na server:\n$url'; } @override - String get errorLoginCouldNotConnectTitle => 'Nepodarilo sa pripojiť'; + String get errorCouldNotConnectTitle => 'Could not connect'; @override String get errorMessageDoesNotSeemToExist => 'Správa zrejme neexistuje.'; @@ -176,7 +323,8 @@ class ZulipLocalizationsSk extends ZulipLocalizations { } @override - String get errorConnectingToServerShort => 'Chyba pri pripájaní na Zulip. Skúšam znovu…'; + String get errorConnectingToServerShort => + 'Chyba pri pripájaní na Zulip. Skúšam znovu…'; @override String errorConnectingToServerDetails(String serverUrl, String error) { @@ -184,10 +332,15 @@ class ZulipLocalizationsSk extends ZulipLocalizations { } @override - String get errorHandlingEventTitle => 'Chyba pri obsluhe Zulip udalosti. Pokúšam sa znovu…'; + String get errorHandlingEventTitle => + 'Chyba pri obsluhe Zulip udalosti. Pokúšam sa znovu…'; @override - String errorHandlingEventDetails(String serverUrl, String error, String event) { + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { return 'Chyba obsluhy Zulip udalosti na serveri $serverUrl; skúsim znovu.\n\nChyba: $error\n\nUdalosť: $event'; } @@ -220,6 +373,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -230,10 +386,55 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get successMessageLinkCopied => 'Message link copied'; @override - String get errorBannerDeactivatedDmLabel => 'You cannot send messages to deactivated users.'; + String get successTopicLinkCopied => 'Topic link copied'; + + @override + String get successChannelLinkCopied => 'Channel link copied'; + + @override + String get errorBannerDeactivatedDmLabel => + 'You cannot send messages to deactivated users.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'You do not have permission to post in this channel.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; @override - String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + String get discardDraftForEditConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -247,6 +448,24 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetComposeButtonLabel => 'Compose'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -259,10 +478,13 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get composeBoxSelfDmContentHint => 'Jot down something'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Message #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; @@ -272,6 +494,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; @@ -299,19 +526,28 @@ class ZulipLocalizationsSk extends ZulipLocalizations { } @override - String get messageListGroupYouWithYourself => 'You with yourself'; + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; @override - String get contentValidationErrorTooLong => 'Message length shouldn\'t be greater than 10000 characters.'; + String get messageListGroupYouWithYourself => 'Messages with yourself'; + + @override + String get contentValidationErrorTooLong => + 'Message length shouldn\'t be greater than 10000 characters.'; @override String get contentValidationErrorEmpty => 'You have nothing to send!'; @override - String get contentValidationErrorQuoteAndReplyInProgress => 'Please wait for the quotation to complete.'; + String get contentValidationErrorQuoteAndReplyInProgress => + 'Please wait for the quotation to complete.'; @override - String get contentValidationErrorUploadInProgress => 'Please wait for the upload to complete.'; + String get contentValidationErrorUploadInProgress => + 'Please wait for the upload to complete.'; @override String get dialogCancel => 'Cancel'; @@ -322,6 +558,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get dialogClose => 'Zavrieť'; + @override + String get errorDialogLearnMore => 'Learn more'; + @override String get errorDialogContinue => 'OK'; @@ -358,7 +597,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'; @@ -382,10 +621,33 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get loginErrorMissingUsername => 'Prosím zadajte prihlasovacie meno.'; @override - String get topicValidationErrorTooLong => 'Topic length shouldn\'t be greater than 60 characters.'; + String get topicValidationErrorTooLong => + 'Topic length shouldn\'t be greater than 60 characters.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Topics are required in this organization.'; + + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => + 'The file to be inserted is empty or cannot be accessed.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } @override - String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } @override String get errorInvalidResponse => 'Server poslal nesprávnu odpoveď'; @@ -418,10 +680,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get serverUrlValidationErrorInvalidUrl => 'Vložte správnu adresu.'; @override - String get serverUrlValidationErrorNoUseEmail => 'Vložte adresu servera, nie email.'; + String get serverUrlValidationErrorNoUseEmail => + 'Vložte adresu servera, nie email.'; @override - String get serverUrlValidationErrorUnsupportedScheme => 'Adresa servera musí začínať s http:// or https://.'; + String get serverUrlValidationErrorUnsupportedScheme => + 'Adresa servera musí začínať s http:// or https://.'; @override String get spoilerDefaultHeaderText => 'Vyzradenie'; @@ -444,7 +708,8 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get markAsReadInProgress => 'Označiť správy ako prečítané…'; @override - String get errorMarkAsReadFailedTitle => 'Neodarilo sa označiť správy ako prečítané'; + String get errorMarkAsReadFailedTitle => + 'Neodarilo sa označiť správy ako prečítané'; @override String markAsUnreadComplete(int num) { @@ -461,7 +726,8 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get markAsUnreadInProgress => 'Označiť správy ako neprečítané…'; @override - String get errorMarkAsUnreadFailedTitle => 'Zlyhalo označenie správ za prečítané'; + String get errorMarkAsUnreadFailedTitle => + 'Zlyhalo označenie správ za prečítané'; @override String get today => 'Dnes'; @@ -469,6 +735,67 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get yesterday => 'Včera'; + @override + String get userActiveNow => 'Active now'; + + @override + String get userIdle => 'Idle'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes', + one: '1 minute', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours hours', + one: '1 hour', + ); + return 'Active $_temp0 ago'; + } + + @override + String get userActiveYesterday => 'Active yesterday'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days days', + one: '1 day', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveDate(String date) { + return 'Active $date'; + } + + @override + String get userNotActiveInYear => 'Not active in the last year'; + + @override + String get invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + @override String get userRoleOwner => 'Majiteľ'; @@ -487,15 +814,78 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get userRoleUnknown => 'Neznáma'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Priama správa'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Zlúčený kanál'; @@ -508,9 +898,19 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanály'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + + @override + String get sharePageTitle => 'Share'; + @override String get mainMenuMyProfile => 'Môj profil'; + @override + String get topicsButtonTooltip => 'Topics'; + @override String get channelFeedButtonTooltip => 'Channel feed'; @@ -531,15 +931,31 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'Ty'; @override String get reactedEmojiSelfUser => 'You'; + @override + String get reactionChipsLabel => 'Reactions'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } + + @override + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: 'You and $otherUsersCount others', + one: 'You and 1 other', + ); + return '$_temp0'; + } + @override String onePersonTyping(String typist) { return '$typist píše…'; @@ -586,22 +1002,86 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get messageIsMovedLabel => 'PRESUNUTÉ'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; } + @override + String get themeSettingTitle => 'THEME'; + + @override + String get themeSettingDark => 'Dark'; + + @override + String get themeSettingLight => 'Light'; + + @override + String get themeSettingSystem => 'System'; + + @override + String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override String get pollWidgetQuestionMissing => 'Bez otázky.'; @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in conversation views, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Experimental features'; + + @override + String get experimentalFeatureSettingsWarning => + 'These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.'; + @override String get errorNotificationOpenTitle => 'Nepodarilo sa otvoriť oznámenie'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Nepodarilo sa pridať reakciu'; @@ -609,6 +1089,13 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Odobranie reakcie zlyhalo'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'viac'; @@ -618,6 +1105,18 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get revealButtonLabel => 'Reveal message'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart new file mode 100644 index 0000000000..782288eb49 --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -0,0 +1,1149 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Slovenian (`sl`). +class ZulipLocalizationsSl extends ZulipLocalizations { + ZulipLocalizationsSl([String locale = 'sl']) : super(locale); + + @override + String get aboutPageTitle => 'O Zulipu'; + + @override + String get aboutPageAppVersion => 'Različica aplikacije'; + + @override + String get aboutPageOpenSourceLicenses => 'Odprtokodne licence'; + + @override + String get aboutPageTapToView => 'Dotaknite se za ogled'; + + @override + String get upgradeWelcomeDialogTitle => 'Dobrodošli v novi aplikaciji Zulip!'; + + @override + String get upgradeWelcomeDialogMessage => + 'Čaka vas znana izkušnja v hitrejši in bolj elegantni obliki.'; + + @override + String get upgradeWelcomeDialogLinkText => 'Preberite objavo na blogu!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Začnimo'; + + @override + String get chooseAccountPageTitle => 'Izberite račun'; + + @override + String get settingsPageTitle => 'Nastavitve'; + + @override + String get switchAccountButton => 'Preklopi račun'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Nalaganje vašega računa na $url traja dlje kot običajno.'; + } + + @override + String get tryAnotherAccountButton => 'Poskusite z drugim računom'; + + @override + String get chooseAccountPageLogOutButton => 'Odjava'; + + @override + String get logOutConfirmationDialogTitle => 'Se želite odjaviti?'; + + @override + String get logOutConfirmationDialogMessage => + 'Če boste ta račun želeli uporabljati v prihodnje, boste morali znova vnesti URL svoje organizacije in podatke za prijavo.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Odjavi se'; + + @override + String get chooseAccountButtonAddAnAccount => 'Dodaj račun'; + + @override + String get profileButtonSendDirectMessage => 'Pošlji neposredno sporočilo'; + + @override + String get errorCouldNotShowUserProfile => + 'Uporabniškega profila ni mogoče prikazati.'; + + @override + String get permissionsNeededTitle => 'Potrebna so dovoljenja'; + + @override + String get permissionsNeededOpenSettings => 'Odpri nastavitve'; + + @override + String get permissionsDeniedCameraAccess => + 'Za nalaganje slik v nastavitvah omogočite Zulipu dostop do kamere.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'Za nalaganje datotek v nastavitvah omogočite Zulipu dostop do shrambe datotek.'; + + @override + String get actionSheetOptionSubscribe => 'Subscribe'; + + @override + String get subscribeFailedTitle => 'Failed to subscribe'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Označi kanal kot prebran'; + + @override + String get actionSheetOptionCopyChannelLink => 'Copy link to channel'; + + @override + String get actionSheetOptionListOfTopics => 'Seznam tem'; + + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + + @override + String get actionSheetOptionUnsubscribe => 'Unsubscribe'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return 'Unsubscribe from $channelName?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + 'Once you leave this channel, you might not be able to rejoin.'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe'; + + @override + String get unsubscribeFailedTitle => 'Failed to unsubscribe'; + + @override + String get actionSheetOptionMuteTopic => 'Utišaj temo'; + + @override + String get actionSheetOptionUnmuteTopic => 'Prekliči utišanje teme'; + + @override + String get actionSheetOptionFollowTopic => 'Sledi temi'; + + @override + String get actionSheetOptionUnfollowTopic => 'Prenehaj slediti temi'; + + @override + String get actionSheetOptionResolveTopic => 'Označi kot razrešeno'; + + @override + String get actionSheetOptionUnresolveTopic => 'Označi kot nerazrešeno'; + + @override + String get errorResolveTopicFailedTitle => + 'Neuspela označitev teme kot razrešene'; + + @override + String get errorUnresolveTopicFailedTitle => + 'Neuspela označitev teme kot nerazrešene'; + + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num total)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + + @override + String get actionSheetOptionCopyMessageText => 'Kopiraj besedilo sporočila'; + + @override + String get actionSheetOptionCopyMessageLink => + 'Kopiraj povezavo do sporočila'; + + @override + String get actionSheetOptionMarkAsUnread => + 'Od tu naprej označi kot neprebrano'; + + @override + String get actionSheetOptionHideMutedMessage => + 'Znova skrij utišano sporočilo'; + + @override + String get actionSheetOptionShare => 'Deli'; + + @override + String get actionSheetOptionQuoteMessage => 'Citiraj sporočilo'; + + @override + String get actionSheetOptionStarMessage => 'Označi sporočilo z zvezdico'; + + @override + String get actionSheetOptionUnstarMessage => 'Odstrani zvezdico s sporočila'; + + @override + String get actionSheetOptionEditMessage => 'Uredi sporočilo'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Označi temo kot prebrano'; + + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Nekaj je šlo narobe'; + + @override + String get errorWebAuthOperationalError => + 'Prišlo je do nepričakovane napake.'; + + @override + String get errorAccountLoggedInTitle => 'Račun je že prijavljen'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'Račun $email na $server je že na vašem seznamu računov.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Ni bilo mogoče pridobiti vira sporočila.'; + + @override + String get errorCopyingFailed => 'Kopiranje ni uspelo'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return 'Nalaganje datoteke ni uspelo: $filename'; + } + + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num datotek presega', + few: '$num datoteke presegajo', + two: '$num datoteki presegata', + one: '$num datoteka presega', + ); + String _temp1 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'ne bodo naložene', + few: 'ne bodo naložene', + two: 'ne bosta naloženi', + one: 'ne bo naložena', + ); + return '$_temp0 omejitev velikosti strežnika ($maxFileUploadSizeMib MiB) in $_temp1:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num datotek je prevelikih', + few: '$num datoteke so prevelike', + two: '$num datoteki sta preveliki', + one: '$num datoteka je prevelika', + ); + return '\"$_temp0\"'; + } + + @override + String get errorLoginInvalidInputTitle => 'Neveljaven vnos'; + + @override + String get errorLoginFailedTitle => 'Prijava ni uspela'; + + @override + String get errorMessageNotSent => 'Pošiljanje sporočila ni uspelo'; + + @override + String get errorMessageEditNotSaved => 'Sporočilo ni bilo shranjeno'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'Ni se mogoče povezati s strežnikom:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => 'Povezave ni bilo mogoče vzpostaviti'; + + @override + String get errorMessageDoesNotSeemToExist => + 'Zdi se, da to sporočilo ne obstaja.'; + + @override + String get errorQuotationFailed => 'Citiranje ni uspelo'; + + @override + String errorServerMessage(String message) { + return 'Strežnik je sporočil:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Napaka pri povezovanju z Zulipom. Poskušamo znova…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Napaka pri povezovanju z Zulipom na $serverUrl. Poskusili bomo znova:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Napaka pri obravnavi posodobitve. Povezujemo se znova…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Napaka pri obravnavi posodobitve iz strežnika $serverUrl; poskusili bomo znova.\n\nNapaka: $error\n\nDogodek: $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Povezave ni mogoče odpreti'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Povezave ni bilo mogoče odpreti: $url'; + } + + @override + String get errorMuteTopicFailed => 'Utišanje teme ni uspelo'; + + @override + String get errorUnmuteTopicFailed => 'Preklic utišanja teme ni uspel'; + + @override + String get errorFollowTopicFailed => 'Sledenje temi ni uspelo'; + + @override + String get errorUnfollowTopicFailed => 'Prenehanje sledenja temi ni uspelo'; + + @override + String get errorSharingFailed => 'Deljenje ni uspelo'; + + @override + String get errorStarMessageFailedTitle => + 'Sporočila ni bilo mogoče označiti z zvezdico'; + + @override + String get errorUnstarMessageFailedTitle => + 'Sporočilu ni bilo mogoče odstraniti zvezdice'; + + @override + String get errorCouldNotEditMessageTitle => 'Sporočila ni mogoče urediti'; + + @override + String get successLinkCopied => 'Povezava je bila kopirana'; + + @override + String get successMessageTextCopied => 'Besedilo sporočila je bilo kopirano'; + + @override + String get successMessageLinkCopied => + 'Povezava do sporočila je bila kopirana'; + + @override + String get successTopicLinkCopied => 'Topic link copied'; + + @override + String get successChannelLinkCopied => 'Channel link copied'; + + @override + String get errorBannerDeactivatedDmLabel => + 'Deaktiviranim uporabnikom ne morete pošiljati sporočil.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'Nimate dovoljenja za objavljanje v tem kanalu.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Uredi sporočilo'; + + @override + String get composeBoxBannerButtonCancel => 'Prekliči'; + + @override + String get composeBoxBannerButtonSave => 'Shrani'; + + @override + String get editAlreadyInProgressTitle => 'Urejanje sporočila ni mogoče'; + + @override + String get editAlreadyInProgressMessage => + 'Urejanje je že v teku. Počakajte, da se konča.'; + + @override + String get savingMessageEditLabel => 'SHRANJEVANJE SPREMEMB…'; + + @override + String get savingMessageEditFailedLabel => 'UREJANJE NI SHRANJENO'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Želite zavreči sporočilo, ki ga pišete?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'Ko urejate sporočilo, se prejšnja vsebina polja za pisanje zavrže.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'Ko obnovite neodposlano sporočilo, se vsebina, ki je bila prej v polju za pisanje, zavrže.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Zavrzi'; + + @override + String get composeBoxAttachFilesTooltip => 'Pripni datoteke'; + + @override + String get composeBoxAttachMediaTooltip => + 'Pripni fotografije ali videoposnetke'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Fotografiraj'; + + @override + String get composeBoxGenericContentHint => 'Vnesite sporočilo'; + + @override + String get newDmSheetComposeButtonLabel => 'Napiši'; + + @override + String get newDmSheetScreenTitle => 'Novo neposredno sporočilo'; + + @override + String get newDmFabButtonLabel => 'Novo neposredno sporočilo'; + + @override + String get newDmSheetSearchHintEmpty => 'Dodajte enega ali več uporabnikov'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Dodajte še enega uporabnika…'; + + @override + String get newDmSheetNoUsersFound => 'Ni zadetkov med uporabniki'; + + @override + String composeBoxDmContentHint(String user) { + return 'Sporočilo @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Skupinsko sporočilo'; + + @override + String get composeBoxSelfDmContentHint => 'Zapišite opombo zase'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Sporočilo $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Pripravljanje…'; + + @override + String get composeBoxSendTooltip => 'Pošlji'; + + @override + String get unknownChannelName => '(neznan kanal)'; + + @override + String get composeBoxTopicHintText => 'Tema'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Vnesite temo (ali pustite prazno za »$defaultTopicName«)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Nalaganje $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(nalaganje sporočila $messageId)'; + } + + @override + String get unknownUserName => '(neznan uporabnik)'; + + @override + String get dmsWithYourselfPageTitle => 'Neposredna sporočila s samim seboj'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'Vi in $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'Neposredna sporočila z $others'; + } + + @override + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + + @override + String get messageListGroupYouWithYourself => 'Sporočila sebi'; + + @override + String get contentValidationErrorTooLong => + 'Dolžina sporočila ne sme presegati 10000 znakov.'; + + @override + String get contentValidationErrorEmpty => 'Ni vsebine za pošiljanje!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Počakajte, da se citat zaključi.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Počakajte, da se nalaganje konča.'; + + @override + String get dialogCancel => 'Prekliči'; + + @override + String get dialogContinue => 'Nadaljuj'; + + @override + String get dialogClose => 'Zapri'; + + @override + String get errorDialogLearnMore => 'Več o tem'; + + @override + String get errorDialogContinue => 'V redu'; + + @override + String get errorDialogTitle => 'Napaka'; + + @override + String get snackBarDetails => 'Podrobnosti'; + + @override + String get lightboxCopyLinkTooltip => 'Kopiraj povezavo'; + + @override + String get lightboxVideoCurrentPosition => 'Trenutni položaj'; + + @override + String get lightboxVideoDuration => 'Trajanje videa'; + + @override + String get loginPageTitle => 'Prijava'; + + @override + String get loginFormSubmitLabel => 'Prijava'; + + @override + String get loginMethodDivider => 'ALI'; + + @override + String signInWithFoo(String method) { + return 'Prijava z $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Dodaj račun'; + + @override + String get loginServerUrlLabel => 'URL strežnika Zulip'; + + @override + String get loginHidePassword => 'Skrij geslo'; + + @override + String get loginEmailLabel => 'E-poštni naslov'; + + @override + String get loginErrorMissingEmail => 'Vnesite svoj e-poštni naslov.'; + + @override + String get loginPasswordLabel => 'Geslo'; + + @override + String get loginErrorMissingPassword => 'Vnesite svoje geslo.'; + + @override + String get loginUsernameLabel => 'Uporabniško ime'; + + @override + String get loginErrorMissingUsername => 'Vnesite svoje uporabniško ime.'; + + @override + String get topicValidationErrorTooLong => + 'Dolžina teme ne sme presegati 60 znakov.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Teme so v tej organizaciji obvezne.'; + + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => + 'The file to be inserted is empty or cannot be accessed.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url uporablja strežnik Zulip $zulipVersion, ki ni podprt. Najnižja podprta različica je strežnik Zulip $minSupportedZulipVersion.'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return 'Vašega računa na $url ni bilo mogoče overiti. Poskusite se znova prijaviti ali uporabite drug račun.'; + } + + @override + String get errorInvalidResponse => 'Strežnik je poslal neveljaven odgovor.'; + + @override + String get errorNetworkRequestFailed => 'Omrežna zahteva je spodletela'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Strežnik je poslal napačno oblikovan odgovor; stanje HTTP $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Strežnik je poslal napačno oblikovan odgovor; stanje HTTP $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Omrežna zahteva je spodletela: Stanje HTTP $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => 'Videa ni mogoče predvajati.'; + + @override + String get serverUrlValidationErrorEmpty => 'Vnesite URL.'; + + @override + String get serverUrlValidationErrorInvalidUrl => 'Vnesite veljaven URL.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Vnesite URL strežnika, ne vašega e-poštnega naslova.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'URL strežnika se mora začeti s http:// ali https://.'; + + @override + String get spoilerDefaultHeaderText => 'Skrito'; + + @override + String get markAllAsReadLabel => 'Označi vsa sporočila kot prebrana'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num sporočil', + few: '$num sporočila', + two: '$num sporočili', + one: '$num sporočilo', + ); + return 'Označeno je $_temp0 kot prebrano.'; + } + + @override + String get markAsReadInProgress => 'Označevanje sporočil kot prebranih…'; + + @override + String get errorMarkAsReadFailedTitle => 'Označevanje kot prebrano ni uspelo'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'Označeno je $num sporočil kot neprebranih', + few: 'Označena so $num sporočila kot neprebrana', + two: 'Označeni sta $num sporočili kot neprebrani', + one: 'Označeno je $num sporočilo kot neprebrano', + ); + return '$_temp0.'; + } + + @override + String get markAsUnreadInProgress => 'Označevanje sporočil kot neprebranih…'; + + @override + String get errorMarkAsUnreadFailedTitle => + 'Označevanje kot neprebrano ni uspelo'; + + @override + String get today => 'Danes'; + + @override + String get yesterday => 'Včeraj'; + + @override + String get userActiveNow => 'Active now'; + + @override + String get userIdle => 'Idle'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes', + one: '1 minute', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours hours', + one: '1 hour', + ); + return 'Active $_temp0 ago'; + } + + @override + String get userActiveYesterday => 'Active yesterday'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days days', + one: '1 day', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveDate(String date) { + return 'Active $date'; + } + + @override + String get userNotActiveInYear => 'Not active in the last year'; + + @override + String get invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + + @override + String get userRoleOwner => 'Lastnik'; + + @override + String get userRoleAdministrator => 'Skrbnik'; + + @override + String get userRoleModerator => 'Moderator'; + + @override + String get userRoleMember => 'Član'; + + @override + String get userRoleGuest => 'Gost'; + + @override + String get userRoleUnknown => 'Neznano'; + + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + + @override + String get inboxPageTitle => 'Nabiralnik'; + + @override + String get inboxEmptyPlaceholder => + 'V vašem nabiralniku ni neprebranih sporočil. Uporabite spodnje gumbe za ogled združenega prikaza ali seznama kanalov.'; + + @override + String get recentDmConversationsPageTitle => 'Neposredna sporočila'; + + @override + String get recentDmConversationsSectionHeader => 'Neposredna sporočila'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'Zaenkrat še nimate neposrednih sporočil! Zakaj ne bi začeli pogovora?'; + + @override + String get combinedFeedPageTitle => 'Združen prikaz'; + + @override + String get mentionsPageTitle => 'Omembe'; + + @override + String get starredMessagesPageTitle => 'Sporočila z zvezdico'; + + @override + String get channelsPageTitle => 'Kanali'; + + @override + String get channelsEmptyPlaceholder => 'Niste še naročeni na noben kanal.'; + + @override + String get sharePageTitle => 'Share'; + + @override + String get mainMenuMyProfile => 'Moj profil'; + + @override + String get topicsButtonTooltip => 'Teme'; + + @override + String get channelFeedButtonTooltip => 'Sporočila kanala'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers drugim osebam', + one: '1 drugi osebi', + ); + return '$senderFullName vam in $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Pripeto'; + + @override + String get unpinnedSubscriptionsLabel => 'Nepripeto'; + + @override + String get notifSelfUser => 'Vi'; + + @override + String get reactedEmojiSelfUser => 'Vi'; + + @override + String get reactionChipsLabel => 'Reactions'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } + + @override + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: 'You and $otherUsersCount others', + one: 'You and 1 other', + ); + return '$_temp0'; + } + + @override + String onePersonTyping(String typist) { + return '$typist tipka…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist in $otherTypist tipkata…'; + } + + @override + String get manyPeopleTyping => 'Več oseb tipka…'; + + @override + String get wildcardMentionAll => 'vsi'; + + @override + String get wildcardMentionEveryone => 'vsi'; + + @override + String get wildcardMentionChannel => 'kanal'; + + @override + String get wildcardMentionStream => 'tok'; + + @override + String get wildcardMentionTopic => 'tema'; + + @override + String get wildcardMentionChannelDescription => 'Obvesti kanal'; + + @override + String get wildcardMentionStreamDescription => 'Obvesti tok'; + + @override + String get wildcardMentionAllDmDescription => 'Obvesti prejemnike'; + + @override + String get wildcardMentionTopicDescription => 'Obvesti udeležence teme'; + + @override + String get messageIsEditedLabel => 'UREJENO'; + + @override + String get messageIsMovedLabel => 'PREMAKNJENO'; + + @override + String get messageNotSentLabel => 'SPOROČILO NI POSLANO'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => 'TEMA'; + + @override + String get themeSettingDark => 'Temna'; + + @override + String get themeSettingLight => 'Svetla'; + + @override + String get themeSettingSystem => 'Sistemska'; + + @override + String get openLinksWithInAppBrowser => + 'Odpri povezave v brskalniku znotraj aplikacije'; + + @override + String get pollWidgetQuestionMissing => 'Brez vprašanja.'; + + @override + String get pollWidgetOptionsMissing => 'Ta anketa še nima odgovorov.'; + + @override + String get initialAnchorSettingTitle => 'Odpri tok sporočil pri'; + + @override + String get initialAnchorSettingDescription => + 'Lahko izberete, ali se tok sporočil odpre pri vašem prvem neprebranem sporočilu ali pri najnovejših sporočilih.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => + 'Prvo neprebrano sporočilo'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'Prvo neprebrano v pogovorih, najnovejše drugje'; + + @override + String get initialAnchorSettingNewestAlways => 'Najnovejše sporočilo'; + + @override + String get markReadOnScrollSettingTitle => + 'Ob pomikanju označi sporočila kot prebrana'; + + @override + String get markReadOnScrollSettingDescription => + 'Naj se sporočila ob pomikanju samodejno označijo kot prebrana?'; + + @override + String get markReadOnScrollSettingAlways => 'Vedno'; + + @override + String get markReadOnScrollSettingNever => 'Nikoli'; + + @override + String get markReadOnScrollSettingConversations => + 'Samo v pogledih pogovorov'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Sporočila bodo samodejno označena kot prebrana samo pri ogledu ene teme ali zasebnega pogovora.'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Eksperimentalne funkcije'; + + @override + String get experimentalFeatureSettingsWarning => + 'Te možnosti omogočajo funkcije, ki so še v razvoju in niso pripravljene. Morda ne bodo delovale in lahko povzročijo težave v drugih delih aplikacije.\n\nNamen teh nastavitev je eksperimentiranje za uporabnike, ki delajo na razvoju Zulipa.'; + + @override + String get errorNotificationOpenTitle => 'Obvestila ni bilo mogoče odpreti'; + + @override + String get errorNotificationOpenAccountNotFound => + 'Računa, povezanega s tem obvestilom, ni bilo mogoče najti.'; + + @override + String get errorReactionAddingFailedTitle => 'Reakcije ni bilo mogoče dodati'; + + @override + String get errorReactionRemovingFailedTitle => + 'Reakcije ni bilo mogoče odstraniti'; + + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + + @override + String get emojiReactionsMore => 'več'; + + @override + String get emojiPickerSearchEmoji => 'Iskanje emojijev'; + + @override + String get noEarlierMessages => 'Ni starejših sporočil'; + + @override + String get revealButtonLabel => 'Prikaži sporočilo utišanega pošiljatelja'; + + @override + String get mutedUser => 'Uporabnik je utišan'; + + @override + String get scrollToBottomTooltip => 'Premakni se na konec'; + + @override + String get appVersionUnknownPlaceholder => '(...)'; + + @override + String get zulipAppTitle => 'Zulip'; +} diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart new file mode 100644 index 0000000000..92bcdb05c8 --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -0,0 +1,1139 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Ukrainian (`uk`). +class ZulipLocalizationsUk extends ZulipLocalizations { + ZulipLocalizationsUk([String locale = 'uk']) : super(locale); + + @override + String get aboutPageTitle => 'Про Zulip'; + + @override + String get aboutPageAppVersion => 'Версія додатку'; + + @override + String get aboutPageOpenSourceLicenses => 'Ліцензії з відкритим кодом'; + + @override + String get aboutPageTapToView => 'Натисніть, щоб переглянути'; + + @override + String get upgradeWelcomeDialogTitle => + 'Ласкаво просимо у новий додаток Zulip!'; + + @override + String get upgradeWelcomeDialogMessage => + 'Ви знайдете звичні можливості у більш швидкому і легкому додатку.'; + + @override + String get upgradeWelcomeDialogLinkText => 'Ознайомтесь з анонсом у блозі!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Ходімо'; + + @override + String get chooseAccountPageTitle => 'Обрати обліковий запис'; + + @override + String get settingsPageTitle => 'Налаштування'; + + @override + String get switchAccountButton => 'Змінити обліковий запис'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Ваш обліковий запис на $url завантажується деякий час.'; + } + + @override + String get tryAnotherAccountButton => 'Спробуйте інший обліковий запис'; + + @override + String get chooseAccountPageLogOutButton => 'Вийти'; + + @override + String get logOutConfirmationDialogTitle => 'Вийти?'; + + @override + String get logOutConfirmationDialogMessage => + 'Щоб використовувати цей обліковий запис у майбутньому, вам доведеться повторно ввести його дані та URL-адресу вашої організації.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Вийти'; + + @override + String get chooseAccountButtonAddAnAccount => 'Додати обліковий запис'; + + @override + String get profileButtonSendDirectMessage => + 'Надіслати особисте повідомлення'; + + @override + String get errorCouldNotShowUserProfile => + 'Не вдалося показати профіль користувача.'; + + @override + String get permissionsNeededTitle => 'Потрібні дозволи'; + + @override + String get permissionsNeededOpenSettings => 'Відкрити налаштування'; + + @override + String get permissionsDeniedCameraAccess => + 'Щоб завантажити зображення, надайте Zulip додаткові дозволи в налаштуваннях.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'Щоб завантажувати файли, надайте Zulip додаткові дозволи в налаштуваннях.'; + + @override + String get actionSheetOptionSubscribe => 'Підписатися'; + + @override + String get subscribeFailedTitle => 'Не вдалося підписатися'; + + @override + String get actionSheetOptionMarkChannelAsRead => + 'Позначити канал як прочитаний'; + + @override + String get actionSheetOptionCopyChannelLink => 'Копіювати посилання на канал'; + + @override + String get actionSheetOptionListOfTopics => 'Список тем'; + + @override + String get actionSheetOptionChannelFeed => 'Стрічка каналу'; + + @override + String get actionSheetOptionUnsubscribe => 'Скасувати підписку'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return 'Відписатися від $channelName?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + 'Після того, як ви залишите цей канал, ви, можливо, не зможете приєднатися знову.'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => 'Скасувати підписку'; + + @override + String get unsubscribeFailedTitle => 'Не вдалося скасувати підписку'; + + @override + String get actionSheetOptionMuteTopic => 'Заглушити тему'; + + @override + String get actionSheetOptionUnmuteTopic => 'Увімкнути тему'; + + @override + String get actionSheetOptionFollowTopic => 'Підписатися на тему'; + + @override + String get actionSheetOptionUnfollowTopic => 'Відписатися від теми'; + + @override + String get actionSheetOptionResolveTopic => 'Позначити як вирішене'; + + @override + String get actionSheetOptionUnresolveTopic => 'Позначити як невирішене'; + + @override + String get errorResolveTopicFailedTitle => + 'Не вдалося позначити тему як вирішену'; + + @override + String get errorUnresolveTopicFailedTitle => + 'Не вдалося позначити тему як невирішену'; + + @override + String get actionSheetOptionSeeWhoReacted => 'Дивіться, хто відреагував'; + + @override + String get seeWhoReactedSheetNoReactions => + 'На це повідомлення немає реакцій.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Реакції емодзі (загалом $num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num голоси', + one: '1 голосу', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Голоси за $emojiName ($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => + 'Переглянути сповіщення про прочитання'; + + @override + String get actionSheetReadReceipts => 'Квитанції про прочитання'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Це повідомлення було прочитано $count людьми:', + one: 'Це повідомлення було прочитано $count особою:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'Ніхто ще не прочитав цього повідомлення.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Не вдалося завантажити сповіщення про прочитання.'; + + @override + String get actionSheetOptionCopyMessageText => 'Копіювати текст повідомлення'; + + @override + String get actionSheetOptionCopyMessageLink => + 'Копіювати посилання на повідомлення'; + + @override + String get actionSheetOptionMarkAsUnread => 'Позначити як непрочитане звідси'; + + @override + String get actionSheetOptionHideMutedMessage => + 'Сховати заглушене повідомлення'; + + @override + String get actionSheetOptionShare => 'Поширити'; + + @override + String get actionSheetOptionQuoteMessage => 'Цитувати повідомлення'; + + @override + String get actionSheetOptionStarMessage => 'Вибрати повідомлення'; + + @override + String get actionSheetOptionUnstarMessage => + 'Зняти позначку зірки з повідомлення'; + + @override + String get actionSheetOptionEditMessage => 'Редагувати повідомлення'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Позначити тему як прочитану'; + + @override + String get actionSheetOptionCopyTopicLink => 'Копіювати посилання на тему'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Щось пішло не так'; + + @override + String get errorWebAuthOperationalError => 'Сталася неочікувана помилка.'; + + @override + String get errorAccountLoggedInTitle => 'В обліковий запис уже ввійшли'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'Обліковий запис $email на $server уже є у вашому списку облікових записів.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Не вдалося отримати джерело повідомлення.'; + + @override + String get errorCopyingFailed => 'Помилка копіювання'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return 'Не вдалося завантажити файл: $filename'; + } + + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num файли', + one: 'Файл', + ); + return '$_temp0 перевищують ліміт сервера в $maxFileUploadSizeMib MiB і не будуть завантажені:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'Файли', + one: 'Файл', + ); + return '$_temp0 занадто великий'; + } + + @override + String get errorLoginInvalidInputTitle => 'Невірний вхід'; + + @override + String get errorLoginFailedTitle => 'Помилка входу'; + + @override + String get errorMessageNotSent => 'Повідомлення не надіслано'; + + @override + String get errorMessageEditNotSaved => 'Повідомлення не збережено'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'Не вдалося підключитися до сервера:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => 'Не вдалося підключитися'; + + @override + String get errorMessageDoesNotSeemToExist => + 'Здається, цього повідомлення не існує.'; + + @override + String get errorQuotationFailed => 'Помилка цитування'; + + @override + String errorServerMessage(String message) { + return 'Сервер сказав:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Помилка підключення до Zulip. Повторна спроба…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Помилка підключення до Zulip на $serverUrl. Буде повторена спроба:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Помилка обробки події Zulip. Повторна спроба підключення…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Помилка обробки події Zulip із $serverUrl; буде повторювати спробу.\n\nПомилка: $error\n\nПодія: $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Неможливо відкрити посилання'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Не вдалося відкрити посилання: $url'; + } + + @override + String get errorMuteTopicFailed => 'Не вдалося заглушити тему'; + + @override + String get errorUnmuteTopicFailed => 'Не вдалося увімкнути тему'; + + @override + String get errorFollowTopicFailed => 'Не вдалося підписатися на тему'; + + @override + String get errorUnfollowTopicFailed => 'Не вдалося відписатися від теми'; + + @override + String get errorSharingFailed => 'Поширення не вдалося'; + + @override + String get errorStarMessageFailedTitle => + 'Не вдалося позначити повідомлення зіркою'; + + @override + String get errorUnstarMessageFailedTitle => + 'Не вдалося зняти позначку зірки з повідомлення'; + + @override + String get errorCouldNotEditMessageTitle => + 'Не вдалося редагувати повідомлення'; + + @override + String get successLinkCopied => 'Посилання скопійовано'; + + @override + String get successMessageTextCopied => 'Текст повідомлення скопійовано'; + + @override + String get successMessageLinkCopied => + 'Посилання на повідомлення скопійовано'; + + @override + String get successTopicLinkCopied => 'Посилання на тему скопійовано'; + + @override + String get successChannelLinkCopied => 'Посилання на канал скопійовано'; + + @override + String get errorBannerDeactivatedDmLabel => + 'Ви не можете надсилати повідомлення деактивованим користувачам.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'Ви не маєте дозволу на публікацію в цьому каналі.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Редагування повідомлення'; + + @override + String get composeBoxBannerButtonCancel => 'Відміна'; + + @override + String get composeBoxBannerButtonSave => 'Зберегти'; + + @override + String get editAlreadyInProgressTitle => 'Неможливо редагувати повідомлення'; + + @override + String get editAlreadyInProgressMessage => + 'Редагування уже виконується. Дочекайтеся його завершення.'; + + @override + String get savingMessageEditLabel => 'ЗБЕРЕЖЕННЯ ПРАВОК…'; + + @override + String get savingMessageEditFailedLabel => 'ПРАВКИ НЕ ЗБЕРЕЖЕНІ'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Відмовитися від написаного повідомлення?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'При редагуванні повідомлення, текст з поля для редагування видаляється.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'При відновленні невідправленого повідомлення, вміст поля редагування очищається.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Скинути'; + + @override + String get composeBoxAttachFilesTooltip => 'Прикріпити файли'; + + @override + String get composeBoxAttachMediaTooltip => 'Додати зображення або відео'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Зробити фото'; + + @override + String get composeBoxGenericContentHint => 'Ввести повідомлення'; + + @override + String get newDmSheetComposeButtonLabel => 'Написати'; + + @override + String get newDmSheetScreenTitle => 'Нове особисте повідомлення'; + + @override + String get newDmFabButtonLabel => 'Нове особисте повідомлення'; + + @override + String get newDmSheetSearchHintEmpty => 'Додати користувачів'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Додати ще…'; + + @override + String get newDmSheetNoUsersFound => 'Користувачі не знайдені'; + + @override + String composeBoxDmContentHint(String user) { + return 'Повідомлення @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Написати групі'; + + @override + String get composeBoxSelfDmContentHint => 'Занотувати щось'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Надіслати повідомлення $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Підготовка…'; + + @override + String get composeBoxSendTooltip => 'Надіслати'; + + @override + String get unknownChannelName => '(невідомий канал)'; + + @override + String get composeBoxTopicHintText => 'Тема'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Вкажіть тему (або залиште “$defaultTopicName”)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Завантаження $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(завантаження повідомлення $messageId)'; + } + + @override + String get unknownUserName => '(невідомий користувач)'; + + @override + String get dmsWithYourselfPageTitle => 'Особисті повідомлення із собою'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'Ви та $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'Особисті повідомлення з $others'; + } + + @override + String get emptyMessageList => 'Тут немає повідомлень.'; + + @override + String get emptyMessageListSearch => 'Немає результатів пошуку.'; + + @override + String get messageListGroupYouWithYourself => 'Повідомлення з собою'; + + @override + String get contentValidationErrorTooLong => + 'Довжина повідомлення не повинна перевищувати 10000 символів.'; + + @override + String get contentValidationErrorEmpty => 'Вам нема чого надсилати!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Будь ласка, дочекайтеся завершення цитування.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Дочекайтеся завершення завантаження.'; + + @override + String get dialogCancel => 'Відміна'; + + @override + String get dialogContinue => 'Продовжити'; + + @override + String get dialogClose => 'Закрити'; + + @override + String get errorDialogLearnMore => 'Дізнайтися більше'; + + @override + String get errorDialogContinue => 'ОК'; + + @override + String get errorDialogTitle => 'Помилка'; + + @override + String get snackBarDetails => 'Деталі'; + + @override + String get lightboxCopyLinkTooltip => 'Копіювати посилання'; + + @override + String get lightboxVideoCurrentPosition => 'Поточна позиція'; + + @override + String get lightboxVideoDuration => 'Довжина відео'; + + @override + String get loginPageTitle => 'Увійти'; + + @override + String get loginFormSubmitLabel => 'Увійти'; + + @override + String get loginMethodDivider => 'АБО'; + + @override + String signInWithFoo(String method) { + return 'Увійти з $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Додати обліковий запис'; + + @override + String get loginServerUrlLabel => 'URL-адреса вашого сервера Zulip'; + + @override + String get loginHidePassword => 'Приховати пароль'; + + @override + String get loginEmailLabel => 'Адреса електронної пошти'; + + @override + String get loginErrorMissingEmail => + 'Будь ласка, введіть свою електронну адресу.'; + + @override + String get loginPasswordLabel => 'Пароль'; + + @override + String get loginErrorMissingPassword => 'Будь ласка, введіть свій пароль.'; + + @override + String get loginUsernameLabel => 'Ім\'я користувача'; + + @override + String get loginErrorMissingUsername => 'Введіть своє ім\'я користувача.'; + + @override + String get topicValidationErrorTooLong => + 'Довжина теми не повинна перевищувати 60 символів.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Теми обовʼязкові в цій організації.'; + + @override + String get errorContentNotInsertedTitle => 'Вміст не вставлено'; + + @override + String get errorContentToInsertIsEmpty => + 'Файл, який потрібно вставити, порожній або до нього немає доступу.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url використовує Zulip Server $zulipVersion, який не підтримується. Мінімальною підтримуваною версією є Zulip Server $minSupportedZulipVersion.'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return 'Ваш обліковий запис на $url не вдалося автентифікувати. Спробуйте увійти ще раз або скористайтеся іншим обліковим записом.'; + } + + @override + String get errorInvalidResponse => 'Сервер надіслав недійсну відповідь.'; + + @override + String get errorNetworkRequestFailed => 'Помилка запиту мережі'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Сервер дав неправильну відповідь; Статус HTTP $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Сервер дав неправильну відповідь; Статус HTTP $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Помилка мережевого запиту: статус HTTP $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => 'Неможливо відтворити відео.'; + + @override + String get serverUrlValidationErrorEmpty => 'Будь ласка, введіть URL.'; + + @override + String get serverUrlValidationErrorInvalidUrl => 'Введіть дійсну URL-адресу.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Введіть URL-адресу сервера, а не свою електронну адресу.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'URL-адреса сервера має починатися з http:// або https://.'; + + @override + String get spoilerDefaultHeaderText => 'Спойлер'; + + @override + String get markAllAsReadLabel => 'Позначити всі повідомлення як прочитані'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num повідомлення', + one: '1 повідомлення', + ); + return 'Позначено як прочитані $_temp0.'; + } + + @override + String get markAsReadInProgress => 'Позначення повідомлень як прочитаних…'; + + @override + String get errorMarkAsReadFailedTitle => 'Не вдалося позначити як прочитане'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num повідомлення', + one: '1 повідомлення', + ); + return 'Позначено як непрочитані $_temp0.'; + } + + @override + String get markAsUnreadInProgress => + 'Позначення повідомлень як непрочитаних…'; + + @override + String get errorMarkAsUnreadFailedTitle => + 'Не вдалося позначити як непрочитане'; + + @override + String get today => 'Сьогодні'; + + @override + String get yesterday => 'Учора'; + + @override + String get userActiveNow => 'Активний зараз'; + + @override + String get userIdle => 'Холостий хід'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes хвилин', + one: '1 хвилина', + ); + return 'Активний $_temp0 тому'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours години', + one: '1 година', + ); + return 'Активний $_temp0 тому'; + } + + @override + String get userActiveYesterday => 'Активний учора'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days дні', + one: '1 день', + ); + return 'Активний $_temp0 тому'; + } + + @override + String userActiveDate(String date) { + return 'Активний $date'; + } + + @override + String get userNotActiveInYear => 'Неактивний протягом останнього року'; + + @override + String get invisibleMode => 'Невидимий режим'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Помилка ввімкнення режиму невидимості. Спробуйте ще раз.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Помилка вимкнення режиму невидимості. Спробуйте ще раз.'; + + @override + String get userRoleOwner => 'Власник'; + + @override + String get userRoleAdministrator => 'Адміністратор'; + + @override + String get userRoleModerator => 'Модератор'; + + @override + String get userRoleMember => 'Учасник'; + + @override + String get userRoleGuest => 'Гість'; + + @override + String get userRoleUnknown => 'Невідомо'; + + @override + String get statusButtonLabelStatusSet => 'Статус'; + + @override + String get statusButtonLabelStatusUnset => 'Встановити статус'; + + @override + String get noStatusText => 'Немає тексту статусу'; + + @override + String get setStatusPageTitle => 'Встановити статус'; + + @override + String get statusClearButtonLabel => 'Очистити'; + + @override + String get statusSaveButtonLabel => 'Зберегти'; + + @override + String get statusTextHint => 'Ваш статус'; + + @override + String get userStatusBusy => 'Зайнятий'; + + @override + String get userStatusInAMeeting => 'На зустрічі'; + + @override + String get userStatusCommuting => 'Поїздки на роботу'; + + @override + String get userStatusOutSick => 'Хворий'; + + @override + String get userStatusVacationing => 'Відпустка'; + + @override + String get userStatusWorkingRemotely => 'Працюємо віддалено'; + + @override + String get userStatusAtTheOffice => 'В офісі'; + + @override + String get updateStatusErrorTitle => + 'Помилка оновлення статусу користувача. Спробуйте ще раз.'; + + @override + String get searchMessagesPageTitle => 'Пошук'; + + @override + String get searchMessagesHintText => 'Пошук'; + + @override + String get searchMessagesClearButtonTooltip => 'Очистити'; + + @override + String get inboxPageTitle => 'Вхідні'; + + @override + String get inboxEmptyPlaceholder => + 'Немає непрочитаних вхідних повідомлень. Використовуйте кнопки знизу для перегляду обʼєднаної стрічки або списку каналів.'; + + @override + String get recentDmConversationsPageTitle => 'Особисті повідомлення'; + + @override + String get recentDmConversationsSectionHeader => 'Особисті повідомлення'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'У вас поки що немає особистих повідомлень! Чому б не розпочати бесіду?'; + + @override + String get combinedFeedPageTitle => 'Об\'єднана стрічка'; + + @override + String get mentionsPageTitle => 'Згадки'; + + @override + String get starredMessagesPageTitle => 'Вибрані повідомлення'; + + @override + String get channelsPageTitle => 'Канали'; + + @override + String get channelsEmptyPlaceholder => 'Ви ще не підписані на жодний канал.'; + + @override + String get sharePageTitle => 'Поділитися'; + + @override + String get mainMenuMyProfile => 'Мій профіль'; + + @override + String get topicsButtonTooltip => 'Теми'; + + @override + String get channelFeedButtonTooltip => 'Стрічка каналу'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers іншим', + one: '1 іншому', + ); + return '$senderFullName вам і $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Закріплені'; + + @override + String get unpinnedSubscriptionsLabel => 'Відкріплені'; + + @override + String get notifSelfUser => 'Ви'; + + @override + String get reactedEmojiSelfUser => 'Ви'; + + @override + String get reactionChipsLabel => 'Реакції'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } + + @override + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: 'Ви і $otherUsersCount інші', + one: 'Ви та ще 1 особа', + ); + return '$_temp0'; + } + + @override + String onePersonTyping(String typist) { + return '$typist друкує…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist і $otherTypist друкують…'; + } + + @override + String get manyPeopleTyping => 'Кілька людей друкують…'; + + @override + String get wildcardMentionAll => 'усі'; + + @override + String get wildcardMentionEveryone => 'усі'; + + @override + String get wildcardMentionChannel => 'канал'; + + @override + String get wildcardMentionStream => 'канал'; + + @override + String get wildcardMentionTopic => 'тема'; + + @override + String get wildcardMentionChannelDescription => 'Повідомити канал'; + + @override + String get wildcardMentionStreamDescription => 'Повідомити канал'; + + @override + String get wildcardMentionAllDmDescription => 'Повідомити одержувачів'; + + @override + String get wildcardMentionTopicDescription => 'Повідомити канал'; + + @override + String get messageIsEditedLabel => 'РЕДАГОВАНО'; + + @override + String get messageIsMovedLabel => 'ПЕРЕМІЩЕНО'; + + @override + String get messageNotSentLabel => 'ПОВІДОМЛЕННЯ НЕ ВІДПРАВЛЕНО'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => 'ТЕМА'; + + @override + String get themeSettingDark => 'Темна'; + + @override + String get themeSettingLight => 'Світла'; + + @override + String get themeSettingSystem => 'Системна'; + + @override + String get openLinksWithInAppBrowser => + 'Відкривати посилання за допомогою браузера додатку'; + + @override + String get pollWidgetQuestionMissing => 'Немає питання.'; + + @override + String get pollWidgetOptionsMissing => + 'У цьому опитуванні ще немає варіантів.'; + + @override + String get initialAnchorSettingTitle => 'Де відкривати стрічку повідомлень'; + + @override + String get initialAnchorSettingDescription => + 'Можна відкривати стрічку повідомлень на першому непрочитаному повідомленні або на найновішому.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => + 'Перше непрочитане повідомлення'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'Перше непрочитане повідомлення при перегляді бесід, найновіше у інших місцях'; + + @override + String get initialAnchorSettingNewestAlways => 'Найновіше повідомлення'; + + @override + String get markReadOnScrollSettingTitle => + 'Відмічати повідомлення як прочитані при прокручуванні'; + + @override + String get markReadOnScrollSettingDescription => + 'При прокручуванні повідомлень автоматично відмічати їх як прочитані?'; + + @override + String get markReadOnScrollSettingAlways => 'Завжди'; + + @override + String get markReadOnScrollSettingNever => 'Ніколи'; + + @override + String get markReadOnScrollSettingConversations => + 'Тільки при перегляді бесід'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Повідомлення будуть автоматично помічатися як прочитані тільки при перегляді окремої теми або особистої бесіди.'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Експериментальні функції'; + + @override + String get experimentalFeatureSettingsWarning => + 'Ці опції вмикають функції, які ще розробляються та не готові. Вони можуть не працювати та викликати проблеми в інших місцях додатку.\n\nМетою цих налаштувань є експериментування людьми, що працюють над розробкою Zulip.'; + + @override + String get errorNotificationOpenTitle => 'Не вдалося відкрити сповіщення'; + + @override + String get errorNotificationOpenAccountNotFound => + 'Обліковий запис, звʼязаний з цим сповіщенням, не знайдений.'; + + @override + String get errorReactionAddingFailedTitle => 'Не вдалося додати реакцію'; + + @override + String get errorReactionRemovingFailedTitle => 'Не вдалося видалити реакцію'; + + @override + String get errorSharingTitle => 'Не вдалося поділитися контентом'; + + @override + String get errorSharingAccountNotLoggedIn => + 'Немає облікового запису, в який ви ввійшли. Будь ласка, увійдіть в обліковий запис і спробуйте ще раз..'; + + @override + String get emojiReactionsMore => 'більше'; + + @override + String get emojiPickerSearchEmoji => 'Пошук емодзі'; + + @override + String get noEarlierMessages => 'Немає попередніх повідомлень'; + + @override + String get revealButtonLabel => 'Показати повідомлення'; + + @override + String get mutedUser => 'Заглушений користувач'; + + @override + String get scrollToBottomTooltip => 'Прокрутити вниз'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; +} diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart new file mode 100644 index 0000000000..00e7ed864e --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -0,0 +1,3275 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class ZulipLocalizationsZh extends ZulipLocalizations { + ZulipLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get aboutPageTitle => 'About Zulip'; + + @override + String get aboutPageAppVersion => 'App version'; + + @override + String get aboutPageOpenSourceLicenses => 'Open-source licenses'; + + @override + String get aboutPageTapToView => 'Tap to view'; + + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + + @override + String get chooseAccountPageTitle => 'Choose account'; + + @override + String get settingsPageTitle => 'Settings'; + + @override + String get switchAccountButton => 'Switch account'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Your account at $url is taking a while to load.'; + } + + @override + String get tryAnotherAccountButton => 'Try another account'; + + @override + String get chooseAccountPageLogOutButton => 'Log out'; + + @override + String get logOutConfirmationDialogTitle => 'Log out?'; + + @override + String get logOutConfirmationDialogMessage => + 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Log out'; + + @override + String get chooseAccountButtonAddAnAccount => 'Add an account'; + + @override + String get profileButtonSendDirectMessage => 'Send direct message'; + + @override + String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + + @override + String get permissionsNeededTitle => 'Permissions needed'; + + @override + String get permissionsNeededOpenSettings => 'Open settings'; + + @override + String get permissionsDeniedCameraAccess => + 'To upload an image, please grant Zulip additional permissions in Settings.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'To upload files, please grant Zulip additional permissions in Settings.'; + + @override + String get actionSheetOptionSubscribe => 'Subscribe'; + + @override + String get subscribeFailedTitle => 'Failed to subscribe'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + + @override + String get actionSheetOptionCopyChannelLink => 'Copy link to channel'; + + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + + @override + String get actionSheetOptionUnsubscribe => 'Unsubscribe'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return 'Unsubscribe from $channelName?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + 'Once you leave this channel, you might not be able to rejoin.'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe'; + + @override + String get unsubscribeFailedTitle => 'Failed to unsubscribe'; + + @override + String get actionSheetOptionMuteTopic => 'Mute topic'; + + @override + String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + + @override + String get actionSheetOptionFollowTopic => 'Follow topic'; + + @override + String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + + @override + String get actionSheetOptionResolveTopic => 'Mark as resolved'; + + @override + String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + + @override + String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + + @override + String get errorUnresolveTopicFailedTitle => + 'Failed to mark topic as unresolved'; + + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num total)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + + @override + String get actionSheetOptionCopyMessageText => 'Copy message text'; + + @override + String get actionSheetOptionCopyMessageLink => 'Copy link to message'; + + @override + String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + + @override + String get actionSheetOptionShare => 'Share'; + + @override + String get actionSheetOptionQuoteMessage => 'Quote message'; + + @override + String get actionSheetOptionStarMessage => 'Star message'; + + @override + String get actionSheetOptionUnstarMessage => 'Unstar message'; + + @override + String get actionSheetOptionEditMessage => 'Edit message'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; + + @override + String get errorWebAuthOperationalError => 'An unexpected error occurred.'; + + @override + String get errorAccountLoggedInTitle => 'Account already logged in'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'The account $email at $server is already in your list of accounts.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Could not fetch message source.'; + + @override + String get errorCopyingFailed => 'Copying failed'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return 'Failed to upload file: $filename'; + } + + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num files are', + one: 'File is', + ); + return '$_temp0 larger than the server\'s limit of $maxFileUploadSizeMib MiB and will not be uploaded:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'Files', + one: 'File', + ); + return '$_temp0 too large'; + } + + @override + String get errorLoginInvalidInputTitle => 'Invalid input'; + + @override + String get errorLoginFailedTitle => 'Login failed'; + + @override + String get errorMessageNotSent => 'Message not sent'; + + @override + String get errorMessageEditNotSaved => 'Message not saved'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'Failed to connect to server:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => 'Could not connect'; + + @override + String get errorMessageDoesNotSeemToExist => + 'That message does not seem to exist.'; + + @override + String get errorQuotationFailed => 'Quotation failed'; + + @override + String errorServerMessage(String message) { + return 'The server said:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Error connecting to Zulip. Retrying…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Error connecting to Zulip at $serverUrl. Will retry:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Error handling a Zulip event. Retrying connection…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Unable to open link'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Link could not be opened: $url'; + } + + @override + String get errorMuteTopicFailed => 'Failed to mute topic'; + + @override + String get errorUnmuteTopicFailed => 'Failed to unmute topic'; + + @override + String get errorFollowTopicFailed => 'Failed to follow topic'; + + @override + String get errorUnfollowTopicFailed => 'Failed to unfollow topic'; + + @override + String get errorSharingFailed => 'Sharing failed'; + + @override + String get errorStarMessageFailedTitle => 'Failed to star message'; + + @override + String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + + @override + String get successLinkCopied => 'Link copied'; + + @override + String get successMessageTextCopied => 'Message text copied'; + + @override + String get successMessageLinkCopied => 'Message link copied'; + + @override + String get successTopicLinkCopied => 'Topic link copied'; + + @override + String get successChannelLinkCopied => 'Channel link copied'; + + @override + String get errorBannerDeactivatedDmLabel => + 'You cannot send messages to deactivated users.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'You do not have permission to post in this channel.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + + @override + String get composeBoxAttachFilesTooltip => 'Attach files'; + + @override + String get composeBoxAttachMediaTooltip => 'Attach images or videos'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + + @override + String get composeBoxGenericContentHint => 'Type a message'; + + @override + String get newDmSheetComposeButtonLabel => 'Compose'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + + @override + String composeBoxDmContentHint(String user) { + return 'Message @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Message group'; + + @override + String get composeBoxSelfDmContentHint => 'Jot down something'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Preparing…'; + + @override + String get composeBoxSendTooltip => 'Send'; + + @override + String get unknownChannelName => '(unknown channel)'; + + @override + String get composeBoxTopicHintText => 'Topic'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Uploading $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(loading message $messageId)'; + } + + @override + String get unknownUserName => '(unknown user)'; + + @override + String get dmsWithYourselfPageTitle => 'DMs with yourself'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'You and $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'DMs with $others'; + } + + @override + String get emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + + @override + String get messageListGroupYouWithYourself => 'Messages with yourself'; + + @override + String get contentValidationErrorTooLong => + 'Message length shouldn\'t be greater than 10000 characters.'; + + @override + String get contentValidationErrorEmpty => 'You have nothing to send!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Please wait for the quotation to complete.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Please wait for the upload to complete.'; + + @override + String get dialogCancel => 'Cancel'; + + @override + String get dialogContinue => 'Continue'; + + @override + String get dialogClose => 'Close'; + + @override + String get errorDialogLearnMore => 'Learn more'; + + @override + String get errorDialogContinue => 'OK'; + + @override + String get errorDialogTitle => 'Error'; + + @override + String get snackBarDetails => 'Details'; + + @override + String get lightboxCopyLinkTooltip => 'Copy link'; + + @override + String get lightboxVideoCurrentPosition => 'Current position'; + + @override + String get lightboxVideoDuration => 'Video duration'; + + @override + String get loginPageTitle => 'Log in'; + + @override + String get loginFormSubmitLabel => 'Log in'; + + @override + String get loginMethodDivider => 'OR'; + + @override + String signInWithFoo(String method) { + return 'Sign in with $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Add an account'; + + @override + String get loginServerUrlLabel => 'Your Zulip server URL'; + + @override + String get loginHidePassword => 'Hide password'; + + @override + String get loginEmailLabel => 'Email address'; + + @override + String get loginErrorMissingEmail => 'Please enter your email.'; + + @override + String get loginPasswordLabel => 'Password'; + + @override + String get loginErrorMissingPassword => 'Please enter your password.'; + + @override + String get loginUsernameLabel => 'Username'; + + @override + String get loginErrorMissingUsername => 'Please enter your username.'; + + @override + String get topicValidationErrorTooLong => + 'Topic length shouldn\'t be greater than 60 characters.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Topics are required in this organization.'; + + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => + 'The file to be inserted is empty or cannot be accessed.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } + + @override + String get errorInvalidResponse => 'The server sent an invalid response.'; + + @override + String get errorNetworkRequestFailed => 'Network request failed'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Server gave malformed response; HTTP status $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Server gave malformed response; HTTP status $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Network request failed: HTTP status $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => 'Unable to play the video.'; + + @override + String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; + + @override + String get serverUrlValidationErrorInvalidUrl => 'Please enter a valid URL.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Please enter the server URL, not your email.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'The server URL must start with http:// or https://.'; + + @override + String get spoilerDefaultHeaderText => 'Spoiler'; + + @override + String get markAllAsReadLabel => 'Mark all messages as read'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messages', + one: '1 message', + ); + return 'Marked $_temp0 as read.'; + } + + @override + String get markAsReadInProgress => 'Marking messages as read…'; + + @override + String get errorMarkAsReadFailedTitle => 'Mark as read failed'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messages', + one: '1 message', + ); + return 'Marked $_temp0 as unread.'; + } + + @override + String get markAsUnreadInProgress => 'Marking messages as unread…'; + + @override + String get errorMarkAsUnreadFailedTitle => 'Mark as unread failed'; + + @override + String get today => 'Today'; + + @override + String get yesterday => 'Yesterday'; + + @override + String get userActiveNow => 'Active now'; + + @override + String get userIdle => 'Idle'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes', + one: '1 minute', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours hours', + one: '1 hour', + ); + return 'Active $_temp0 ago'; + } + + @override + String get userActiveYesterday => 'Active yesterday'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days days', + one: '1 day', + ); + return 'Active $_temp0 ago'; + } + + @override + String userActiveDate(String date) { + return 'Active $date'; + } + + @override + String get userNotActiveInYear => 'Not active in the last year'; + + @override + String get invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + + @override + String get userRoleOwner => 'Owner'; + + @override + String get userRoleAdministrator => 'Administrator'; + + @override + String get userRoleModerator => 'Moderator'; + + @override + String get userRoleMember => 'Member'; + + @override + String get userRoleGuest => 'Guest'; + + @override + String get userRoleUnknown => 'Unknown'; + + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + + @override + String get searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + + @override + String get inboxPageTitle => 'Inbox'; + + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + + @override + String get recentDmConversationsPageTitle => 'Direct messages'; + + @override + String get recentDmConversationsSectionHeader => 'Direct messages'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + + @override + String get combinedFeedPageTitle => 'Combined feed'; + + @override + String get mentionsPageTitle => 'Mentions'; + + @override + String get starredMessagesPageTitle => 'Starred messages'; + + @override + String get channelsPageTitle => 'Channels'; + + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + + @override + String get sharePageTitle => 'Share'; + + @override + String get mainMenuMyProfile => 'My profile'; + + @override + String get topicsButtonTooltip => 'Topics'; + + @override + String get channelFeedButtonTooltip => 'Channel feed'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers others', + one: '1 other', + ); + return '$senderFullName to you and $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Pinned'; + + @override + String get unpinnedSubscriptionsLabel => 'Unpinned'; + + @override + String get notifSelfUser => 'You'; + + @override + String get reactedEmojiSelfUser => 'You'; + + @override + String get reactionChipsLabel => 'Reactions'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } + + @override + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: 'You and $otherUsersCount others', + one: 'You and 1 other', + ); + return '$_temp0'; + } + + @override + String onePersonTyping(String typist) { + return '$typist is typing…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist and $otherTypist are typing…'; + } + + @override + String get manyPeopleTyping => 'Several people are typing…'; + + @override + String get wildcardMentionAll => 'all'; + + @override + String get wildcardMentionEveryone => 'everyone'; + + @override + String get wildcardMentionChannel => 'channel'; + + @override + String get wildcardMentionStream => 'stream'; + + @override + String get wildcardMentionTopic => 'topic'; + + @override + String get wildcardMentionChannelDescription => 'Notify channel'; + + @override + String get wildcardMentionStreamDescription => 'Notify stream'; + + @override + String get wildcardMentionAllDmDescription => 'Notify recipients'; + + @override + String get wildcardMentionTopicDescription => 'Notify topic'; + + @override + String get messageIsEditedLabel => 'EDITED'; + + @override + String get messageIsMovedLabel => 'MOVED'; + + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => 'THEME'; + + @override + String get themeSettingDark => 'Dark'; + + @override + String get themeSettingLight => 'Light'; + + @override + String get themeSettingSystem => 'System'; + + @override + String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + + @override + String get pollWidgetQuestionMissing => 'No question.'; + + @override + String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in conversation views, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Experimental features'; + + @override + String get experimentalFeatureSettingsWarning => + 'These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.'; + + @override + String get errorNotificationOpenTitle => 'Failed to open notification'; + + @override + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; + + @override + String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + + @override + String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + + @override + String get emojiReactionsMore => 'more'; + + @override + String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get noEarlierMessages => 'No earlier messages'; + + @override + String get revealButtonLabel => 'Reveal message'; + + @override + String get mutedUser => 'Muted user'; + + @override + String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; +} + +/// The translations for Chinese, as used in China, using the Han script (`zh_Hans_CN`). +class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { + ZulipLocalizationsZhHansCn() : super('zh_Hans_CN'); + + @override + String get aboutPageTitle => '关于 Zulip'; + + @override + String get aboutPageAppVersion => '应用程序版本'; + + @override + String get aboutPageOpenSourceLicenses => '开源许可'; + + @override + String get aboutPageTapToView => '查看更多'; + + @override + String get upgradeWelcomeDialogTitle => '欢迎来到新的 Zulip 应用程序!'; + + @override + String get upgradeWelcomeDialogMessage => '您将在更快、更流畅的版本中享受熟悉的体验。'; + + @override + String get upgradeWelcomeDialogLinkText => '来看看最新的公告博客吧!'; + + @override + String get upgradeWelcomeDialogDismiss => '开始吧'; + + @override + String get chooseAccountPageTitle => '选择账号'; + + @override + String get settingsPageTitle => '设置'; + + @override + String get switchAccountButton => '切换账号'; + + @override + String tryAnotherAccountMessage(Object url) { + return '您在 $url 的账号加载时间过长。'; + } + + @override + String get tryAnotherAccountButton => '尝试另一个账号'; + + @override + String get chooseAccountPageLogOutButton => '登出'; + + @override + String get logOutConfirmationDialogTitle => '登出?'; + + @override + String get logOutConfirmationDialogMessage => '下次登入此账号时,您将需要重新输入组织网址和账号信息。'; + + @override + String get logOutConfirmationDialogConfirmButton => '登出'; + + @override + String get chooseAccountButtonAddAnAccount => '添加一个账号'; + + @override + String get profileButtonSendDirectMessage => '发送私信'; + + @override + String get errorCouldNotShowUserProfile => '无法显示用户个人资料。'; + + @override + String get permissionsNeededTitle => '需要额外权限'; + + @override + String get permissionsNeededOpenSettings => '打开设置'; + + @override + String get permissionsDeniedCameraAccess => '上传图片前,请在设置中授予 Zulip 相应的权限。'; + + @override + String get permissionsDeniedReadExternalStorage => + '上传文件前,请在设置中授予 Zulip 相应的权限。'; + + @override + String get actionSheetOptionSubscribe => '订阅'; + + @override + String get subscribeFailedTitle => '订阅失败'; + + @override + String get actionSheetOptionMarkChannelAsRead => '标记频道为已读'; + + @override + String get actionSheetOptionCopyChannelLink => '复制频道链接'; + + @override + String get actionSheetOptionListOfTopics => '话题列表'; + + @override + String get actionSheetOptionUnsubscribe => '取消订阅'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return '确定取消订阅$channelName么?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + '一旦退出该频道,您可能无法重新加入。'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => '取消订阅'; + + @override + String get unsubscribeFailedTitle => '取消订阅失败'; + + @override + String get actionSheetOptionMuteTopic => '静音话题'; + + @override + String get actionSheetOptionUnmuteTopic => '取消静音话题'; + + @override + String get actionSheetOptionFollowTopic => '关注话题'; + + @override + String get actionSheetOptionUnfollowTopic => '取消关注话题'; + + @override + String get actionSheetOptionResolveTopic => '标记为已解决'; + + @override + String get actionSheetOptionUnresolveTopic => '标记为未解决'; + + @override + String get errorResolveTopicFailedTitle => '未能将话题标记为解决'; + + @override + String get errorUnresolveTopicFailedTitle => '未能将话题标记为未解决'; + + @override + String get actionSheetOptionSeeWhoReacted => '查看谁做出了表情符号回应'; + + @override + String get seeWhoReactedSheetNoReactions => '此消息尚无表情符号回应。'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return '表情符号回应(共$num个)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num 票', + one: '1 票', + ); + return '$emojiName:$_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return '$emojiName 的投票数($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => '查看已读回执'; + + @override + String get actionSheetReadReceipts => '已读回执'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '此消息已被阅读,共有 $count 人:', + one: '此消息已被阅读,共有 $count 人:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => '尚无人阅读此消息。'; + + @override + String get actionSheetReadReceiptsErrorReadCount => '加载已读回执失败。'; + + @override + String get actionSheetOptionCopyMessageText => '复制消息文本'; + + @override + String get actionSheetOptionCopyMessageLink => '复制消息链接'; + + @override + String get actionSheetOptionMarkAsUnread => '从这里开始标为未读'; + + @override + String get actionSheetOptionHideMutedMessage => '再次隐藏静音消息'; + + @override + String get actionSheetOptionShare => '分享'; + + @override + String get actionSheetOptionQuoteMessage => '引用消息'; + + @override + String get actionSheetOptionStarMessage => '添加星标消息标记'; + + @override + String get actionSheetOptionUnstarMessage => '取消星标消息标记'; + + @override + String get actionSheetOptionEditMessage => '编辑消息'; + + @override + String get actionSheetOptionMarkTopicAsRead => '将话题标为已读'; + + @override + String get actionSheetOptionCopyTopicLink => '复制话题链接'; + + @override + String get errorWebAuthOperationalErrorTitle => '出现了一些问题'; + + @override + String get errorWebAuthOperationalError => '发生了未知的错误。'; + + @override + String get errorAccountLoggedInTitle => '已经登入该账号'; + + @override + String errorAccountLoggedIn(String email, String server) { + return '在 $server 的账号 $email 已经在您的账号列表了。'; + } + + @override + String get errorCouldNotFetchMessageSource => '未能获取原始消息。'; + + @override + String get errorCopyingFailed => '未能复制消息文本'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return '未能上传文件:$filename'; + } + + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num 个您上传的文件', + ); + return '$_temp0大小超过了该组织 $maxFileUploadSizeMib MiB 的限制:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '文件', + one: '文件', + ); + return '$_temp0太大'; + } + + @override + String get errorLoginInvalidInputTitle => '输入的信息不正确'; + + @override + String get errorLoginFailedTitle => '未能登入'; + + @override + String get errorMessageNotSent => '未能发送消息'; + + @override + String get errorMessageEditNotSaved => '未能保存消息编辑'; + + @override + String errorLoginCouldNotConnect(String url) { + return '未能连接到服务器:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => '未能连接'; + + @override + String get errorMessageDoesNotSeemToExist => '找不到此消息。'; + + @override + String get errorQuotationFailed => '未能引用消息'; + + @override + String errorServerMessage(String message) { + return '服务器:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => '未能连接到 Zulip. 重试中…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return '未能连接到在 $serverUrl 的 Zulip 服务器。即将重连:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => '处理 Zulip 事件时发生了一些问题。即将重连…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return '处理来自 $serverUrl 的 Zulip 事件时发生了一些问题。即将重连。\n\n错误:$error\n\n事件:$event'; + } + + @override + String get errorCouldNotOpenLinkTitle => '未能打开链接'; + + @override + String errorCouldNotOpenLink(String url) { + return '未能打开此链接:$url'; + } + + @override + String get errorMuteTopicFailed => '未能静音话题'; + + @override + String get errorUnmuteTopicFailed => '未能取消静音话题'; + + @override + String get errorFollowTopicFailed => '未能关注话题'; + + @override + String get errorUnfollowTopicFailed => '未能取消关注话题'; + + @override + String get errorSharingFailed => '分享失败'; + + @override + String get errorStarMessageFailedTitle => '未能添加星标消息标记'; + + @override + String get errorUnstarMessageFailedTitle => '未能取消星标消息标记'; + + @override + String get errorCouldNotEditMessageTitle => '未能编辑消息'; + + @override + String get successLinkCopied => '已复制链接'; + + @override + String get successMessageTextCopied => '已复制消息文本'; + + @override + String get successMessageLinkCopied => '已复制消息链接'; + + @override + String get successTopicLinkCopied => '话题链接已复制'; + + @override + String get successChannelLinkCopied => '频道链接已复制'; + + @override + String get errorBannerDeactivatedDmLabel => '您不能向被停用的用户发送消息。'; + + @override + String get errorBannerCannotPostInChannelLabel => '您没有足够的权限在此频道发送消息。'; + + @override + String get composeBoxBannerLabelEditMessage => '编辑消息'; + + @override + String get composeBoxBannerButtonCancel => '取消'; + + @override + String get composeBoxBannerButtonSave => '保存'; + + @override + String get editAlreadyInProgressTitle => '未能编辑消息'; + + @override + String get editAlreadyInProgressMessage => '已有正在被编辑的消息。请在其完成后重试。'; + + @override + String get savingMessageEditLabel => '保存中…'; + + @override + String get savingMessageEditFailedLabel => '编辑失败'; + + @override + String get discardDraftConfirmationDialogTitle => '放弃您正在撰写的消息?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + '当您编辑消息时,文本框中已有的内容将会被清空。'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + '当您恢复未能发送的消息时,文本框已有的内容将会被清空。'; + + @override + String get discardDraftConfirmationDialogConfirmButton => '清空'; + + @override + String get composeBoxAttachFilesTooltip => '上传文件'; + + @override + String get composeBoxAttachMediaTooltip => '上传图片或视频'; + + @override + String get composeBoxAttachFromCameraTooltip => '拍摄照片'; + + @override + String get composeBoxGenericContentHint => '撰写消息'; + + @override + String get newDmSheetComposeButtonLabel => '撰写消息'; + + @override + String get newDmSheetScreenTitle => '发起私信'; + + @override + String get newDmFabButtonLabel => '发起私信'; + + @override + String get newDmSheetSearchHintEmpty => '添加一个或多个用户'; + + @override + String get newDmSheetSearchHintSomeSelected => '添加更多用户…'; + + @override + String get newDmSheetNoUsersFound => '没有用户'; + + @override + String composeBoxDmContentHint(String user) { + return '发送私信给 @$user'; + } + + @override + String get composeBoxGroupDmContentHint => '发送私信到群组'; + + @override + String get composeBoxSelfDmContentHint => '向自己撰写消息'; + + @override + String composeBoxChannelContentHint(String destination) { + return '发送消息到 $destination'; + } + + @override + String get preparingEditMessageContentInput => '准备编辑消息…'; + + @override + String get composeBoxSendTooltip => '发送'; + + @override + String get unknownChannelName => '(未知频道)'; + + @override + String get composeBoxTopicHintText => '话题'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return '输入话题(默认为“$defaultTopicName”)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return '正在上传 $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(加载消息 $messageId)'; + } + + @override + String get unknownUserName => '(未知用户)'; + + @override + String get dmsWithYourselfPageTitle => '与自己的私信'; + + @override + String messageListGroupYouAndOthers(String others) { + return '您和$others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return '与$others的私信'; + } + + @override + String get emptyMessageList => '这里没有消息。'; + + @override + String get emptyMessageListSearch => '没有搜索结果。'; + + @override + String get messageListGroupYouWithYourself => '与自己的私信'; + + @override + String get contentValidationErrorTooLong => '消息的长度不能超过10000个字符。'; + + @override + String get contentValidationErrorEmpty => '发送的消息不能为空!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => '请等待引用消息完成。'; + + @override + String get contentValidationErrorUploadInProgress => '请等待上传完成。'; + + @override + String get dialogCancel => '取消'; + + @override + String get dialogContinue => '继续'; + + @override + String get dialogClose => '关闭'; + + @override + String get errorDialogLearnMore => '更多信息'; + + @override + String get errorDialogContinue => '好的'; + + @override + String get errorDialogTitle => '错误'; + + @override + String get snackBarDetails => '详情'; + + @override + String get lightboxCopyLinkTooltip => '复制链接'; + + @override + String get lightboxVideoCurrentPosition => '当前进度'; + + @override + String get lightboxVideoDuration => '视频时长'; + + @override + String get loginPageTitle => '登入'; + + @override + String get loginFormSubmitLabel => '登入'; + + @override + String get loginMethodDivider => '或'; + + @override + String signInWithFoo(String method) { + return '使用$method登入'; + } + + @override + String get loginAddAnAccountPageTitle => '添加账号'; + + @override + String get loginServerUrlLabel => 'Zulip 服务器网址'; + + @override + String get loginHidePassword => '隐藏密码'; + + @override + String get loginEmailLabel => '电子邮箱地址'; + + @override + String get loginErrorMissingEmail => '请输入电子邮箱地址。'; + + @override + String get loginPasswordLabel => '密码'; + + @override + String get loginErrorMissingPassword => '请输入密码。'; + + @override + String get loginUsernameLabel => '用户名'; + + @override + String get loginErrorMissingUsername => '请输入用户名。'; + + @override + String get topicValidationErrorTooLong => '话题长度不应该超过 60 个字符。'; + + @override + String get topicValidationErrorMandatoryButEmpty => '话题在该组织为必填项。'; + + @override + String get errorContentNotInsertedTitle => '未插入内容'; + + @override + String get errorContentToInsertIsEmpty => '要插入的文件为空或无法访问。'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url 运行的 Zulip 服务器版本 $zulipVersion 过低。该客户端只支持 $minSupportedZulipVersion 及以后的服务器版本。'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return '您在 $url 的账号无法被登入。请重试或者使用另外的账号。'; + } + + @override + String get errorInvalidResponse => '服务器的回复不合法。'; + + @override + String get errorNetworkRequestFailed => '网络请求失败'; + + @override + String errorMalformedResponse(int httpStatus) { + return '服务器的回复不合法;HTTP 状态码 $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return '服务器的回复不合法;HTTP 状态码 $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return '网络请求失败;HTTP 状态码 $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => '未能播放视频。'; + + @override + String get serverUrlValidationErrorEmpty => '请输入网址。'; + + @override + String get serverUrlValidationErrorInvalidUrl => '请输入正确的网址。'; + + @override + String get serverUrlValidationErrorNoUseEmail => '请输入服务器网址,而不是您的电子邮件。'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + '服务器网址必须以 http:// 或 https:// 开头。'; + + @override + String get spoilerDefaultHeaderText => '剧透'; + + @override + String get markAllAsReadLabel => '将所有消息标为已读'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num 条消息', + ); + return '已将 $_temp0标为已读。'; + } + + @override + String get markAsReadInProgress => '正在将消息标为已读…'; + + @override + String get errorMarkAsReadFailedTitle => '未能将消息标为已读'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num 条消息', + ); + return '已将 $_temp0标为未读。'; + } + + @override + String get markAsUnreadInProgress => '正在将消息标为未读…'; + + @override + String get errorMarkAsUnreadFailedTitle => '未能将消息标为未读'; + + @override + String get today => '今天'; + + @override + String get yesterday => '昨天'; + + @override + String get userActiveNow => '当前活跃'; + + @override + String get userIdle => '空闲'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes 分钟前', + one: '1 分钟前', + ); + return '上次活跃于 $_temp0'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours 小时前', + one: '1 小时前', + ); + return '上次活跃于 $_temp0'; + } + + @override + String get userActiveYesterday => '昨天活跃'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days 天前', + one: '1 天前', + ); + return '上次活跃于 $_temp0'; + } + + @override + String userActiveDate(String date) { + return '上次活跃于 $date'; + } + + @override + String get userNotActiveInYear => '去年未活跃'; + + @override + String get invisibleMode => '隐身模式'; + + @override + String get turnOnInvisibleModeErrorTitle => '启用隐身模式时发生错误。请再尝试一次。'; + + @override + String get turnOffInvisibleModeErrorTitle => '关闭隐身模式时发生错误。请再尝试一次。'; + + @override + String get userRoleOwner => '所有者'; + + @override + String get userRoleAdministrator => '管理员'; + + @override + String get userRoleModerator => '版主'; + + @override + String get userRoleMember => '成员'; + + @override + String get userRoleGuest => '访客'; + + @override + String get userRoleUnknown => '未知'; + + @override + String get statusButtonLabelStatusSet => '状态'; + + @override + String get statusButtonLabelStatusUnset => '设定状态'; + + @override + String get noStatusText => '无状态文字'; + + @override + String get setStatusPageTitle => '设定状态'; + + @override + String get statusClearButtonLabel => '清除'; + + @override + String get statusSaveButtonLabel => '保存'; + + @override + String get statusTextHint => '您的状态'; + + @override + String get userStatusBusy => '忙碌'; + + @override + String get userStatusInAMeeting => '会议中'; + + @override + String get userStatusCommuting => '通勤中'; + + @override + String get userStatusOutSick => '病假中'; + + @override + String get userStatusVacationing => '休假中'; + + @override + String get userStatusWorkingRemotely => '远程工作中'; + + @override + String get userStatusAtTheOffice => '在办公室'; + + @override + String get updateStatusErrorTitle => '更新用户状态时发生错误。请再试一次。'; + + @override + String get searchMessagesPageTitle => '搜索'; + + @override + String get searchMessagesHintText => '搜索'; + + @override + String get searchMessagesClearButtonTooltip => '清除'; + + @override + String get inboxPageTitle => '收件箱'; + + @override + String get inboxEmptyPlaceholder => '您的收件箱中没有未读消息。您可以通过底部导航栏访问综合消息或者频道列表。'; + + @override + String get recentDmConversationsPageTitle => '私信'; + + @override + String get recentDmConversationsSectionHeader => '私信'; + + @override + String get recentDmConversationsEmptyPlaceholder => '您还没有任何私信消息!何不开启一个新对话?'; + + @override + String get combinedFeedPageTitle => '综合消息'; + + @override + String get mentionsPageTitle => '被提及消息'; + + @override + String get starredMessagesPageTitle => '星标消息'; + + @override + String get channelsPageTitle => '频道'; + + @override + String get channelsEmptyPlaceholder => '您还没有订阅任何频道。'; + + @override + String get sharePageTitle => '分享'; + + @override + String get mainMenuMyProfile => '个人资料'; + + @override + String get topicsButtonTooltip => '话题'; + + @override + String get channelFeedButtonTooltip => '频道订阅'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers 个用户', + ); + return '$senderFullName向您和其他 $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => '置顶'; + + @override + String get unpinnedSubscriptionsLabel => '未置顶'; + + @override + String get notifSelfUser => '您'; + + @override + String get reactedEmojiSelfUser => '您'; + + @override + String get reactionChipsLabel => '表情符号回应'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } + + @override + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: '你与其他 $otherUsersCount 人', + one: '你与其他 1 人', + ); + return '$_temp0'; + } + + @override + String onePersonTyping(String typist) { + return '$typist正在输入…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist和$otherTypist正在输入…'; + } + + @override + String get manyPeopleTyping => '多个用户正在输入…'; + + @override + String get wildcardMentionAll => '所有人'; + + @override + String get wildcardMentionEveryone => '所有人'; + + @override + String get wildcardMentionChannel => '频道'; + + @override + String get wildcardMentionStream => '频道'; + + @override + String get wildcardMentionTopic => '话题'; + + @override + String get wildcardMentionChannelDescription => '通知频道'; + + @override + String get wildcardMentionStreamDescription => '通知频道'; + + @override + String get wildcardMentionAllDmDescription => '通知收件人'; + + @override + String get wildcardMentionTopicDescription => '通知话题'; + + @override + String get messageIsEditedLabel => '已编辑'; + + @override + String get messageIsMovedLabel => '已移动'; + + @override + String get messageNotSentLabel => '消息未发送'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => '主题'; + + @override + String get themeSettingDark => '暗色模式'; + + @override + String get themeSettingLight => '浅色模式'; + + @override + String get themeSettingSystem => '跟随系统'; + + @override + String get openLinksWithInAppBrowser => '使用内置浏览器打开链接'; + + @override + String get pollWidgetQuestionMissing => '无问题。'; + + @override + String get pollWidgetOptionsMissing => '该投票还没有任何选项。'; + + @override + String get initialAnchorSettingTitle => '设置消息起始位置于'; + + @override + String get initialAnchorSettingDescription => '您可以将消息的起始位置设置为第一条未读消息或者最新消息。'; + + @override + String get initialAnchorSettingFirstUnreadAlways => '第一条未读消息'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + '在单个话题或私信的第一条未读消息;在其他情况下的最新消息'; + + @override + String get initialAnchorSettingNewestAlways => '最新消息'; + + @override + String get markReadOnScrollSettingTitle => '滑动时将消息标为已读'; + + @override + String get markReadOnScrollSettingDescription => '在滑动浏览消息时,是否自动将它们标记为已读?'; + + @override + String get markReadOnScrollSettingAlways => '总是'; + + @override + String get markReadOnScrollSettingNever => '从不'; + + @override + String get markReadOnScrollSettingConversations => '只在对话视图'; + + @override + String get markReadOnScrollSettingConversationsDescription => + '只将在同一个话题或私聊中的消息自动标记为已读。'; + + @override + String get experimentalFeatureSettingsPageTitle => '实验功能'; + + @override + String get experimentalFeatureSettingsWarning => + '以下选项能够启用开发中的功能。它们暂不完善,并可能造成其他的一些问题。\n\n这些选项的目的是为了帮助开发者进行实验。'; + + @override + String get errorNotificationOpenTitle => '未能打开消息提醒'; + + @override + String get errorNotificationOpenAccountNotFound => '未能找到关联该消息提醒的账号。'; + + @override + String get errorReactionAddingFailedTitle => '未能添加表情符号'; + + @override + String get errorReactionRemovingFailedTitle => '未能移除表情符号'; + + @override + String get errorSharingTitle => '分享内容失败'; + + @override + String get errorSharingAccountNotLoggedIn => '尚未登录任何账号。请登录账号后再次尝试。'; + + @override + String get emojiReactionsMore => '更多'; + + @override + String get emojiPickerSearchEmoji => '搜索表情符号'; + + @override + String get noEarlierMessages => '没有更早的消息了'; + + @override + String get revealButtonLabel => '显示消息'; + + @override + String get mutedUser => '静音用户'; + + @override + String get scrollToBottomTooltip => '拖动到最底'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; +} + +/// The translations for Chinese, as used in Taiwan, using the Han script (`zh_Hant_TW`). +class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { + ZulipLocalizationsZhHantTw() : super('zh_Hant_TW'); + + @override + String get aboutPageTitle => '關於 Zulip'; + + @override + String get aboutPageAppVersion => 'App 版本'; + + @override + String get aboutPageOpenSourceLicenses => '開源授權條款'; + + @override + String get aboutPageTapToView => '點選查看'; + + @override + String get upgradeWelcomeDialogTitle => '歡迎使用新 Zulip 應用程式!'; + + @override + String get upgradeWelcomeDialogMessage => '您將在更快、更流暢的版本中享受熟悉的體驗。'; + + @override + String get upgradeWelcomeDialogLinkText => '查看公告部落格文章!'; + + @override + String get upgradeWelcomeDialogDismiss => '開始吧'; + + @override + String get chooseAccountPageTitle => '選取帳號'; + + @override + String get settingsPageTitle => '設定'; + + @override + String get switchAccountButton => '切換帳號'; + + @override + String tryAnotherAccountMessage(Object url) { + return '您在 $url 的帳號載入的比較久。'; + } + + @override + String get tryAnotherAccountButton => '請嘗試別的帳號'; + + @override + String get chooseAccountPageLogOutButton => '登出'; + + @override + String get logOutConfirmationDialogTitle => '登出?'; + + @override + String get logOutConfirmationDialogMessage => + '要在未來使用此帳號,您將需要重新輸入您組織的網址和您的帳號資訊。'; + + @override + String get logOutConfirmationDialogConfirmButton => '登出'; + + @override + String get chooseAccountButtonAddAnAccount => '增添帳號'; + + @override + String get profileButtonSendDirectMessage => '發送私訊'; + + @override + String get errorCouldNotShowUserProfile => '無法顯示使用者設定檔。'; + + @override + String get permissionsNeededTitle => '需要的權限'; + + @override + String get permissionsNeededOpenSettings => '開啟設定'; + + @override + String get permissionsDeniedCameraAccess => '要上傳圖片,請在設定中授予 Zulip 額外權限。'; + + @override + String get permissionsDeniedReadExternalStorage => + '要上傳檔案,請在設定中授予 Zulip 額外權限。'; + + @override + String get actionSheetOptionSubscribe => '訂閱'; + + @override + String get subscribeFailedTitle => '訂閱失敗'; + + @override + String get actionSheetOptionMarkChannelAsRead => '標註頻道為已讀'; + + @override + String get actionSheetOptionCopyChannelLink => '複製頻道連結'; + + @override + String get actionSheetOptionListOfTopics => '議題列表'; + + @override + String get actionSheetOptionUnsubscribe => '取消訂閱'; + + @override + String unsubscribeConfirmationDialogTitle(String channelName) { + return '確定要取消訂閱 $channelName 嗎?'; + } + + @override + String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe => + '一旦您離開此頻道,可能無法重新加入。'; + + @override + String get unsubscribeConfirmationDialogConfirmButton => '取消訂閱'; + + @override + String get unsubscribeFailedTitle => '取消訂閱失敗'; + + @override + String get actionSheetOptionMuteTopic => '靜音話題'; + + @override + String get actionSheetOptionUnmuteTopic => '取消靜音話題'; + + @override + String get actionSheetOptionFollowTopic => '跟隨話題'; + + @override + String get actionSheetOptionUnfollowTopic => '取消跟隨話題'; + + @override + String get actionSheetOptionResolveTopic => '標註為已解決'; + + @override + String get actionSheetOptionUnresolveTopic => '標註為未解決'; + + @override + String get errorResolveTopicFailedTitle => '無法標註話題為已解決'; + + @override + String get errorUnresolveTopicFailedTitle => '無法標註話題為未解決'; + + @override + String get actionSheetOptionSeeWhoReacted => '查看誰有回應'; + + @override + String get seeWhoReactedSheetNoReactions => '此訊息尚無任何回應。'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return '表情符號回應 (共 $num 個)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num 票', + one: '1 票', + ); + return '$emojiName:$_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return '$emojiName 的投票數($num)'; + } + + @override + String get actionSheetOptionViewReadReceipts => '查看已讀回條'; + + @override + String get actionSheetReadReceipts => '已讀回條'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '此訊息已被閱讀,共有 $count 人:', + one: '此訊息已被閱讀,共有 $count 人:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => '尚無人閱讀此訊息。'; + + @override + String get actionSheetReadReceiptsErrorReadCount => '載入已讀回條失敗。'; + + @override + String get actionSheetOptionCopyMessageText => '複製訊息文字'; + + @override + String get actionSheetOptionCopyMessageLink => '複製訊息連結'; + + @override + String get actionSheetOptionMarkAsUnread => '從這裡開始標註為未讀'; + + @override + String get actionSheetOptionHideMutedMessage => '再次隱藏已靜音的話題'; + + @override + String get actionSheetOptionShare => '分享'; + + @override + String get actionSheetOptionQuoteMessage => '引述訊息'; + + @override + String get actionSheetOptionStarMessage => '收藏訊息'; + + @override + String get actionSheetOptionUnstarMessage => '取消收藏訊息'; + + @override + String get actionSheetOptionEditMessage => '編輯訊息'; + + @override + String get actionSheetOptionMarkTopicAsRead => '標註話題為已讀'; + + @override + String get actionSheetOptionCopyTopicLink => '複製議題的連結'; + + @override + String get errorWebAuthOperationalErrorTitle => '出錯了'; + + @override + String get errorWebAuthOperationalError => '出現了意外的錯誤。'; + + @override + String get errorAccountLoggedInTitle => '帳號已經登入了'; + + @override + String errorAccountLoggedIn(String email, String server) { + return '在 $server 的帳號 $email 已經存在帳號清單中。'; + } + + @override + String get errorCouldNotFetchMessageSource => '無法取得訊息來源。'; + + @override + String get errorCopyingFailed => '複製失敗'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return '上傳檔案失敗:$filename'; + } + + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + + @override + String errorFilesTooLarge( + int num, + int maxFileUploadSizeMib, + String listMessage, + ) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num 個檔案', + one: '檔案', + ); + return '$_temp0超過伺服器 $maxFileUploadSizeMib MiB 的限制,將不會上傳:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '檔案', + one: '檔案', + ); + return '$_temp0太大'; + } + + @override + String get errorLoginInvalidInputTitle => '無效的輸入'; + + @override + String get errorLoginFailedTitle => '登入失敗'; + + @override + String get errorMessageNotSent => '訊息沒有送出'; + + @override + String get errorMessageEditNotSaved => '訊息沒有儲存'; + + @override + String errorLoginCouldNotConnect(String url) { + return '無法連線到伺服器:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => '無法連線'; + + @override + String get errorMessageDoesNotSeemToExist => '該訊息似乎不存在。'; + + @override + String get errorQuotationFailed => '引述失敗'; + + @override + String errorServerMessage(String message) { + return '伺服器回應:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => '連接 Zulip 時發生錯誤。重試中…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return '連接 Zulip $serverUrl 時發生錯誤。將重試:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => '處理 Zulip 事件時發生錯誤。重新連線中…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return '處理來自 $serverUrl 的 Zulip 事件時發生錯誤;將重試。\n\n錯誤:$error\n\n事件:$event'; + } + + @override + String get errorCouldNotOpenLinkTitle => '無法開啟連結'; + + @override + String errorCouldNotOpenLink(String url) { + return '無法開啟連結: $url'; + } + + @override + String get errorMuteTopicFailed => '無法靜音話題'; + + @override + String get errorUnmuteTopicFailed => '無法取消靜音話題'; + + @override + String get errorFollowTopicFailed => '無法跟隨話題'; + + @override + String get errorUnfollowTopicFailed => '無法取消跟隨話題'; + + @override + String get errorSharingFailed => '分享失敗'; + + @override + String get errorStarMessageFailedTitle => '無法收藏訊息'; + + @override + String get errorUnstarMessageFailedTitle => '無法取消收藏訊息'; + + @override + String get errorCouldNotEditMessageTitle => '無法編輯訊息'; + + @override + String get successLinkCopied => '已複製連結'; + + @override + String get successMessageTextCopied => '已複製訊息文字'; + + @override + String get successMessageLinkCopied => '已複製訊息連結'; + + @override + String get successTopicLinkCopied => '議題連結已複製'; + + @override + String get successChannelLinkCopied => '頻道連結已複製'; + + @override + String get errorBannerDeactivatedDmLabel => '您無法向已停用的使用者發送訊息。'; + + @override + String get errorBannerCannotPostInChannelLabel => '您沒有權限在此頻道發佈訊息。'; + + @override + String get composeBoxBannerLabelEditMessage => '編輯訊息'; + + @override + String get composeBoxBannerButtonCancel => '取消'; + + @override + String get composeBoxBannerButtonSave => '儲存'; + + @override + String get editAlreadyInProgressTitle => '無法編輯訊息'; + + @override + String get editAlreadyInProgressMessage => '編輯已在進行中。請等待其完成。'; + + @override + String get savingMessageEditLabel => '儲存編輯中…'; + + @override + String get savingMessageEditFailedLabel => '編輯未儲存'; + + @override + String get discardDraftConfirmationDialogTitle => '要捨棄您正在編寫的訊息嗎?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + '當您編輯訊息時,編輯框中原有的內容將被捨棄。'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + '當您還原未發送的訊息時,編輯框中原有的內容將被捨棄。'; + + @override + String get discardDraftConfirmationDialogConfirmButton => '捨棄'; + + @override + String get composeBoxAttachFilesTooltip => '附加檔案'; + + @override + String get composeBoxAttachMediaTooltip => '附加圖片或影片'; + + @override + String get composeBoxAttachFromCameraTooltip => '拍照'; + + @override + String get composeBoxGenericContentHint => '輸入訊息'; + + @override + String get newDmSheetComposeButtonLabel => '編寫'; + + @override + String get newDmSheetScreenTitle => '新增私訊'; + + @override + String get newDmFabButtonLabel => '新增私訊'; + + @override + String get newDmSheetSearchHintEmpty => '增添一個或多個使用者'; + + @override + String get newDmSheetSearchHintSomeSelected => '增添其他使用者…'; + + @override + String get newDmSheetNoUsersFound => '找不到使用者'; + + @override + String composeBoxDmContentHint(String user) { + return '訊息 @$user'; + } + + @override + String get composeBoxGroupDmContentHint => '訊息群組'; + + @override + String get composeBoxSelfDmContentHint => '記下些什麼'; + + @override + String composeBoxChannelContentHint(String destination) { + return '訊息 $destination'; + } + + @override + String get preparingEditMessageContentInput => '準備中…'; + + @override + String get composeBoxSendTooltip => '發送'; + + @override + String get unknownChannelName => '(未知頻道)'; + + @override + String get composeBoxTopicHintText => '議題'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return '輸入議題(留空則使用「$defaultTopicName」)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return '正在上傳 $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(載入訊息 $messageId 中)'; + } + + @override + String get unknownUserName => '(未知使用者)'; + + @override + String get dmsWithYourselfPageTitle => '私訊給自己'; + + @override + String messageListGroupYouAndOthers(String others) { + return '您與 $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return '與 $others 的私訊'; + } + + @override + String get emptyMessageList => '這裡沒有訊息。'; + + @override + String get emptyMessageListSearch => '沒有搜尋結果。'; + + @override + String get messageListGroupYouWithYourself => '與自己的訊息'; + + @override + String get contentValidationErrorTooLong => '訊息長度不應超過 10000 個字元。'; + + @override + String get contentValidationErrorEmpty => '您沒有要發送的內容!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => '請等待引述完成。'; + + @override + String get contentValidationErrorUploadInProgress => '請等待上傳完成。'; + + @override + String get dialogCancel => '取消'; + + @override + String get dialogContinue => '繼續'; + + @override + String get dialogClose => '關閉'; + + @override + String get errorDialogLearnMore => '了解更多'; + + @override + String get errorDialogContinue => 'OK'; + + @override + String get errorDialogTitle => '錯誤'; + + @override + String get snackBarDetails => '詳細資訊'; + + @override + String get lightboxCopyLinkTooltip => '複製連結'; + + @override + String get lightboxVideoCurrentPosition => '目前位置'; + + @override + String get lightboxVideoDuration => '影片長度'; + + @override + String get loginPageTitle => '登入'; + + @override + String get loginFormSubmitLabel => '登入'; + + @override + String get loginMethodDivider => '或'; + + @override + String signInWithFoo(String method) { + return '使用 $method 登入'; + } + + @override + String get loginAddAnAccountPageTitle => '增添帳號'; + + @override + String get loginServerUrlLabel => '您的 Zulip 伺服器網址'; + + @override + String get loginHidePassword => '隱藏密碼'; + + @override + String get loginEmailLabel => '電子郵件地址'; + + @override + String get loginErrorMissingEmail => '請輸入您的電子郵件地址。'; + + @override + String get loginPasswordLabel => '密碼'; + + @override + String get loginErrorMissingPassword => '請輸入您的密碼。'; + + @override + String get loginUsernameLabel => '使用者名稱'; + + @override + String get loginErrorMissingUsername => '請輸入您的使用者名稱。'; + + @override + String get topicValidationErrorTooLong => '議題長度不得超過 60 個字元。'; + + @override + String get topicValidationErrorMandatoryButEmpty => '此組織要求必須填寫議題。'; + + @override + String get errorContentNotInsertedTitle => '未插入內容'; + + @override + String get errorContentToInsertIsEmpty => '要插入的檔案為空或無法存取。'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url 執行的 Zulip Server 為 $zulipVersion,此版本已不受支援。最低支援版本為 Zulip Server $minSupportedZulipVersion。'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return '您在 $url 的帳號無法通過驗證。請重新登入或使用其他帳號。'; + } + + @override + String get errorInvalidResponse => '伺服器傳送了無效的請求。'; + + @override + String get errorNetworkRequestFailed => '網路請求失敗'; + + @override + String errorMalformedResponse(int httpStatus) { + return '伺服器回傳了格式錯誤的回應;HTTP 狀態碼為 $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return '伺服器回傳了格式錯誤的回應;HTTP 狀態碼為 $httpStatus;$details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return '網路請求失敗:HTTP 狀態碼為 $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => '無法播放影片。'; + + @override + String get serverUrlValidationErrorEmpty => '請輸入網址。'; + + @override + String get serverUrlValidationErrorInvalidUrl => '請輸入有效的網址。'; + + @override + String get serverUrlValidationErrorNoUseEmail => '請輸入伺服器網址,而非您的電子郵件。'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + '伺服器 URL 必須以 http:// 或 https:// 開頭。'; + + @override + String get spoilerDefaultHeaderText => '劇透'; + + @override + String get markAllAsReadLabel => '標註所有訊息為已讀'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num 則訊息', + one: '1 則訊息', + ); + return '已標為已讀:$_temp0。'; + } + + @override + String get markAsReadInProgress => '正在標記訊息為已讀…'; + + @override + String get errorMarkAsReadFailedTitle => '標記為已讀失敗'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num 則訊息', + one: '1 則訊息', + ); + return '已標為未讀:$_temp0。'; + } + + @override + String get markAsUnreadInProgress => '正在標註訊息為未讀…'; + + @override + String get errorMarkAsUnreadFailedTitle => '標記為未讀失敗'; + + @override + String get today => '今天'; + + @override + String get yesterday => '昨天'; + + @override + String get userActiveNow => '目前活躍'; + + @override + String get userIdle => '閒置'; + + @override + String userActiveMinutesAgo(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes 分鐘前', + one: '1 分鐘前', + ); + return '上次活躍於 $_temp0'; + } + + @override + String userActiveHoursAgo(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours 小時前', + one: '1 小時前', + ); + return '上次活躍於 $_temp0'; + } + + @override + String get userActiveYesterday => '昨天活躍'; + + @override + String userActiveDaysAgo(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days 天前', + one: '1 天前', + ); + return '上次活躍於 $_temp0'; + } + + @override + String userActiveDate(String date) { + return '上次活躍於 $date'; + } + + @override + String get userNotActiveInYear => '去年未活躍'; + + @override + String get invisibleMode => '隱身模式'; + + @override + String get turnOnInvisibleModeErrorTitle => '啟用隱身模式時發生錯誤。請再試一次。'; + + @override + String get turnOffInvisibleModeErrorTitle => '關閉隱身模式時發生錯誤。請再試一次。'; + + @override + String get userRoleOwner => '擁有者'; + + @override + String get userRoleAdministrator => '管理員'; + + @override + String get userRoleModerator => '版主'; + + @override + String get userRoleMember => '成員'; + + @override + String get userRoleGuest => '訪客'; + + @override + String get userRoleUnknown => '未知'; + + @override + String get statusButtonLabelStatusSet => '狀態'; + + @override + String get statusButtonLabelStatusUnset => '設定狀態'; + + @override + String get noStatusText => '無狀態文字'; + + @override + String get setStatusPageTitle => '設定狀態'; + + @override + String get statusClearButtonLabel => '清除'; + + @override + String get statusSaveButtonLabel => '儲存'; + + @override + String get statusTextHint => '您的狀態'; + + @override + String get userStatusBusy => '忙碌'; + + @override + String get userStatusInAMeeting => '會議中'; + + @override + String get userStatusCommuting => '通勤中'; + + @override + String get userStatusOutSick => '請病假'; + + @override + String get userStatusVacationing => '休假中'; + + @override + String get userStatusWorkingRemotely => '遠端工作中'; + + @override + String get userStatusAtTheOffice => '在辦公室'; + + @override + String get updateStatusErrorTitle => '更新使用者狀態時發生錯誤。請再試一次。'; + + @override + String get searchMessagesPageTitle => '搜尋'; + + @override + String get searchMessagesHintText => '搜尋'; + + @override + String get searchMessagesClearButtonTooltip => '清除'; + + @override + String get inboxPageTitle => '收件匣'; + + @override + String get inboxEmptyPlaceholder => '您的收件匣中沒有未讀訊息。請使用下方按鈕查看整合訊息流或頻道清單。'; + + @override + String get recentDmConversationsPageTitle => '私人訊息'; + + @override + String get recentDmConversationsSectionHeader => '私人訊息'; + + @override + String get recentDmConversationsEmptyPlaceholder => '您尚未有任何私人訊息!不如開始一段對話吧?'; + + @override + String get combinedFeedPageTitle => '綜合饋給'; + + @override + String get mentionsPageTitle => '提及'; + + @override + String get starredMessagesPageTitle => '已加星號的訊息'; + + @override + String get channelsPageTitle => '頻道'; + + @override + String get channelsEmptyPlaceholder => '您尚未訂閱任何頻道。'; + + @override + String get sharePageTitle => '分享'; + + @override + String get mainMenuMyProfile => '我的設定檔'; + + @override + String get topicsButtonTooltip => '話題'; + + @override + String get channelFeedButtonTooltip => '頻道饋給'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers 位其他對象', + one: '1 位其他對象、', + ); + return '$senderFullName 傳送給您和 $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => '已釘選'; + + @override + String get unpinnedSubscriptionsLabel => '未釘選'; + + @override + String get notifSelfUser => '您'; + + @override + String get reactedEmojiSelfUser => '您'; + + @override + String get reactionChipsLabel => '反應'; + + @override + String reactionChipLabel(String emojiName, String votes) { + return '$emojiName: $votes'; + } + + @override + String reactionChipVotesYouAndOthers(int otherUsersCount) { + String _temp0 = intl.Intl.pluralLogic( + otherUsersCount, + locale: localeName, + other: '你與其他 $otherUsersCount 人', + one: '你與其他 1 人', + ); + return '$_temp0'; + } + + @override + String onePersonTyping(String typist) { + return '$typist 正在輸入…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist 和 $otherTypist 正在輸入…'; + } + + @override + String get manyPeopleTyping => '有些人正在輸入…'; + + @override + String get wildcardMentionAll => '全部'; + + @override + String get wildcardMentionEveryone => '所有人'; + + @override + String get wildcardMentionChannel => '頻道'; + + @override + String get wildcardMentionStream => '串流'; + + @override + String get wildcardMentionTopic => '議題'; + + @override + String get wildcardMentionChannelDescription => '通知頻道'; + + @override + String get wildcardMentionStreamDescription => '通知串流'; + + @override + String get wildcardMentionAllDmDescription => '通知收件人'; + + @override + String get wildcardMentionTopicDescription => '通知話題'; + + @override + String get messageIsEditedLabel => '已編輯'; + + @override + String get messageIsMovedLabel => '已移動'; + + @override + String get messageNotSentLabel => '訊息未送出'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => '主題'; + + @override + String get themeSettingDark => '深色主題'; + + @override + String get themeSettingLight => '淺色主題'; + + @override + String get themeSettingSystem => '系統主題'; + + @override + String get openLinksWithInAppBrowser => '使用應用程式內建瀏覽器開啟連結'; + + @override + String get pollWidgetQuestionMissing => '沒有問題。'; + + @override + String get pollWidgetOptionsMissing => '此投票尚未有任何選項。'; + + @override + String get initialAnchorSettingTitle => '開啟訊息串於'; + + @override + String get initialAnchorSettingDescription => '您可以選擇將訊息串開啟在第一則未讀訊息,或是最新的訊息。'; + + @override + String get initialAnchorSettingFirstUnreadAlways => '第一則未讀訊息'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + '在對話檢視中開啟第一則未讀訊息,其餘情況則開啟最新訊息'; + + @override + String get initialAnchorSettingNewestAlways => '最新訊息'; + + @override + String get markReadOnScrollSettingTitle => '捲動時將訊息標記為已讀'; + + @override + String get markReadOnScrollSettingDescription => '在捲動瀏覽訊息時,是否要自動將其標記為已讀?'; + + @override + String get markReadOnScrollSettingAlways => '總是'; + + @override + String get markReadOnScrollSettingNever => '從不'; + + @override + String get markReadOnScrollSettingConversations => '僅在對話檢視中'; + + @override + String get markReadOnScrollSettingConversationsDescription => + '只有在查看單一議題或私人訊息對話時,訊息才會自動標記為已讀。'; + + @override + String get experimentalFeatureSettingsPageTitle => '實驗性功能'; + + @override + String get experimentalFeatureSettingsWarning => + '這些選項啟用的功能仍在開發中,尚未完善。它們可能無法正常運作,且可能導致應用程式其他部分出現問題。\n\n這些設定的目的是供參與 Zulip 開發的人員進行試驗使用。'; + + @override + String get errorNotificationOpenTitle => '無法開啟通知'; + + @override + String get errorNotificationOpenAccountNotFound => '找不到與此通知相關聯的帳號。'; + + @override + String get errorReactionAddingFailedTitle => '新增表情反應失敗'; + + @override + String get errorReactionRemovingFailedTitle => '移除表情反應失敗'; + + @override + String get errorSharingTitle => '分享內容失敗'; + + @override + String get errorSharingAccountNotLoggedIn => '尚未登入任何帳號。請登入帳號後再試一次。'; + + @override + String get emojiReactionsMore => '更多'; + + @override + String get emojiPickerSearchEmoji => '搜尋表情符號'; + + @override + String get noEarlierMessages => '沒有更早的訊息'; + + @override + String get revealButtonLabel => '顯示訊息'; + + @override + String get mutedUser => '已靜音的使用者'; + + @override + String get scrollToBottomTooltip => '捲動至底部'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; +} diff --git a/lib/host/android_intents.dart b/lib/host/android_intents.dart new file mode 100644 index 0000000000..6bd1e60de5 --- /dev/null +++ b/lib/host/android_intents.dart @@ -0,0 +1 @@ +export 'android_intents.g.dart'; diff --git a/lib/host/android_intents.g.dart b/lib/host/android_intents.g.dart new file mode 100644 index 0000000000..fb97d7311f --- /dev/null +++ b/lib/host/android_intents.g.dart @@ -0,0 +1,174 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); + } + return a == b; +} + + +class IntentSharedFile { + IntentSharedFile({ + this.name, + this.mimeType, + required this.bytes, + }); + + String? name; + + String? mimeType; + + Uint8List bytes; + + List _toList() { + return [ + name, + mimeType, + bytes, + ]; + } + + Object encode() { + return _toList(); } + + static IntentSharedFile decode(Object result) { + result as List; + return IntentSharedFile( + name: result[0] as String?, + mimeType: result[1] as String?, + bytes: result[2]! as Uint8List, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! IntentSharedFile || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +sealed class AndroidIntentEvent { +} + +class AndroidIntentSendEvent extends AndroidIntentEvent { + AndroidIntentSendEvent({ + required this.action, + this.extraText, + this.extraStream, + }); + + String action; + + String? extraText; + + List? extraStream; + + List _toList() { + return [ + action, + extraText, + extraStream, + ]; + } + + Object encode() { + return _toList(); } + + static AndroidIntentSendEvent decode(Object result) { + result as List; + return AndroidIntentSendEvent( + action: result[0]! as String, + extraText: result[1] as String?, + extraStream: (result[2] as List?)?.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AndroidIntentSendEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is IntentSharedFile) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is AndroidIntentSendEvent) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return IntentSharedFile.decode(readValue(buffer)!); + case 130: + return AndroidIntentSendEvent.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec(_PigeonCodec()); + +Stream androidIntentEvents( {String instanceName = ''}) { + if (instanceName.isNotEmpty) { + instanceName = '.$instanceName'; + } + final EventChannel androidIntentEventsChannel = + EventChannel('dev.flutter.pigeon.zulip.AndroidIntentsEventChannelApi.androidIntentEvents$instanceName', pigeonMethodCodec); + return androidIntentEventsChannel.receiveBroadcastStream().map((dynamic event) { + return event as AndroidIntentEvent; + }); +} + diff --git a/lib/host/android_notifications.g.dart b/lib/host/android_notifications.g.dart index 7b53559e64..1bae0a8bf1 100644 --- a/lib/host/android_notifications.g.dart +++ b/lib/host/android_notifications.g.dart @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v22.7.2), do not edit directly. +// Autogenerated from Pigeon (v26.0.0), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers @@ -14,6 +14,20 @@ PlatformException _createConnectionError(String channelName) { message: 'Unable to establish connection on channel: "$channelName".', ); } +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); + } + return a == b; +} + /// Corresponds to `androidx.core.app.NotificationChannelCompat` /// @@ -44,7 +58,7 @@ class NotificationChannel { Int64List? vibrationPattern; - Object encode() { + List _toList() { return [ id, importance, @@ -55,6 +69,9 @@ class NotificationChannel { ]; } + Object encode() { + return _toList(); } + static NotificationChannel decode(Object result) { result as List; return NotificationChannel( @@ -66,6 +83,23 @@ class NotificationChannel { vibrationPattern: result[5] as Int64List?, ); } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NotificationChannel || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; } /// Corresponds to `android.content.Intent` @@ -87,7 +121,7 @@ class AndroidIntent { /// A combination of flags from [IntentFlag]. int flags; - Object encode() { + List _toList() { return [ action, dataUrl, @@ -95,6 +129,9 @@ class AndroidIntent { ]; } + Object encode() { + return _toList(); } + static AndroidIntent decode(Object result) { result as List; return AndroidIntent( @@ -103,6 +140,23 @@ class AndroidIntent { flags: result[2]! as int, ); } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AndroidIntent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; } /// Corresponds to `android.app.PendingIntent`. @@ -123,7 +177,7 @@ class PendingIntent { /// with `Intent`; see Android docs for `PendingIntent.getActivity`. int flags; - Object encode() { + List _toList() { return [ requestCode, intent, @@ -131,6 +185,9 @@ class PendingIntent { ]; } + Object encode() { + return _toList(); } + static PendingIntent decode(Object result) { result as List; return PendingIntent( @@ -139,6 +196,23 @@ class PendingIntent { flags: result[2]! as int, ); } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PendingIntent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; } /// Corresponds to `androidx.core.app.NotificationCompat.InboxStyle` @@ -151,18 +225,38 @@ class InboxStyle { String summaryText; - Object encode() { + List _toList() { return [ summaryText, ]; } + Object encode() { + return _toList(); } + static InboxStyle decode(Object result) { result as List; return InboxStyle( summaryText: result[0]! as String, ); } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! InboxStyle || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; } /// Corresponds to `androidx.core.app.Person` @@ -189,7 +283,7 @@ class Person { String name; - Object encode() { + List _toList() { return [ iconBitmap, key, @@ -197,6 +291,9 @@ class Person { ]; } + Object encode() { + return _toList(); } + static Person decode(Object result) { result as List; return Person( @@ -205,6 +302,23 @@ class Person { name: result[2]! as String, ); } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! Person || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; } /// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` @@ -223,7 +337,7 @@ class MessagingStyleMessage { Person person; - Object encode() { + List _toList() { return [ text, timestampMs, @@ -231,6 +345,9 @@ class MessagingStyleMessage { ]; } + Object encode() { + return _toList(); } + static MessagingStyleMessage decode(Object result) { result as List; return MessagingStyleMessage( @@ -239,6 +356,23 @@ class MessagingStyleMessage { person: result[2]! as Person, ); } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! MessagingStyleMessage || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; } /// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` @@ -260,7 +394,7 @@ class MessagingStyle { bool isGroupConversation; - Object encode() { + List _toList() { return [ user, conversationTitle, @@ -269,6 +403,9 @@ class MessagingStyle { ]; } + Object encode() { + return _toList(); } + static MessagingStyle decode(Object result) { result as List; return MessagingStyle( @@ -278,6 +415,23 @@ class MessagingStyle { isGroupConversation: result[3]! as bool, ); } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! MessagingStyle || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; } /// Corresponds to `android.app.Notification` @@ -293,13 +447,16 @@ class Notification { Map extras; - Object encode() { + List _toList() { return [ group, extras, ]; } + Object encode() { + return _toList(); } + static Notification decode(Object result) { result as List; return Notification( @@ -307,6 +464,23 @@ class Notification { extras: (result[1] as Map?)!.cast(), ); } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! Notification || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; } /// Corresponds to `android.service.notification.StatusBarNotification` @@ -325,7 +499,7 @@ class StatusBarNotification { Notification notification; - Object encode() { + List _toList() { return [ id, tag, @@ -333,6 +507,9 @@ class StatusBarNotification { ]; } + Object encode() { + return _toList(); } + static StatusBarNotification decode(Object result) { result as List; return StatusBarNotification( @@ -341,6 +518,23 @@ class StatusBarNotification { notification: result[2]! as Notification, ); } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! StatusBarNotification || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; } /// Represents details about a notification sound stored in the @@ -367,7 +561,7 @@ class StoredNotificationSound { /// A `content://…` URL pointing to the sound file. String contentUrl; - Object encode() { + List _toList() { return [ fileName, isOwned, @@ -375,6 +569,9 @@ class StoredNotificationSound { ]; } + Object encode() { + return _toList(); } + static StoredNotificationSound decode(Object result) { result as List; return StoredNotificationSound( @@ -383,6 +580,23 @@ class StoredNotificationSound { contentUrl: result[2]! as String, ); } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! StoredNotificationSound || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; } @@ -480,8 +694,9 @@ class AndroidNotificationHostApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([channel]); final List? pigeonVar_replyList = - await pigeonVar_channel.send([channel]) as List?; + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -505,8 +720,9 @@ class AndroidNotificationHostApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); final List? pigeonVar_replyList = - await pigeonVar_channel.send(null) as List?; + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -535,8 +751,9 @@ class AndroidNotificationHostApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([channelId]); final List? pigeonVar_replyList = - await pigeonVar_channel.send([channelId]) as List?; + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -567,8 +784,9 @@ class AndroidNotificationHostApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); final List? pigeonVar_replyList = - await pigeonVar_channel.send(null) as List?; + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -608,8 +826,9 @@ class AndroidNotificationHostApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([targetFileDisplayName, sourceResourceName]); final List? pigeonVar_replyList = - await pigeonVar_channel.send([targetFileDisplayName, sourceResourceName]) as List?; + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -652,8 +871,9 @@ class AndroidNotificationHostApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([tag, id, autoCancel, channelId, color, contentIntent, contentText, contentTitle, extras, groupKey, inboxStyle, isGroupSummary, messagingStyle, number, smallIconResourceName]); 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?; + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -685,8 +905,9 @@ class AndroidNotificationHostApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([tag]); final List? pigeonVar_replyList = - await pigeonVar_channel.send([tag]) as List?; + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -715,8 +936,9 @@ class AndroidNotificationHostApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([desiredExtras]); final List? pigeonVar_replyList = - await pigeonVar_channel.send([desiredExtras]) as List?; + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -745,8 +967,9 @@ class AndroidNotificationHostApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([tag, id]); final List? pigeonVar_replyList = - await pigeonVar_channel.send([tag, id]) as List?; + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { diff --git a/lib/host/notifications.dart b/lib/host/notifications.dart new file mode 100644 index 0000000000..6c3e593e2c --- /dev/null +++ b/lib/host/notifications.dart @@ -0,0 +1 @@ +export './notifications.g.dart'; diff --git a/lib/host/notifications.g.dart b/lib/host/notifications.g.dart new file mode 100644 index 0000000000..65dc92c3b3 --- /dev/null +++ b/lib/host/notifications.g.dart @@ -0,0 +1,210 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); + } + return a == b; +} + + +class NotificationDataFromLaunch { + NotificationDataFromLaunch({ + required this.payload, + }); + + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + Map payload; + + List _toList() { + return [ + payload, + ]; + } + + Object encode() { + return _toList(); } + + static NotificationDataFromLaunch decode(Object result) { + result as List; + return NotificationDataFromLaunch( + payload: (result[0] as Map?)!.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NotificationDataFromLaunch || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class NotificationTapEvent { + NotificationTapEvent({ + required this.payload, + }); + + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [notificationTapEvents]. + Map payload; + + List _toList() { + return [ + payload, + ]; + } + + Object encode() { + return _toList(); } + + static NotificationTapEvent decode(Object result) { + result as List; + return NotificationTapEvent( + payload: (result[0] as Map?)!.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NotificationTapEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is NotificationDataFromLaunch) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is NotificationTapEvent) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return NotificationDataFromLaunch.decode(readValue(buffer)!); + case 130: + return NotificationTapEvent.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec(_PigeonCodec()); + +class NotificationHostApi { + /// Constructor for [NotificationHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + NotificationHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// Returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + Future getNotificationDataFromLaunch() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return (pigeonVar_replyList[0] as NotificationDataFromLaunch?); + } + } +} + +Stream notificationTapEvents( {String instanceName = ''}) { + if (instanceName.isNotEmpty) { + instanceName = '.$instanceName'; + } + final EventChannel notificationTapEventsChannel = + EventChannel('dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents$instanceName', pigeonMethodCodec); + return notificationTapEventsChannel.receiveBroadcastStream().map((dynamic event) { + return event as NotificationTapEvent; + }); +} + diff --git a/lib/licenses.dart b/lib/licenses.dart index a52c3f3240..6c873dbb49 100644 --- a/lib/licenses.dart +++ b/lib/licenses.dart @@ -12,6 +12,9 @@ import 'package:flutter/services.dart'; Stream additionalLicenses() async* { // Alphabetic by path. + yield LicenseEntryWithLineBreaks( + ['KaTeX'], + await rootBundle.loadString('assets/KaTeX/LICENSE')); yield LicenseEntryWithLineBreaks( ['Noto Color Emoji'], await rootBundle.loadString('assets/Noto_Color_Emoji/LICENSE')); @@ -23,6 +26,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/log.dart b/lib/log.dart index e3261f8cba..64cb409a0e 100644 --- a/lib/log.dart +++ b/lib/log.dart @@ -1,4 +1,6 @@ +import 'package:flutter/foundation.dart'; + /// Whether [debugLog] should do anything. /// /// This has an effect only in a debug build. @@ -31,7 +33,40 @@ bool debugLog(String message) { return true; } -typedef ReportErrorCallback = void Function(String? message, {String? details}); +/// Print a piece of profiling data. +/// +/// This should be called only in profile mode: +/// * In debug mode, any profiling results will be misleading. +/// * In release mode, we should avoid doing the computation to even produce +/// the [message] argument. +/// +/// As a reminder of that, this function will throw in debug mode. +/// +/// Example usage: +/// ```dart +/// final stopwatch = Stopwatch()..start(); +/// final data = await someSlowOperation(); +/// if (kProfileMode) { +/// final t = stopwatch.elapsed; +/// profilePrint("some-operation time: ${t.inMilliseconds}ms"); +/// } +/// ``` +void profilePrint(String message) { + assert(kProfileMode, 'Use profilePrint only within `if (kProfileMode)`.'); + if (kReleaseMode) return; + print(message); // ignore: avoid_print +} + +// This should only be used for error reporting functions that allow the error +// to be cancelled programmatically. The implementation is expected to handle +// `null` for the `message` parameter and promptly dismiss the reported errors. +typedef ReportErrorCancellablyCallback = void Function(String? message, {String? details}); + +typedef ReportErrorCallback = void Function( + String title, { + String? message, + Uri? learnMoreButtonUrl, +}); /// Show the user an error message, without requiring them to interact with it. /// @@ -45,13 +80,39 @@ typedef ReportErrorCallback = void Function(String? message, {String? details}); /// /// If `details` is non-null, the [SnackBar] will contain a button that would /// open a dialog containing the error details. +/// Prose in `details` should have final punctuation. // This gets set in [ZulipApp]. We need this indirection to keep `lib/log.dart` // from importing widget code, because the file is a dependency for the rest of // the app. -ReportErrorCallback reportErrorToUserBriefly = defaultReportErrorToUserBriefly; +ReportErrorCancellablyCallback reportErrorToUserBriefly = defaultReportErrorToUserBriefly; + +/// Show the user a dismissable error message in a modal popup. +/// +/// Typically this shows an [AlertDialog] with `title` as the title, `message` +/// as the body. If called before the app's widget tree is ready +/// (see [ZulipApp.ready]), then we give up on showing the message to the user, +/// and just log the message to the console. +/// +/// Prose in `message` should have final punctuation. +// This gets set in [ZulipApp]. We need this indirection to keep `lib/log.dart` +// from importing widget code, because the file is a dependency for the rest of +// the app. +ReportErrorCallback reportErrorToUserModally = defaultReportErrorToUserModally; void defaultReportErrorToUserBriefly(String? message, {String? details}) { - // Error dismissing is a no-op to the default handler. + _reportErrorToConsole(message, details); +} + +void defaultReportErrorToUserModally( + String title, { + String? message, + Uri? learnMoreButtonUrl, +}) { + _reportErrorToConsole(title, message); +} + +void _reportErrorToConsole(String? message, String? details) { + // Error dismissing is a no-op for the console. if (message == null) return; // If this callback is still in place, then the app's widget tree // hasn't mounted yet even as far as the [Navigator]. diff --git a/lib/main.dart b/lib/main.dart index f89ccb6040..6c23bca66a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'log.dart'; import 'model/binding.dart'; import 'notifications/receive.dart'; import 'widgets/app.dart'; +import 'widgets/share.dart'; void main() { assert(() { @@ -16,5 +17,6 @@ void main() { WidgetsFlutterBinding.ensureInitialized(); LiveZulipBinding.ensureInitialized(); NotificationService.instance.start(); + ShareService.start(); runApp(const ZulipApp()); } diff --git a/lib/model/actions.dart b/lib/model/actions.dart new file mode 100644 index 0000000000..3869faae03 --- /dev/null +++ b/lib/model/actions.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../notifications/display.dart'; +import '../notifications/receive.dart'; +import 'store.dart'; + +// TODO: Make this a part of GlobalStore +Future logOutAccount(GlobalStore globalStore, int accountId) async { + final account = globalStore.getAccount(accountId); + if (account == null) return; // TODO(log) + + // Unawaited, to not block removing the account on this request. + unawaited(unregisterToken(globalStore, accountId)); + + if (defaultTargetPlatform == TargetPlatform.android) { + unawaited(NotificationDisplayManager.removeNotificationsForAccount(account.realmUrl, account.userId)); + } + + await globalStore.removeAccount(accountId); +} + +Future unregisterToken(GlobalStore globalStore, int accountId) async { + final account = globalStore.getAccount(accountId); + if (account == null) return; // TODO(log) + + // TODO(#322) use actual acked push token; until #322, this is just null. + final token = account.ackedPushToken + // Try the current token as a fallback; maybe the server has registered + // it and we just haven't recorded that fact in the client. + ?? NotificationService.instance.token.value; + if (token == null) return; + + final connection = globalStore.apiConnectionFromAccount(account); + try { + await NotificationService.unregisterToken(connection, token: token); + } catch (e) { + // TODO retry? handle failures? + } finally { + connection.close(); + } +} diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index dffe44d6fe..cf5c6b86e0 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -2,12 +2,14 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:unorm_dart/unorm_dart.dart' as unorm; 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 'algorithms.dart'; import 'compose.dart'; import 'emoji.dart'; import 'narrow.dart'; @@ -248,6 +250,14 @@ class AutocompleteViewManager { autocompleteDataCache.invalidateUser(event.userId); } + void handleUserGroupRemoveEvent(UserGroupRemoveEvent event) { + autocompleteDataCache.invalidateUserGroup(event.groupId); + } + + void handleUserGroupUpdateEvent(UserGroupUpdateEvent event) { + autocompleteDataCache.invalidateUserGroup(event.groupId); + } + /// Called when the app is reassembled during debugging, e.g. for hot reload. /// /// Calls [AutocompleteView.reassemble] for all that are registered. @@ -422,6 +432,7 @@ class MentionAutocompleteView extends AutocompleteView sortedUsers; + final List sortedUserGroups; final ZulipLocalizations localizations; static List _usersByRelevance({ required PerAccountStore store, required Narrow narrow, }) { - return store.users.values.toList() + return store.allUsers.toList() ..sort(_comparator(store: store, narrow: narrow)); } /// Compare the users the same way they would be sorted as - /// autocomplete candidates. + /// autocomplete candidates, given [query]. + /// + /// The users must both match the query. /// /// This behaves the same as the comparator used for sorting in - /// [_usersByRelevance], but calling this for each comparison would be a bit - /// less efficient because some of the logic is independent of the users and - /// can be precomputed. + /// [_usersByRelevance], combined with the ranking applied at the end + /// of [computeResults]. /// /// This is useful for tests in order to distinguish "A comes before B" /// from "A ranks equal to B, and the sort happened to put A before B", /// particularly because [List.sort] makes no guarantees about the order /// of items that compare equal. int debugCompareUsers(User userA, User userB) { + final rankA = query.testUser(userA, store)!.rank; + final rankB = query.testUser(userB, store)!.rank; + if (rankA != rankB) return rankA.compareTo(rankB); + return _comparator(store: store, narrow: narrow)(userA, userB); } @@ -473,6 +491,9 @@ class MentionAutocompleteView extends AutocompleteView _compareByRelevance(userA, userB, @@ -556,7 +578,6 @@ class MentionAutocompleteView extends AutocompleteView _userGroupsByRelevance({required PerAccountStore store}) { + return store.activeGroups + // TODO(#1776) Follow new "Who can mention this group" setting instead + .where((userGroup) => !userGroup.isSystemGroup) + .toList() + ..sort(_userGroupComparator(store: store)); + } + + static int Function(UserGroup, UserGroup) _userGroupComparator({ + required PerAccountStore store, + }) { + // See also [MentionAutocompleteQuery._rankUserGroupResult]; + // that ranking takes precedence over this. + + return (userGroupA, userGroupB) => + compareGroupsByAlphabeticalOrder(userGroupA, userGroupB, store: store); + } + + static int compareGroupsByAlphabeticalOrder(UserGroup userGroupA, UserGroup userGroupB, + {required PerAccountStore store}) { + final groupAName = store.autocompleteViewManager.autocompleteDataCache + .normalizedNameForUserGroup(userGroupA); + final groupBName = store.autocompleteViewManager.autocompleteDataCache + .normalizedNameForUserGroup(userGroupB); + return groupAName.compareTo(groupBName); // TODO(i18n): add locale-aware sorting + } + void computeWildcardMentionResults({ required List results, required bool isComposingChannelMessage, @@ -609,11 +657,10 @@ class MentionAutocompleteView extends AutocompleteView= 247; // TODO(server-9) + final isChannelWildcardAvailable = store.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) + final isTopicWildcardAvailable = store.zulipFeatureLevel >= 224; // TODO(server-8) if (isComposingChannelMessage && isTopicWildcardAvailable) { tryOption(WildcardMentionOption.topic); } @@ -636,23 +683,31 @@ class MentionAutocompleteView extends AutocompleteView?> computeResults() async { - final results = []; + final unsorted = []; // Give priority to wildcard mentions. - computeWildcardMentionResults(results: results, + computeWildcardMentionResults(results: unsorted, isComposingChannelMessage: narrow is ChannelNarrow || narrow is TopicNarrow); if (await filterCandidates(filter: _testUser, - candidates: sortedUsers, results: results)) { + candidates: sortedUsers, results: unsorted)) { return null; } - return results; + + if (await filterCandidates(filter: _testUserGroup, + candidates: sortedUserGroups, results: unsorted)) { + return null; + } + + return bucketSort(unsorted, + (r) => r.rank, numBuckets: MentionAutocompleteQuery._numResultRanks); } MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) { - if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) { - return UserMentionAutocompleteResult(userId: user.userId); - } - return null; + return query.testUser(user, store); + } + + MentionAutocompleteResult? _testUserGroup(MentionAutocompleteQuery query, UserGroup userGroup) { + return query.testUserGroup(userGroup, store); } @override @@ -678,38 +733,53 @@ class MentionAutocompleteView extends AutocompleteView _lowercaseWords; + late final List _normalizedWords; + + static final RegExp _regExpStripMarkCharacters = RegExp(r'\p{M}', unicode: true); + + static String lowercaseAndStripDiacritics(String input) { + // Anders reports that this is what web does; see discussion: + // https://chat.zulip.org/#narrow/channel/48-mobile/topic/deps.3A.20Add.20new.20package.20to.20handle.20diacritics/near/2244487 + final lowercase = input.toLowerCase(); + final compatibilityNormalized = unorm.nfkd(lowercase); + return compatibilityNormalized.replaceAll(_regExpStripMarkCharacters, ''); + } - /// Whether all of this query's words have matches in [words] that appear in order. + /// Whether all of this query's words have matches in [words], + /// insensitively to case and diacritics, that appear in order. /// /// A "match" means the word in [words] starts with the query word. + /// + /// [words] must all have been passed through [lowercaseAndStripDiacritics]. bool _testContainsQueryWords(List words) { - // TODO(#237) test with diacritics stripped, where appropriate int wordsIndex = 0; int queryWordsIndex = 0; while (true) { - if (queryWordsIndex == _lowercaseWords.length) { + if (queryWordsIndex == _normalizedWords.length) { return true; } if (wordsIndex == words.length) { return false; } - if (words[wordsIndex].startsWith(_lowercaseWords[queryWordsIndex])) { + if (words[wordsIndex].startsWith(_normalizedWords[queryWordsIndex])) { queryWordsIndex++; } wordsIndex++; @@ -717,6 +787,24 @@ abstract class AutocompleteQuery { } } +/// The match quality of a [User.fullName] or [UserGroup.name] +/// to a mention autocomplete query. +/// +/// All matches are case-insensitive. +enum NameMatchQuality { + /// The query matches the whole name exactly. + exact, + + /// The name starts with the query. + totalPrefix, + + /// All of the query's words have matches in the words of the name + /// that appear in order. + /// + /// A "match" means the word in the name starts with the query word. + wordPrefixes, +} + /// Any autocomplete query in the compose box's content input. abstract class ComposeAutocompleteQuery extends AutocompleteQuery { ComposeAutocompleteQuery(super.raw); @@ -747,24 +835,122 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery { store: store, localizations: localizations, narrow: narrow, query: this); } - bool testWildcardOption(WildcardMentionOption wildcardOption, { + WildcardMentionAutocompleteResult? testWildcardOption(WildcardMentionOption wildcardOption, { required ZulipLocalizations localizations}) { - // TODO(#237): match insensitively to diacritics - return wildcardOption.canonicalString.contains(_lowercase) - || wildcardOption.localizedCanonicalString(localizations).contains(_lowercase); + final localized = wildcardOption.localizedCanonicalString(localizations); + final matches = wildcardOption.canonicalString.contains(_normalized) + || AutocompleteQuery.lowercaseAndStripDiacritics(localized).contains(_normalized); + if (!matches) return null; + return WildcardMentionAutocompleteResult( + wildcardOption: wildcardOption, rank: _rankWildcardResult); + } + + MentionAutocompleteResult? testUser(User user, PerAccountStore store) { + if (!user.isActive) return null; + if (store.isUserMuted(user.userId)) return null; + + final cache = store.autocompleteViewManager.autocompleteDataCache; + final nameMatchQuality = _matchName( + normalizedName: cache.normalizedNameForUser(user), + normalizedNameWords: cache.normalizedNameWordsForUser(user)); + bool? matchesEmail; + if (nameMatchQuality == null) { + matchesEmail = _matchEmail(user, cache); + if (!matchesEmail) return null; + } + + return UserMentionAutocompleteResult( + userId: user.userId, + rank: _rankUserResult(user, + nameMatchQuality: nameMatchQuality, matchesEmail: matchesEmail)); + } + + NameMatchQuality? _matchName({ + required String normalizedName, + required List normalizedNameWords, + }) { + if (normalizedName.startsWith(_normalized)) { + if (normalizedName.length == _normalized.length) { + return NameMatchQuality.exact; + } else { + return NameMatchQuality.totalPrefix; + } + } + + if (_testContainsQueryWords(normalizedNameWords)) { + return NameMatchQuality.wordPrefixes; + } + + return null; } - bool testUser(User user, AutocompleteDataCache cache) { - // TODO(#236) test email too, not just name - if (!user.isActive) return false; + bool _matchEmail(User user, AutocompleteDataCache cache) { + final normalizedEmail = cache.normalizedEmailForUser(user); + if (normalizedEmail == null) return false; // Email not known + return normalizedEmail.startsWith(_normalized); + } + + MentionAutocompleteResult? testUserGroup(UserGroup userGroup, PerAccountStore store) { + final cache = store.autocompleteViewManager.autocompleteDataCache; + + final nameMatchQuality = _matchName( + normalizedName: cache.normalizedNameForUserGroup(userGroup), + normalizedNameWords: cache.normalizedNameWordsForUserGroup(userGroup)); + + if (nameMatchQuality == null) return null; - return _testName(user, cache); + return UserGroupMentionAutocompleteResult( + groupId: userGroup.id, + rank: _rankUserGroupResult(userGroup, nameMatchQuality: nameMatchQuality)); } - bool _testName(User user, AutocompleteDataCache cache) { - return _testContainsQueryWords(cache.nameWordsForUser(user)); + /// A measure of a wildcard result's quality in the context of the query, + /// from 0 (best) to one less than [_numResultRanks]. + /// + /// See also [_rankUserResult] and [_rankUserGroupResult]. + static const _rankWildcardResult = 0; + + /// A measure of a user result's quality in the context of the query, + /// from 0 (best) to one less than [_numResultRanks]. + /// + /// When [nameMatchQuality] is non-null (the name matches), + /// callers should skip computing [matchesEmail] and pass null for that. + /// + /// See also [_rankWildcardResult] and [_rankUserGroupResult]. + static int _rankUserResult(User user, { + required NameMatchQuality? nameMatchQuality, + required bool? matchesEmail, + }) { + if (nameMatchQuality != null) { + assert(matchesEmail == null); + return switch (nameMatchQuality) { + NameMatchQuality.exact => 1, + NameMatchQuality.totalPrefix => 2, + NameMatchQuality.wordPrefixes => 3, + }; + } + assert(matchesEmail == true); + return 7; } + /// A measure of a user-group result's quality in the context of the query, + /// from 0 (best) to one less than [_numResultRanks]. + /// + /// See also [_rankWildcardResult] and [_rankUserResult]. + static int _rankUserGroupResult(UserGroup userGroup, { + required NameMatchQuality nameMatchQuality, + }) { + return switch (nameMatchQuality) { + NameMatchQuality.exact => 4, + NameMatchQuality.totalPrefix => 5, + NameMatchQuality.wordPrefixes => 6, + }; + } + + /// The number of possible values returned by + /// [_rankWildcardResult], [_rankUserResult], and [_rankUserGroupResult].. + static const _numResultRanks = 8; + @override String toString() { return '${objectRuntimeType(this, 'MentionAutocompleteQuery')}(raw: $raw, silent: $silent})'; @@ -796,23 +982,59 @@ extension WildcardMentionOptionExtension on WildcardMentionOption { /// but kept around in between autocomplete interactions. /// /// An instance of this class is managed by [AutocompleteViewManager]. +// TODO(#1805) when splitting words, split on space characters that are likely +// to be used (e.g. U+3000 IDEOGRAPHIC SPACE); +// could check server language or just split on all kinds of spaces class AutocompleteDataCache { final Map _normalizedNamesByUser = {}; - /// The lowercase `fullName` of [user]. + /// The normalized `fullName` of [user]. String normalizedNameForUser(User user) { - return _normalizedNamesByUser[user.userId] ??= user.fullName.toLowerCase(); + return _normalizedNamesByUser[user.userId] + ??= AutocompleteQuery.lowercaseAndStripDiacritics(user.fullName); + } + + final Map> _normalizedNameWordsByUser = {}; + + List normalizedNameWordsForUser(User user) { + return _normalizedNameWordsByUser[user.userId] + ??= normalizedNameForUser(user).split(' '); + } + + final Map _normalizedEmailsByUser = {}; + + /// The normalized `deliveryEmail` of [user], or null if that's null. + String? normalizedEmailForUser(User user) { + return _normalizedEmailsByUser[user.userId] + ??= (user.deliveryEmail != null + ? AutocompleteQuery.lowercaseAndStripDiacritics(user.deliveryEmail!) + : null); } - final Map> _nameWordsByUser = {}; + final Map _normalizedNamesByUserGroup = {}; - List nameWordsForUser(User user) { - return _nameWordsByUser[user.userId] ??= normalizedNameForUser(user).split(' '); + /// The normalized `name` of [userGroup]. + String normalizedNameForUserGroup(UserGroup userGroup) { + return _normalizedNamesByUserGroup[userGroup.id] + ??= AutocompleteQuery.lowercaseAndStripDiacritics(userGroup.name); + } + + final Map> _normalizedNameWordsByUserGroup = {}; + + List normalizedNameWordsForUserGroup(UserGroup userGroup) { + return _normalizedNameWordsByUserGroup[userGroup.id] + ??= normalizedNameForUserGroup(userGroup).split(' '); } void invalidateUser(int userId) { _normalizedNamesByUser.remove(userId); - _nameWordsByUser.remove(userId); + _normalizedNameWordsByUser.remove(userId); + _normalizedEmailsByUser.remove(userId); + } + + void invalidateUserGroup(int id) { + _normalizedNamesByUserGroup.remove(id); + _normalizedNameWordsByUserGroup.remove(id); } } @@ -851,23 +1073,72 @@ class EmojiAutocompleteResult extends ComposeAutocompleteResult { /// This is abstract because there are several kinds of result /// that can all be offered in the same @-mention autocomplete interaction: /// a user, a wildcard, or a user group. -sealed class MentionAutocompleteResult extends ComposeAutocompleteResult {} +sealed class MentionAutocompleteResult extends ComposeAutocompleteResult { + /// A measure of the result's quality in the context of the query. + /// + /// Used internally by [MentionAutocompleteView] for ranking the results. + // See also [MentionAutocompleteView._usersByRelevance]; + // results with equal [rank] will appear in the order they were put in + // by that method. + // + // Compare sort_recipients in Zulip web: + // https://github.com/zulip/zulip/blob/afdf20c67/web/src/typeahead_helper.ts#L472 + // + // Behavior we have that web doesn't and might like to follow: + // - A "word-prefixes" match quality on user and user-group names: + // see [NameMatchQuality.wordPrefixes], which we rank on. + // + // Behavior web has that seems undesired, which we don't plan to follow: + // - Ranking humans above bots, even when the bots have higher relevance + // and better match quality. If there's a bot participating in the + // current conversation and I start typing its name, why wouldn't we want + // that as a top result? Issue: https://github.com/zulip/zulip/issues/35467 + // - A "word-boundary" match quality on user and user-group names: + // special rank when the whole query appears contiguously + // right after a word-boundary character. + // Our [NameMatchQuality.wordPrefixes] seems smarter. + // - An "exact" match quality on emails: probably not worth its complexity. + // Emails are much more uniform in their endings than users' names are, + // so a prefix match should be adequate. (If I've typed "email@example.co", + // that'll probably be the only result. There might be an "email@example.com", + // and an "exact" match would downrank that, but still that's just two items + // to scan through.) + // - A "word-boundary" match quality on user emails: + // "words" is a wrong abstraction when matching on emails. + // - Ranking some case-sensitive matches differently from case-insensitive + // matches. Users will expect a lowercase query to be adequate. + int get rank; +} /// An autocomplete result for an @-mention of an individual user. class UserMentionAutocompleteResult extends MentionAutocompleteResult { - UserMentionAutocompleteResult({required this.userId}); + UserMentionAutocompleteResult({required this.userId, required this.rank}); final int userId; + + @override + final int rank; } /// An autocomplete result for an @-mention of all the users in a conversation. class WildcardMentionAutocompleteResult extends MentionAutocompleteResult { - WildcardMentionAutocompleteResult({required this.wildcardOption}); + WildcardMentionAutocompleteResult({required this.wildcardOption, required this.rank}); final WildcardMentionOption wildcardOption; + + @override + final int rank; } -// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult { +/// An autocomplete result for an @-mention of a user group. +class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult { + UserGroupMentionAutocompleteResult({required this.groupId, required this.rank}); + + final int groupId; + + @override + final int rank; +} /// An autocomplete interaction for choosing a topic for a message. class TopicAutocompleteView extends AutocompleteView { @@ -904,7 +1175,9 @@ class TopicAutocompleteView extends AutocompleteView _fetch() async { assert(!_isFetching); _isFetching = true; - final result = await getStreamTopics(store.connection, streamId: streamId); + final result = await getStreamTopics(store.connection, streamId: streamId, + allowEmptyTopicName: true, + ); _topics = result.topics.map((e) => e.name); _isFetching = false; return _startSearch(); @@ -921,7 +1194,7 @@ class TopicAutocompleteView extends AutocompleteView getGlobalStore(); + /// Get the app's singleton [GlobalStore] if already loaded, else null. + /// + /// Where possible, use [GlobalStoreWidget.of] to get access to a [GlobalStore]. + /// Use this method only in contexts where getting access to a [BuildContext] + /// is inconvenient. + GlobalStore? getGlobalStoreSync(); + /// Like [getGlobalStore], but assert this method was not previously called. /// /// This is used by the implementation of [GlobalStoreWidget], @@ -113,6 +121,11 @@ abstract class ZulipBinding { /// This wraps [url_launcher.closeInAppWebView]. Future closeInAppWebView(); + /// Provides access to the current UTC date and time. + /// + /// Outside tests, this just calls [DateTime.timestamp]. + DateTime utcNow(); + /// Provides access to a new stopwatch. /// /// Outside tests, this just calls the [Stopwatch] constructor. @@ -168,6 +181,11 @@ abstract class ZulipBinding { /// Wraps the [AndroidNotificationHostApi] constructor. AndroidNotificationHostApi get androidNotificationHost; + /// Wraps the [notif_pigeon.NotificationHostApi] class. + NotificationPigeonApi get notificationPigeonApi; + + Stream get androidIntentEvents; + /// Pick files from the media library, via package:file_picker. /// /// This wraps [file_picker.pickFiles]. @@ -303,16 +321,31 @@ class LinuxDeviceInfo implements BaseDeviceInfo { class PackageInfo { final String version; final String buildNumber; + final String packageName; const PackageInfo({ required this.version, required this.buildNumber, + required this.packageName, }); } +// Pigeon generates methods under `@EventChannelApi` annotated classes +// in global scope of the generated file. This is a helper class to +// namespace the notification related Pigeon API under a single class. +class NotificationPigeonApi { + final _hostApi = notif_pigeon.NotificationHostApi(); + + Future getNotificationDataFromLaunch() => + _hostApi.getNotificationDataFromLaunch(); + + Stream notificationTapEventsStream() => + notif_pigeon.notificationTapEvents(); +} + /// A concrete binding for use in the live application. /// -/// The global store returned by [loadGlobalStore], and consequently by +/// The global store returned by [getGlobalStore], and consequently by /// [GlobalStoreWidget.of] in application code, will be a [LiveGlobalStore]. /// It therefore uses a live server and live, persistent local database. /// @@ -333,8 +366,17 @@ class LiveZulipBinding extends ZulipBinding { } @override - Future getGlobalStore() => _globalStore ??= LiveGlobalStore.load(); - Future? _globalStore; + Future getGlobalStore() { + return _globalStoreFuture ??= LiveGlobalStore.load().then((store) { + return _globalStore = store; + }); + } + + @override + GlobalStore? getGlobalStoreSync() => _globalStore; + + Future? _globalStoreFuture; + GlobalStore? _globalStore; @override Future getGlobalStoreUniquely() { @@ -365,6 +407,9 @@ class LiveZulipBinding extends ZulipBinding { return url_launcher.closeInAppWebView(); } + @override + DateTime utcNow() => DateTime.timestamp(); + @override Stopwatch stopwatch() => Stopwatch(); @@ -411,6 +456,7 @@ class LiveZulipBinding extends ZulipBinding { _syncPackageInfo = PackageInfo( version: info.version, buildNumber: info.buildNumber, + packageName: info.packageName, ); } catch (e, st) { assert(debugLog('Failed to prefetch package info: $e\n$st')); // TODO(log) @@ -442,6 +488,12 @@ class LiveZulipBinding extends ZulipBinding { @override AndroidNotificationHostApi get androidNotificationHost => AndroidNotificationHostApi(); + @override + NotificationPigeonApi get notificationPigeonApi => NotificationPigeonApi(); + + @override + Stream get androidIntentEvents => android_intents_pigeon.androidIntentEvents(); + @override Future pickFiles({ bool allowMultiple = false, diff --git a/lib/model/channel.dart b/lib/model/channel.dart index 3f5fbe33af..ae864e7ed5 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -1,8 +1,13 @@ +import 'dart:collection'; + import 'package:flutter/foundation.dart'; import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; +import 'realm.dart'; +import 'store.dart'; +import 'user.dart'; /// The portion of [PerAccountStore] for channels, topics, and stuff about them. /// @@ -10,7 +15,10 @@ import '../api/model/model.dart'; /// implementation of [PerAccountStore], to avoid circularity. /// /// The data structures described here are implemented at [ChannelStoreImpl]. -mixin ChannelStore { +mixin ChannelStore on UserStore { + @protected + UserStore get userStore; + /// All known channels/streams, indexed by [ZulipStream.streamId]. /// /// The same [ZulipStream] objects also appear in [streamsByName]. @@ -41,6 +49,8 @@ mixin ChannelStore { /// /// For policies directly applicable in the UI, see /// [isTopicVisibleInStream] and [isTopicVisible]. + /// + /// Topics are treated case-insensitively; see [TopicName.isSameAs]. UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic); /// The raw data structure underlying [topicVisibilityPolicy]. @@ -69,10 +79,10 @@ mixin ChannelStore { /// Whether the given event will change the result of [isTopicVisibleInStream] /// for its stream and topic, compared to the current state. - VisibilityEffect willChangeIfTopicVisibleInStream(UserTopicEvent event) { + UserTopicVisibilityEffect willChangeIfTopicVisibleInStream(UserTopicEvent event) { final streamId = event.streamId; final topic = event.topicName; - return VisibilityEffect._fromBeforeAfter( + return UserTopicVisibilityEffect._fromBeforeAfter( _isTopicVisibleInStream(topicVisibilityPolicy(streamId, topic)), _isTopicVisibleInStream(event.visibilityPolicy)); } @@ -106,10 +116,10 @@ mixin ChannelStore { /// Whether the given event will change the result of [isTopicVisible] /// for its stream and topic, compared to the current state. - VisibilityEffect willChangeIfTopicVisible(UserTopicEvent event) { + UserTopicVisibilityEffect willChangeIfTopicVisible(UserTopicEvent event) { final streamId = event.streamId; final topic = event.topicName; - return VisibilityEffect._fromBeforeAfter( + return UserTopicVisibilityEffect._fromBeforeAfter( _isTopicVisible(streamId, topicVisibilityPolicy(streamId, topic)), _isTopicVisible(streamId, event.visibilityPolicy)); } @@ -132,12 +142,75 @@ mixin ChannelStore { return true; } } + + bool selfHasContentAccess(ZulipStream channel) { + // Compare web's stream_data.has_content_access. + if (channel.isWebPublic) return true; + if (channel is Subscription) return true; + // Here web calls has_metadata_access... but that always returns true, + // as its comment says. + if (selfUser.role == UserRole.guest) return false; + if (!channel.inviteOnly) return true; + return _selfHasContentAccessViaGroupPermissions(channel); + } + + bool _selfHasContentAccessViaGroupPermissions(ZulipStream channel) { + // Compare web's stream_data.has_content_access_via_group_permissions. + // TODO(#814) try to clean up this logic; perhaps record more explicitly + // what default/fallback value to use for a given group-based permission + // on older servers. + + if (channel.canAddSubscribersGroup != null + && selfHasPermissionForGroupSetting(channel.canAddSubscribersGroup!, + GroupSettingType.stream, 'can_add_subscribers_group')) { + // The behavior before this permission was introduced was equivalent to + // the "nobody" group. + // TODO(server-10): simplify + return true; + } + + if (channel.canSubscribeGroup != null + && selfHasPermissionForGroupSetting(channel.canSubscribeGroup!, + GroupSettingType.stream, 'can_subscribe_group')) { + // The behavior before this permission was introduced was equivalent to + // the "nobody" group. + // TODO(server-10): simplify + return true; + } + + return false; + } + + bool hasPostingPermission({ + required ZulipStream inChannel, + required User user, + required DateTime byDate, + }) { + final role = user.role; + // We let the users with [unknown] role to send the message, then the server + // will decide to accept it or not based on its actual role. + if (role == UserRole.unknown) return true; + + switch (inChannel.channelPostPolicy) { + case ChannelPostPolicy.any: return true; + case ChannelPostPolicy.fullMembers: { + if (!role.isAtLeast(UserRole.member)) return false; + if (role == UserRole.member) { + return hasPassedWaitingPeriod(user, byDate: byDate); + } + return true; + } + case ChannelPostPolicy.moderators: return role.isAtLeast(UserRole.moderator); + case ChannelPostPolicy.administrators: return role.isAtLeast(UserRole.administrator); + case ChannelPostPolicy.unknown: return true; + } + } } /// Whether and how a given [UserTopicEvent] will affect the results /// that [ChannelStore.isTopicVisible] or [ChannelStore.isTopicVisibleInStream] /// would give for some messages. -enum VisibilityEffect { +enum UserTopicVisibilityEffect { /// The event will have no effect on the visibility results. none, @@ -147,22 +220,59 @@ enum VisibilityEffect { /// The event will change some visibility results from false to true. unmuted; - factory VisibilityEffect._fromBeforeAfter(bool before, bool after) { + factory UserTopicVisibilityEffect._fromBeforeAfter(bool before, bool after) { return switch ((before, after)) { - (false, true) => VisibilityEffect.unmuted, - (true, false) => VisibilityEffect.muted, - _ => VisibilityEffect.none, + (false, true) => UserTopicVisibilityEffect.unmuted, + (true, false) => UserTopicVisibilityEffect.muted, + _ => UserTopicVisibilityEffect.none, }; } } +mixin ProxyChannelStore on ChannelStore { + @protected + ChannelStore get channelStore; + + @override + Map get streams => channelStore.streams; + + @override + Map get streamsByName => channelStore.streamsByName; + + @override + Map get subscriptions => channelStore.subscriptions; + + @override + UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) => + channelStore.topicVisibilityPolicy(streamId, topic); + + @override + Map> get debugTopicVisibility => + channelStore.debugTopicVisibility; +} + +/// A base class for [PerAccountStore] substores +/// that need access to [ChannelStore] as well as to its prerequisites +/// [CorePerAccountStore], [RealmStore], and [UserStore]. +abstract class HasChannelStore extends HasUserStore with ChannelStore, ProxyChannelStore { + HasChannelStore({required ChannelStore channels}) + : channelStore = channels, super(users: channels.userStore); + + @protected + @override + final ChannelStore channelStore; +} + /// The implementation of [ChannelStore] that does the work. /// /// Generally the only code that should need this class is [PerAccountStore] /// itself. Other code accesses this functionality through [PerAccountStore], /// or through the mixin [ChannelStore] which describes its interface. -class ChannelStoreImpl with ChannelStore { - factory ChannelStoreImpl({required InitialSnapshot initialSnapshot}) { +class ChannelStoreImpl extends HasUserStore with ChannelStore { + factory ChannelStoreImpl({ + required UserStore users, + required InitialSnapshot initialSnapshot, + }) { final subscriptions = Map.fromEntries(initialSnapshot.subscriptions.map( (subscription) => MapEntry(subscription.streamId, subscription))); @@ -171,17 +281,18 @@ class ChannelStoreImpl with ChannelStore { streams.putIfAbsent(stream.streamId, () => stream); } - final topicVisibility = >{}; + final topicVisibility = >{}; for (final item in initialSnapshot.userTopics ?? const []) { if (_warnInvalidVisibilityPolicy(item.visibilityPolicy)) { // Not a value we expect. Keep it out of our data structures. // TODO(log) continue; } - final forStream = topicVisibility.putIfAbsent(item.streamId, () => {}); + final forStream = topicVisibility.putIfAbsent(item.streamId, () => makeTopicKeyedMap()); forStream[item.topicName] = item.visibilityPolicy; } return ChannelStoreImpl._( + users: users, streams: streams, streamsByName: streams.map((_, stream) => MapEntry(stream.name, stream)), subscriptions: subscriptions, @@ -190,6 +301,7 @@ class ChannelStoreImpl with ChannelStore { } ChannelStoreImpl._({ + required super.users, required this.streams, required this.streamsByName, required this.subscriptions, @@ -204,9 +316,9 @@ class ChannelStoreImpl with ChannelStore { final Map subscriptions; @override - Map> get debugTopicVisibility => topicVisibility; + Map> get debugTopicVisibility => topicVisibility; - final Map> topicVisibility; + final Map> topicVisibility; @override UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) { @@ -269,6 +381,8 @@ class ChannelStoreImpl with ChannelStore { stream.name = event.value as String; streamsByName.remove(streamName); streamsByName[stream.name] = stream; + case ChannelPropertyName.isArchived: + stream.isArchived = event.value as bool; case ChannelPropertyName.description: stream.description = event.value as String; case ChannelPropertyName.firstMessageId: @@ -279,6 +393,14 @@ class ChannelStoreImpl with ChannelStore { stream.messageRetentionDays = event.value as int?; case ChannelPropertyName.channelPostPolicy: stream.channelPostPolicy = event.value as ChannelPostPolicy; + case ChannelPropertyName.canAddSubscribersGroup: + stream.canAddSubscribersGroup = event.value as GroupSettingValue; + case ChannelPropertyName.canDeleteAnyMessageGroup: + stream.canDeleteAnyMessageGroup = event.value as GroupSettingValue; + case ChannelPropertyName.canDeleteOwnMessageGroup: + stream.canDeleteOwnMessageGroup = event.value as GroupSettingValue; + case ChannelPropertyName.canSubscribeGroup: + stream.canSubscribeGroup = event.value as GroupSettingValue; case ChannelPropertyName.streamWeeklyTraffic: stream.streamWeeklyTraffic = event.value as int?; } @@ -301,7 +423,16 @@ class ChannelStoreImpl with ChannelStore { case SubscriptionRemoveEvent(): for (final streamId in event.streamIds) { - subscriptions.remove(streamId); + assert(streams.containsKey(streamId)); + assert(streams[streamId] is Subscription); + assert(streamsByName.containsKey(streams[streamId]!.name)); + assert(streamsByName[streams[streamId]!.name] is Subscription); + assert(subscriptions.containsKey(streamId)); + final subscription = subscriptions.remove(streamId); + if (subscription == null) continue; // TODO(log) + final stream = ZulipStream.fromSubscription(subscription); + streams[streamId] = stream; + streamsByName[subscription.name] = stream; } case SubscriptionUpdateEvent(): @@ -313,7 +444,7 @@ class ChannelStoreImpl with ChannelStore { case SubscriptionProperty.color: subscription.color = event.value as int; case SubscriptionProperty.isMuted: - // TODO(#421) update [MessageListView] if affected + // TODO(#1255) update [MessageListView] if affected subscription.isMuted = event.value as bool; case SubscriptionProperty.inHomeView: subscription.isMuted = !(event.value as bool); @@ -345,7 +476,6 @@ class ChannelStoreImpl with ChannelStore { if (_warnInvalidVisibilityPolicy(visibilityPolicy)) { visibilityPolicy = UserTopicVisibilityPolicy.none; } - // TODO(#421) update [MessageListView] if affected if (visibilityPolicy == UserTopicVisibilityPolicy.none) { // This is the "zero value" for this type, which our data structure // represents by leaving the topic out entirely. @@ -356,8 +486,26 @@ class ChannelStoreImpl with ChannelStore { topicVisibility.remove(event.streamId); } } else { - final forStream = topicVisibility.putIfAbsent(event.streamId, () => {}); + final forStream = topicVisibility.putIfAbsent(event.streamId, () => makeTopicKeyedMap()); forStream[event.topicName] = visibilityPolicy; } } } + +/// A [Map] with [TopicName] keys and [V] values. +/// +/// When one of these is created by [makeTopicKeyedMap], +/// key equality is done case-insensitively; see there. +/// +/// This type should only be used for maps created by [makeTopicKeyedMap]. +/// It would be nice to enforce that. +typedef TopicKeyedMap = Map; + +/// Make a case-insensitive, case-preserving [TopicName]-keyed [LinkedHashMap]. +/// +/// The equality function is [TopicName.isSameAs], +/// and the hash code is [String.hashCode] of [TopicName.canonicalize]. +TopicKeyedMap makeTopicKeyedMap() => LinkedHashMap( + equals: (a, b) => a.isSameAs(b), + hashCode: (k) => k.canonicalize().hashCode, +); diff --git a/lib/model/compose.dart b/lib/model/compose.dart index 54c7f6ce00..3a7a75976f 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -5,6 +5,7 @@ import '../generated/l10n/zulip_localizations.dart'; import 'internal_link.dart'; import 'narrow.dart'; import 'store.dart'; +import 'user.dart'; /// The available user wildcard mention options, /// known to the server as [canonicalString]. @@ -127,21 +128,41 @@ String wrapWithBacktickFence({required String content, String? infoString}) { /// 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 +/// pass the full UserStore. This means accepting a linear scan /// through all users; avoid it in performance-sensitive codepaths. -String userMention(User user, {bool silent = false, Map? users}) { +/// +/// See also [userMentionFromMessage]. +String userMention(User user, {bool silent = false, UserStore? users}) { bool includeUserId = users == null - || users.values.where((u) => u.fullName == user.fullName).take(2).length == 2; - - return '@${silent ? '_' : ''}**${user.fullName}${includeUserId ? '|${user.userId}' : ''}**'; + || users.allUsers.where((u) => u.fullName == user.fullName) + .take(2).length == 2; + return _userMentionImpl( + silent: silent, + fullName: user.fullName, + userId: includeUserId ? user.userId : null); } +/// An @-mention of an individual user, like @**Chris Bobbe|13313**, +/// from sender data in a [Message]. +/// +/// The user ID part ("|13313") is always included. +/// +/// See also [userMention]. +String userMentionFromMessage(Message message, {bool silent = false, required UserStore users}) => + _userMentionImpl( + silent: silent, + fullName: users.senderDisplayName(message, replaceIfMuted: false), + userId: message.senderId); + +String _userMentionImpl({required bool silent, required String fullName, int? userId}) => + '@${silent ? '_' : ''}**$fullName${userId != null ? '|$userId' : ''}**'; + /// An @-mention of all the users in a conversation, like @**channel**. String wildcardMention(WildcardMentionOption wildcardOption, { required PerAccountStore store, }) { - final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9) - final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(server-8) + final isChannelWildcardAvailable = store.zulipFeatureLevel >= 247; // TODO(server-9) + final isTopicWildcardAvailable = store.zulipFeatureLevel >= 224; // TODO(server-8) String name = wildcardOption.canonicalString; switch (wildcardOption) { @@ -160,6 +181,10 @@ String wildcardMention(WildcardMentionOption wildcardOption, { return '@**$name**'; } +/// An @-mention of a user group, like @*mobile*. +String userGroupMention(String userGroupName, {bool silent = false}) => + '@${silent ? '_' : ''}*$userGroupName*'; + /// https://spec.commonmark.org/0.30/#inline-link /// /// The "link text" is made by enclosing [visibleText] in square brackets. @@ -167,8 +192,8 @@ String wildcardMention(WildcardMentionOption wildcardOption, { /// result may be surprising. /// /// The part between "(" and ")" is just a "link destination" (no "link title"). -/// That destination is simply the stringified [destination], if provided. -/// If that has parentheses in it, the result may be surprising. +/// That destination is the string [destination]. +/// If [destination] has parentheses in it, the result may be surprising. // TODO: Try harder to guarantee output that creates an inline link, // and in particular, the intended one. We could help with this by escaping // square brackets, perhaps with HTML character references: @@ -178,8 +203,8 @@ String wildcardMention(WildcardMentionOption wildcardOption, { // > Backtick code spans, autolinks, and raw HTML tags bind more tightly // > than the brackets in link text. Thus, for example, [foo`]` could not be // > a link text, since the second ] is part of a code span. -String inlineLink(String visibleText, Uri? destination) { - return '[$visibleText](${destination?.toString() ?? ''})'; +String inlineLink(String visibleText, String destination) { + return '[$visibleText]($destination)'; } /// What we show while fetching the target message's raw Markdown. @@ -188,13 +213,11 @@ String quoteAndReplyPlaceholder( PerAccountStore store, { required Message message, }) { - final sender = store.users[message.senderId]; - assert(sender != null); final url = narrowLink(store, SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), nearMessageId: message.id); - // See note in [quoteAndReply] about asking `mention` to omit the | part. - return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(#1285) + return '${userMentionFromMessage(message, silent: true, users: store)} ' + '${inlineLink('said', url.toString())}: ' // TODO(#1285) '*${zulipLocalizations.composeBoxLoadingMessage(message.id)}*\n'; } @@ -210,14 +233,14 @@ String quoteAndReply(PerAccountStore store, { required Message message, required String rawContent, }) { - final sender = store.users[message.senderId]; - assert(sender != null); final url = narrowLink(store, SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), nearMessageId: message.id); - // Could ask `mention` to omit the | part unless the mention is ambiguous… - // but that would mean a linear scan through all users, and the extra noise - // won't much matter with the already probably-long message link in there too. - return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(#1285) - '${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}'; + // Could ask userMentionFromMessage to omit the | part unless the mention + // is ambiguous… but that would mean a linear scan through all users, + // and the extra noise won't much matter with the already probably-long + // message link in there too. + return '${userMentionFromMessage(message, silent: true, users: store)} ' + '${inlineLink('said', url.toString())}:\n' // TODO(#1285) + '${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}'; } diff --git a/lib/model/content.dart b/lib/model/content.dart index e228163a2e..4413857173 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -6,6 +6,7 @@ import 'package:html/parser.dart'; import '../api/model/model.dart'; import '../api/model/submessage.dart'; import 'code_block.dart'; +import 'katex.dart'; /// A node in a parse tree for Zulip message-style content. /// @@ -251,21 +252,11 @@ class HeadingNode extends BlockInlineContainerNode { } } -enum ListStyle { ordered, unordered } +sealed class ListNode extends BlockContentNode { + const ListNode(this.items, {super.debugHtmlNode}); -class ListNode extends BlockContentNode { - const ListNode(this.style, this.items, {super.debugHtmlNode}); - - final ListStyle style; final List> items; - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(FlagProperty('ordered', value: style == ListStyle.ordered, - ifTrue: 'ordered', ifFalse: 'unordered')); - } - @override List debugDescribeChildren() { return items @@ -275,6 +266,22 @@ class ListNode extends BlockContentNode { } } +class UnorderedListNode extends ListNode { + const UnorderedListNode(super.items, {super.debugHtmlNode}); +} + +class OrderedListNode extends ListNode { + const OrderedListNode(super.items, {required this.start, super.debugHtmlNode}); + + final int start; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IntProperty('start', start)); + } +} + class QuotationNode extends BlockContentNode { const QuotationNode(this.nodes, {super.debugHtmlNode}); @@ -334,26 +341,189 @@ class CodeBlockSpanNode extends ContentNode { } } -class MathBlockNode extends BlockContentNode { - const MathBlockNode({super.debugHtmlNode, required this.texSource}); +/// A complete KaTeX math expression within Zulip content, +/// whether block or inline. +/// +/// The content nodes that are descendants of this node +/// will all be of KaTeX-specific types, such as [KatexNode]. +sealed class MathNode extends ContentNode { + const MathNode({ + super.debugHtmlNode, + required this.texSource, + required this.nodes, + this.debugHardFailReason, + this.debugSoftFailReason, + }); final String texSource; + /// Parsed KaTeX node tree to be used for rendering the KaTeX content. + /// + /// It will be null if the parser encounters an unsupported HTML element or + /// CSS style, indicating that the widget should render the [texSource] as a + /// fallback instead. + final List? nodes; + + final KatexParserHardFailReason? debugHardFailReason; + final KatexParserSoftFailReason? debugSoftFailReason; + @override - bool operator ==(Object other) { - return other is MathBlockNode && other.texSource == texSource; + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('texSource', texSource)); } @override - int get hashCode => Object.hash('MathBlockNode', texSource); + List debugDescribeChildren() { + return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const []; + } +} + +/// A content node that expects a generic KaTeX context from its parent. +/// +/// Each of these will have a [MathNode] as an ancestor. +sealed class KatexNode extends ContentNode { + const KatexNode({super.debugHtmlNode}); +} + +/// A generic KaTeX content node, corresponding to any span in KaTeX HTML +/// that we don't otherwise specially handle. +class KatexSpanNode extends KatexNode { + const KatexSpanNode({ + this.styles = const KatexSpanStyles(), + this.text, + this.nodes, + super.debugHtmlNode, + }) : assert((text != null) ^ (nodes != null)); + + final KatexSpanStyles styles; + + /// The text this KaTeX node contains. + /// + /// It will be null if [nodes] is non-null. + final String? text; + + /// The child nodes of this node in the KaTeX HTML tree. + /// + /// It will be null if [text] is non-null. + final List? nodes; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(StringProperty('texSource', texSource)); + properties.add(DiagnosticsProperty('styles', styles)); + properties.add(StringProperty('text', text)); + } + + @override + List debugDescribeChildren() { + return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const []; } } +/// A KaTeX strut, corresponding to a `span.strut` node in KaTeX HTML. +class KatexStrutNode extends KatexNode { + const KatexStrutNode({ + required this.heightEm, + required this.verticalAlignEm, + super.debugHtmlNode, + }); + + final double heightEm; + final double? verticalAlignEm; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('heightEm', heightEm)); + properties.add(DoubleProperty('verticalAlignEm', verticalAlignEm)); + } +} + +/// A KaTeX "vertical list", corresponding to a `span.vlist-t` in KaTeX HTML. +/// +/// These nodes in KaTeX HTML have a very specific structure. +/// The children of these nodes in our tree correspond in the HTML to +/// certain great-grandchildren (certain `> .vlist-r > .vlist > span`) +/// of the `.vlist-t` node. +class KatexVlistNode extends KatexNode { + const KatexVlistNode({ + required this.rows, + super.debugHtmlNode, + }); + + final List rows; + + @override + List debugDescribeChildren() { + return rows.map((row) => row.toDiagnosticsNode()).toList(); + } +} + +/// An element of a KaTeX "vertical list"; a child of a [KatexVlistNode]. +/// +/// These correspond to certain `.vlist-t > .vlist-r > .vlist > span` nodes +/// in KaTeX HTML. The [KatexVlistNode] parent in our tree +/// corresponds to the `.vlist-t` great-grandparent in the HTML. +class KatexVlistRowNode extends ContentNode { + const KatexVlistRowNode({ + required this.verticalOffsetEm, + required this.node, + super.debugHtmlNode, + }); + + final double verticalOffsetEm; + final KatexSpanNode node; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('verticalOffsetEm', verticalOffsetEm)); + } + + @override + List debugDescribeChildren() { + return [node.toDiagnosticsNode()]; + } +} + +/// A KaTeX node corresponding to negative values for `margin-left` +/// or `margin-right` in the inline CSS style of a KaTeX HTML node. +/// +/// The parser synthesizes these as additional nodes, not corresponding +/// directly to any node in the HTML. +class KatexNegativeMarginNode extends KatexNode { + const KatexNegativeMarginNode({ + required this.leftOffsetEm, + required this.nodes, + super.debugHtmlNode, + }) : assert(leftOffsetEm < 0); + + final double leftOffsetEm; + final List nodes; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('leftOffsetEm', leftOffsetEm)); + } + + @override + List debugDescribeChildren() { + return nodes.map((node) => node.toDiagnosticsNode()).toList(); + } +} + +class MathBlockNode extends MathNode implements BlockContentNode { + const MathBlockNode({ + super.debugHtmlNode, + required super.texSource, + required super.nodes, + super.debugHardFailReason, + super.debugSoftFailReason, + }); +} + class ImageNodeList extends BlockContentNode { const ImageNodeList(this.images, {super.debugHtmlNode}); @@ -504,6 +674,58 @@ class EmbedVideoNode extends BlockContentNode { } } +// See: +// https://ogp.me/ +// https://oembed.com/ +// https://zulip.com/help/image-video-and-website-previews#configure-whether-website-previews-are-shown +class WebsitePreviewNode extends BlockContentNode { + const WebsitePreviewNode({ + super.debugHtmlNode, + required this.hrefUrl, + required this.imageSrcUrl, + required this.title, + required this.description, + }); + + /// The URL from which this preview data was retrieved. + final String hrefUrl; + + /// The image URL representing the webpage, content value + /// of `og:image` HTML meta property. + final String imageSrcUrl; + + /// Represents the webpage title, derived from either + /// the content of the `og:title` HTML meta property or + /// the HTML element. + final String? title; + + /// Description about the webpage, content value of + /// `og:description` HTML meta property. + final String? description; + + @override + bool operator ==(Object other) { + return other is WebsitePreviewNode + && other.hrefUrl == hrefUrl + && other.imageSrcUrl == imageSrcUrl + && other.title == title + && other.description == description; + } + + @override + int get hashCode => + Object.hash('WebsitePreviewNode', hrefUrl, imageSrcUrl, title, description); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('hrefUrl', hrefUrl)); + properties.add(StringProperty('imageSrcUrl', imageSrcUrl)); + properties.add(StringProperty('title', title)); + properties.add(StringProperty('description', description)); + } +} + class TableNode extends BlockContentNode { const TableNode({super.debugHtmlNode, required this.rows}); @@ -763,24 +985,14 @@ class ImageEmojiNode extends EmojiNode { } } -class MathInlineNode extends InlineContentNode { - const MathInlineNode({super.debugHtmlNode, required this.texSource}); - - final String texSource; - - @override - bool operator ==(Object other) { - return other is MathInlineNode && other.texSource == texSource; - } - - @override - int get hashCode => Object.hash('MathInlineNode', texSource); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(StringProperty('texSource', texSource)); - } +class MathInlineNode extends MathNode implements InlineContentNode { + const MathInlineNode({ + super.debugHtmlNode, + required super.texSource, + required super.nodes, + super.debugHardFailReason, + super.debugSoftFailReason, + }); } class GlobalTimeNode extends InlineContentNode { @@ -804,53 +1016,7 @@ class GlobalTimeNode extends InlineContentNode { } } -//////////////////////////////////////////////////////////////// - -String? _parseMath(dom.Element element, {required bool 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'); - - 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 (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. /// @@ -862,9 +1028,14 @@ String? _parseMath(dom.Element element, {required bool block}) { 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); + final parsed = parseMath(element, block: false); + if (parsed == null) return null; + return MathInlineNode( + texSource: parsed.texSource, + nodes: parsed.nodes, + debugHtmlNode: debugHtmlNode, + debugHardFailReason: kDebugMode ? parsed.hardFailReason : null, + debugSoftFailReason: kDebugMode ? parsed.softFailReason : null); } UserMentionNode? parseUserMention(dom.Element element) { @@ -1019,6 +1190,22 @@ class _ZulipInlineContentParser { return GlobalTimeNode(datetime: datetime, debugHtmlNode: debugHtmlNode); } + if (localName == 'audio' && className.isEmpty) { + final srcAttr = element.attributes['src']; + if (srcAttr == null) return unimplemented(); + + final String title = switch (element.attributes) { + {'title': final titleAttr} => titleAttr, + _ => Uri.tryParse(srcAttr)?.pathSegments.lastOrNull ?? srcAttr, + }; + + final link = LinkNode( + url: srcAttr, + nodes: [TextNode(title)]); + (_linkNodes ??= []).add(link); + return link; + } + if (localName == 'span' && className == 'katex') { return parseInlineMath(element) ?? unimplemented(); } @@ -1055,20 +1242,8 @@ class _ZulipContentParser { 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) { - ListStyle? listStyle; - switch (element.localName) { - case 'ol': listStyle = ListStyle.ordered; break; - case 'ul': listStyle = ListStyle.unordered; break; - } - assert(listStyle != null); + assert(element.localName == 'ol' || element.localName == 'ul'); assert(element.className.isEmpty); final debugHtmlNode = kDebugMode ? element : null; @@ -1081,7 +1256,15 @@ class _ZulipContentParser { items.add(parseImplicitParagraphBlockContentList(item.nodes)); } - return ListNode(listStyle!, items, debugHtmlNode: debugHtmlNode); + if (element.localName == 'ol') { + final startAttr = element.attributes['start']; + final start = startAttr == null ? 1 + : int.tryParse(startAttr, radix: 10); + if (start == null) return UnimplementedBlockContentNode(htmlNode: element); + return OrderedListNode(items, start: start, debugHtmlNode: debugHtmlNode); + } else { + return UnorderedListNode(items, debugHtmlNode: debugHtmlNode); + } } BlockContentNode parseSpoilerNode(dom.Element divElement) { @@ -1346,6 +1529,113 @@ class _ZulipContentParser { return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode); } + static final _websitePreviewImageSrcRegexp = RegExp(r'background-image: url\(("?)(.+?)\1\)'); + + BlockContentNode parseWebsitePreviewNode(dom.Element divElement) { + assert(divElement.localName == 'div' + && divElement.className == 'message_embed'); + + final debugHtmlNode = kDebugMode ? divElement : null; + final result = () { + if (divElement.nodes case [ + dom.Element( + localName: 'a', + className: 'message_embed_image', + attributes: { + 'href': final String imageHref, + 'style': final String imageStyleAttr, + }, + nodes: []), + dom.Element( + localName: 'div', + className: 'data-container', + nodes: [...]) && final dataContainer, + ]) { + final match = _websitePreviewImageSrcRegexp.firstMatch(imageStyleAttr); + if (match == null) return null; + final imageSrcUrl = match.group(2); + if (imageSrcUrl == null) return null; + + String? parseTitle(dom.Element element) { + assert(element.localName == 'div' && + element.className == 'message_embed_title'); + if (element.nodes case [ + dom.Element(localName: 'a', className: '') && final child, + ]) { + final titleHref = child.attributes['href']; + // Make sure both image hyperlink and title hyperlink are same. + if (imageHref != titleHref) return null; + + if (child.nodes case [dom.Text(text: final title)]) { + return title; + } + } + return null; + } + + String? parseDescription(dom.Element element) { + assert(element.localName == 'div' && + element.className == 'message_embed_description'); + if (element.nodes case [dom.Text(text: final description)]) { + return description; + } + return null; + } + + String? title, description; + switch (dataContainer.nodes) { + case [ + dom.Element( + localName: 'div', + className: 'message_embed_title') && final first, + dom.Element( + localName: 'div', + className: 'message_embed_description') && final second, + ]: + title = parseTitle(first); + if (title == null) return null; + description = parseDescription(second); + if (description == null) return null; + + case [dom.Element(localName: 'div') && final single]: + switch (single.className) { + case 'message_embed_title': + title = parseTitle(single); + if (title == null) return null; + + case 'message_embed_description': + description = parseDescription(single); + if (description == null) return null; + + default: + return null; + } + + case []: + // Server generates an empty `<div class="data-container"></div>` + // if website HTML has neither title (derived from + // `og:title` or `<title>…`) nor description (derived from + // `og:description`). + break; + + default: + return null; + } + + return WebsitePreviewNode( + hrefUrl: imageHref, + imageSrcUrl: imageSrcUrl, + title: title, + description: description, + debugHtmlNode: debugHtmlNode); + } else { + return null; + } + }(); + + return result ?? UnimplementedBlockContentNode(htmlNode: divElement); + } + BlockContentNode parseTableContent(dom.Element tableElement) { assert(tableElement.localName == 'table' && tableElement.className.isEmpty); @@ -1453,6 +1743,70 @@ class _ZulipContentParser { return tableNode ?? UnimplementedBlockContentNode(htmlNode: tableElement); } + void parseMathBlocks(dom.NodeList nodes, List result) { + assert(nodes.isNotEmpty); + assert((() { + final first = nodes.first; + return first is dom.Element + && first.localName == 'span' + && first.className == 'katex-display'; + })()); + + final firstChild = nodes.first as dom.Element; + final parsed = parseMath(firstChild, block: true); + if (parsed != null) { + result.add(MathBlockNode( + texSource: parsed.texSource, + nodes: parsed.nodes, + debugHtmlNode: kDebugMode ? firstChild : null, + debugHardFailReason: kDebugMode ? parsed.hardFailReason : null, + debugSoftFailReason: kDebugMode ? parsed.softFailReason : null)); + } else { + result.add(UnimplementedBlockContentNode(htmlNode: firstChild)); + } + + // Skip further checks if there was only a single child. + if (nodes.length == 1) return; + + // 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. + bool hasTrailingBreakNewline = false; + if (nodes case [..., dom.Element(localName: 'br'), dom.Text(text: '\n')]) { + hasTrailingBreakNewline = true; + } + + final length = hasTrailingBreakNewline + ? nodes.length - 2 + : nodes.length; + for (int i = 1; i < length; i++) { + final child = nodes[i]; + final debugHtmlNode = kDebugMode ? child : null; + + // If there are multiple nodes in a

+ // each node is interleaved by '\n\n'. Whitespaces are ignored in HTML + // on web but each node has `display: block`, which renders each node + // on a new line. Since the emitted MathBlockNode are BlockContentNode, + // we skip these newlines here to replicate the same behavior as on web. + if (child case dom.Text(text: '\n\n')) continue; + + if (child case dom.Element(localName: 'span', className: 'katex-display')) { + final parsed = parseMath(child, block: true); + if (parsed != null) { + result.add(MathBlockNode( + texSource: parsed.texSource, + nodes: parsed.nodes, + debugHtmlNode: debugHtmlNode, + debugHardFailReason: kDebugMode ? parsed.hardFailReason : null, + debugSoftFailReason: kDebugMode ? parsed.softFailReason : null)); + continue; + } + } + + result.add(UnimplementedBlockContentNode(htmlNode: child)); + } + } + BlockContentNode parseBlockContent(dom.Node node) { final debugHtmlNode = kDebugMode ? node : null; if (node is! dom.Element) { @@ -1471,21 +1825,6 @@ class _ZulipContentParser { } if (localName == 'p' && className.isEmpty) { - // Oddly, the way a math block gets encoded in Zulip HTML is inside a

. - if (element.nodes case [dom.Element(localName: 'span') && var child, ...]) { - if (child.className == 'katex-display') { - if (element.nodes case [_] - || [_, dom.Element(localName: 'br'), - dom.Text(text: "\n")]) { - // This might be too specific; we'll find out when we do #190. - // 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. - return parseMathBlock(child); - } - } - } - final parsed = parseBlockInline(element.nodes); return ParagraphNode(debugHtmlNode: debugHtmlNode, links: parsed.links, @@ -1547,6 +1886,10 @@ class _ZulipContentParser { } } + if (localName == 'div' && className == 'message_embed') { + return parseWebsitePreviewNode(element); + } + // TODO more types of node return UnimplementedBlockContentNode(htmlNode: node); } @@ -1599,6 +1942,17 @@ class _ZulipContentParser { for (final node in nodes) { if (node is dom.Text && (node.text == '\n')) continue; + // Oddly, the way math blocks get encoded in Zulip HTML is inside a

. + // And there can be multiple math blocks inside the paragraph node, so + // handle it explicitly here. + if (node case dom.Element(localName: 'p', className: '', nodes: [ + dom.Element(localName: 'span', className: 'katex-display'), ...])) { + if (currentParagraph.isNotEmpty) consumeParagraph(); + if (imageNodes.isNotEmpty) consumeImageNodes(); + parseMathBlocks(node.nodes, result); + continue; + } + if (_isPossibleInlineNode(node)) { if (imageNodes.isNotEmpty) { consumeImageNodes(); @@ -1642,6 +1996,16 @@ class _ZulipContentParser { continue; } + // Oddly, the way math blocks get encoded in Zulip HTML is inside a

. + // And there can be multiple math blocks inside the paragraph node, so + // handle it explicitly here. + if (node case dom.Element(localName: 'p', className: '', nodes: [ + dom.Element(localName: 'span', className: 'katex-display'), ...])) { + if (imageNodes.isNotEmpty) consumeImageNodes(); + parseMathBlocks(node.nodes, result); + continue; + } + final block = parseBlockContent(node); if (block is ImageNode) { imageNodes.add(block); @@ -1666,3 +2030,9 @@ class _ZulipContentParser { ZulipContent parseContent(String html) { return _ZulipContentParser().parse(html); } + +ZulipMessageContent parseMessageContent(Message message) { + final poll = message.poll; + if (poll != null) return PollContent(poll); + return parseContent(message.content); +} diff --git a/lib/model/database.dart b/lib/model/database.dart index 6ca2aa3726..bce8b2f422 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -1,14 +1,113 @@ -import 'dart:io'; - import 'package:drift/drift.dart'; -import 'package:drift/native.dart'; +import 'package:drift/internal/versioned_schema.dart'; import 'package:drift/remote.dart'; -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; import 'package:sqlite3/common.dart'; +import '../log.dart'; +import 'legacy_app_data.dart'; +import 'schema_versions.g.dart'; +import 'settings.dart'; + part 'database.g.dart'; +/// The table of one [GlobalSettingsData] record, the user's chosen settings +/// on this client that are independent of account. +/// +/// These apply across all the user's accounts on this client (i.e. on this +/// install of the app on this device). +/// +/// This table should always have exactly one row (it's created by a migration). +@DataClassName('GlobalSettingsData') +class GlobalSettings extends Table { + Column get themeSetting => textEnum() + .nullable()(); + + Column get browserPreference => textEnum() + .nullable()(); + + Column get visitFirstUnread => textEnum() + .nullable()(); + + Column get markReadOnScroll => textEnum() + .nullable()(); + + Column get legacyUpgradeState => textEnum() + .nullable()(); + + // If adding a new column to this table, consider whether [BoolGlobalSettings] + // or [IntGlobalSettings] can do the job instead (by adding a value to the + // [BoolGlobalSetting] or [IntGlobalSetting] enum). + // That way is more convenient, when it works, because + // it avoids a migration and therefore several added copies of our schema + // in the Drift generated files. +} + +/// The table of the user's bool-valued, account-independent settings. +/// +/// These apply across all the user's accounts on this client +/// (i.e. on this install of the app on this device). +/// +/// Each row is a [BoolGlobalSettingRow], +/// referring to a possible setting from [BoolGlobalSetting]. +/// For settings in [BoolGlobalSetting] without a row in this table, +/// the setting's value is that of [BoolGlobalSetting.default_]. +/// +/// See also: +/// - [IntGlobalSettings], the int-valued counterpart of this table. +@DataClassName('BoolGlobalSettingRow') +class BoolGlobalSettings extends Table { + /// The setting's name, a possible name from [BoolGlobalSetting]. + /// + /// The table may have rows where [name] is not the name of any + /// enum value in [BoolGlobalSetting]. + /// This happens if the app has previously run at a future or modified + /// version which had additional values in that enum, + /// and the user set one of those additional settings. + /// The app ignores any such unknown rows. + Column get name => text()(); + + /// The user's chosen value for the setting. + /// + /// This is non-nullable; if the user wants to revert to + /// following the app's default for the setting, + /// that can be expressed by deleting the row. + Column get value => boolean()(); + + @override + Set>? get primaryKey => {name}; +} + +/// The table of the user's int-valued, account-independent settings. +/// +/// These apply across all the user's accounts on this client +/// (i.e. on this install of the app on this device). +/// +/// Each row is a [IntGlobalSettingRow], +/// referring to a possible setting from [IntGlobalSetting]. +/// For settings in [IntGlobalSetting] without a row in this table, +/// the setting's value is `null`. +/// +/// See also: +/// - [BoolGlobalSettings], the bool-valued counterpart of this table. +@DataClassName('IntGlobalSettingRow') +class IntGlobalSettings extends Table { + /// The setting's name, a possible name from [IntGlobalSetting]. + /// + /// The table may have rows where [name] is not the name of any + /// enum value in [IntGlobalSetting]. + /// This happens if the app has previously run at a future or modified + /// version which had additional values in that enum, + /// and the user set one of those additional settings. + /// The app ignores any such unknown rows. + TextColumn get name => text()(); + + /// The user's chosen value for the setting. + IntColumn get value => integer()(); + + @override + Set>? get primaryKey => {name}; +} + /// The table of [Account] records in the app's database. class Accounts extends Table { /// The ID of this account in the app's local database. @@ -52,57 +151,175 @@ class UriConverter extends TypeConverter { @override Uri fromSql(String fromDb) => Uri.parse(fromDb); } -LazyDatabase _openConnection() { - return LazyDatabase(() async { - // TODO decide if this path is the right one to use - final dbFolder = await getApplicationDocumentsDirectory(); - final file = File(path.join(dbFolder.path, 'db.sqlite')); - return NativeDatabase.createInBackground(file); - }); -} - -@DriftDatabase(tables: [Accounts]) +@DriftDatabase(tables: [GlobalSettings, BoolGlobalSettings, IntGlobalSettings, Accounts]) class AppDatabase extends _$AppDatabase { AppDatabase(super.e); - AppDatabase.live() : this(_openConnection()); - // When updating the schema: - // * Make the change in the table classes, and bump schemaVersion. - // * Export the new schema and generate test migrations: + // * Make the change in the table classes, and bump latestSchemaVersion. + // * Export the new schema and generate test migrations with drift: // $ tools/check --fix drift - // * Write a migration in `onUpgrade` below. + // and generate database code with build_runner. + // See ../../README.md#generated-files for more + // information on using the build_runner. + // * Write a migration in `_migrationSteps` below. // * Write tests. + static const int latestSchemaVersion = 11; // See note. + @override - int get schemaVersion => 2; // See note. + int get schemaVersion => latestSchemaVersion; + + /// Drop all tables, indexes, etc., in the database. + /// + /// This includes tables that aren't known to the schema, for example because + /// they were defined by a future (perhaps experimental) version of the app + /// before switching back to the version currently running. + static Future _dropAll(Migrator m) async { + final query = m.database.customSelect( + "SELECT name FROM sqlite_master WHERE type='table'"); + for (final row in await query.get()) { + final data = row.data; + final tableName = data['name'] as String; + // Skip sqlite-internal tables. See for comparison: + // https://www.sqlite.org/fileformat2.html#intschema + // https://github.com/simolus3/drift/blob/0901c984a/drift_dev/lib/src/services/schema/verifier_common.dart#L9-L22 + if (tableName.startsWith('sqlite_')) continue; + // No need to worry about SQL injection; this table name + // was already a table name in the database, not something + // that should be affected by user data. + await m.database.customStatement('DROP TABLE $tableName'); + } + } + + static final MigrationStepWithVersion _migrationSteps = migrationSteps( + from1To2: (m, schema) async { + await m.addColumn(schema.accounts, schema.accounts.ackedPushToken); + }, + from2To3: (m, schema) async { + await m.createTable(schema.globalSettings); + }, + from3To4: (m, schema) async { + await m.addColumn( + schema.globalSettings, schema.globalSettings.browserPreference); + }, + from4To5: (m, schema) async { + // Corresponds to the `into(globalSettings).insert` in `onCreate`. + // This migration ensures there is a row in GlobalSettings. + // (If the app already ran at schema 3 or 4, there will be; + // if not, there won't be before this point.) + final rows = await m.database.select(schema.globalSettings).get(); + if (rows.isEmpty) { + await m.database.into(schema.globalSettings).insert( + // No field values; just use the defaults for both fields. + // (This is like `GlobalSettingsCompanion.insert()`, but + // without dependence on the current schema.) + RawValuesInsertable({})); + } + }, + from5To6: (m, schema) async { + await m.createTable(schema.boolGlobalSettings); + }, + from6To7: (m, schema) async { + await m.addColumn(schema.globalSettings, + schema.globalSettings.visitFirstUnread); + }, + from7To8: (m, schema) async { + await m.addColumn(schema.globalSettings, + schema.globalSettings.markReadOnScroll); + }, + from8To9: (m, schema) async { + await m.addColumn(schema.globalSettings, + schema.globalSettings.legacyUpgradeState); + // Earlier versions of this app weren't built to be installed over + // the legacy app. So if upgrading from an earlier version of this app, + // assume there wasn't also the legacy app before that. + await m.database.update(schema.globalSettings).write( + RawValuesInsertable({'legacy_upgrade_state': Constant('noLegacy')})); + }, + from9To10: (m, schema) async { + await m.createTable(schema.intGlobalSettings); + }, + from10To11: (Migrator m, Schema11 schema) async { + // To provide a smooth experience for users when they first install a new + // version of the app with support for the "last visited account" feature, + // we set the first available account as the last visited one. This way, + // the user is still taken straight to the first account, just as they + // were used to before, instead of being shown the "choose account" page. + final firstAccountId = await (m.database.selectOnly(schema.accounts) + ..addColumns([schema.accounts.id]) + ..limit(1) + ).map((row) => row.read(schema.accounts.id)).getSingleOrNull(); + if (firstAccountId == null) return; + + // Like `globalStore.setLastVisitedAccount(firstAccountId)`, + // as of the schema at the time of this migration. + await m.database.into(schema.intGlobalSettings).insert( + RawValuesInsertable({ + 'name': Variable('lastVisitedAccountId'), + 'value': Variable(firstAccountId), + })); + }, + ); + + Future _createLatestSchema(Migrator m) async { + assert(debugLog('Creating DB schema from scratch.')); + await m.createAll(); + // Corresponds to `from4to5` above. + await into(globalSettings).insert(GlobalSettingsCompanion()); + // Corresponds to (but differs from) part of `from8To9` above. + await migrateLegacyAppData(this); + } @override MigrationStrategy get migration { return MigrationStrategy( - onCreate: (Migrator m) async { - await m.createAll(); - }, + onCreate: _createLatestSchema, onUpgrade: (Migrator m, int from, int to) async { if (from > to) { - // TODO(log): log schema downgrade as an error // This should only ever happen in dev. As a dev convenience, // drop everything from the database and start over. - for (final entity in allSchemaEntities) { - // This will miss any entire tables (or indexes, etc.) that - // don't exist at this version. For a dev-only feature, that's OK. - await m.drop(entity); - } - await m.createAll(); + // TODO(log): log schema downgrade as an error + assert(debugLog('Downgrading DB schema from v$from to v$to.')); + + // In the actual app, the target schema version is always + // the latest version as of the code that's being run. + // Migrating to earlier versions is useful only for isolating steps + // in migration tests; we can forego that for testing downgrades. + assert(to == latestSchemaVersion); + + await _dropAll(m); + await _createLatestSchema(m); return; } - assert(1 <= from && from <= to && to <= schemaVersion); + assert(1 <= from && from <= to && to <= latestSchemaVersion); - if (from < 2 && 2 <= to) { - await m.addColumn(accounts, accounts.ackedPushToken); - } - // New migrations go here. - } - ); + assert(debugLog('Upgrading DB schema from v$from to v$to.')); + await m.runMigrationSteps(from: from, to: to, steps: _migrationSteps); + }); + } + + Future getGlobalSettings() async { + // The migrations ensure there is a row. + return await (select(globalSettings)..limit(1)).getSingle(); + } + + Future> getBoolGlobalSettings() async { + final result = {}; + final rows = await select(boolGlobalSettings).get(); + for (final row in rows) { + final setting = BoolGlobalSetting.byName(row.name); + if (setting == null) continue; + result[setting] = row.value; + } + return result; + } + + Future> getIntGlobalSettings() async { + return { + for (final row in await select(intGlobalSettings).get()) + if (IntGlobalSetting.byName(row.name) case final setting?) + setting: row.value + }; } Future createAccount(AccountsCompanion values) async { diff --git a/lib/model/database.g.dart b/lib/model/database.g.dart index f2471d1e2f..bfca5d5857 100644 --- a/lib/model/database.g.dart +++ b/lib/model/database.g.dart @@ -5,6 +5,928 @@ part of 'database.dart'; // ignore_for_file: type=lint +class $GlobalSettingsTable extends GlobalSettings + with TableInfo<$GlobalSettingsTable, GlobalSettingsData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $GlobalSettingsTable(this.attachedDatabase, [this._alias]); + @override + late final GeneratedColumnWithTypeConverter + themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter($GlobalSettingsTable.$converterthemeSettingn); + @override + late final GeneratedColumnWithTypeConverter + browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $GlobalSettingsTable.$converterbrowserPreferencen, + ); + @override + late final GeneratedColumnWithTypeConverter + visitFirstUnread = + GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $GlobalSettingsTable.$convertervisitFirstUnreadn, + ); + @override + late final GeneratedColumnWithTypeConverter + markReadOnScroll = + GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $GlobalSettingsTable.$convertermarkReadOnScrolln, + ); + @override + late final GeneratedColumnWithTypeConverter + legacyUpgradeState = + GeneratedColumn( + 'legacy_upgrade_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $GlobalSettingsTable.$converterlegacyUpgradeStaten, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: $GlobalSettingsTable.$converterthemeSettingn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + ), + browserPreference: $GlobalSettingsTable.$converterbrowserPreferencen + .fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + ), + visitFirstUnread: $GlobalSettingsTable.$convertervisitFirstUnreadn + .fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + ), + markReadOnScroll: $GlobalSettingsTable.$convertermarkReadOnScrolln + .fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mark_read_on_scroll'], + ), + ), + legacyUpgradeState: $GlobalSettingsTable.$converterlegacyUpgradeStaten + .fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}legacy_upgrade_state'], + ), + ), + ); + } + + @override + $GlobalSettingsTable createAlias(String alias) { + return $GlobalSettingsTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 + $converterthemeSetting = const EnumNameConverter( + ThemeSetting.values, + ); + static JsonTypeConverter2 + $converterthemeSettingn = JsonTypeConverter2.asNullable( + $converterthemeSetting, + ); + static JsonTypeConverter2 + $converterbrowserPreference = const EnumNameConverter( + BrowserPreference.values, + ); + static JsonTypeConverter2 + $converterbrowserPreferencen = JsonTypeConverter2.asNullable( + $converterbrowserPreference, + ); + static JsonTypeConverter2 + $convertervisitFirstUnread = const EnumNameConverter( + VisitFirstUnreadSetting.values, + ); + static JsonTypeConverter2 + $convertervisitFirstUnreadn = JsonTypeConverter2.asNullable( + $convertervisitFirstUnread, + ); + static JsonTypeConverter2 + $convertermarkReadOnScroll = const EnumNameConverter( + MarkReadOnScrollSetting.values, + ); + static JsonTypeConverter2 + $convertermarkReadOnScrolln = JsonTypeConverter2.asNullable( + $convertermarkReadOnScroll, + ); + static JsonTypeConverter2 + $converterlegacyUpgradeState = const EnumNameConverter( + LegacyUpgradeState.values, + ); + static JsonTypeConverter2 + $converterlegacyUpgradeStaten = JsonTypeConverter2.asNullable( + $converterlegacyUpgradeState, + ); +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final ThemeSetting? themeSetting; + final BrowserPreference? browserPreference; + final VisitFirstUnreadSetting? visitFirstUnread; + final MarkReadOnScrollSetting? markReadOnScroll; + final LegacyUpgradeState? legacyUpgradeState; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + this.markReadOnScroll, + this.legacyUpgradeState, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable( + $GlobalSettingsTable.$converterthemeSettingn.toSql(themeSetting), + ); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable( + $GlobalSettingsTable.$converterbrowserPreferencen.toSql( + browserPreference, + ), + ); + } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable( + $GlobalSettingsTable.$convertervisitFirstUnreadn.toSql( + visitFirstUnread, + ), + ); + } + if (!nullToAbsent || markReadOnScroll != null) { + map['mark_read_on_scroll'] = Variable( + $GlobalSettingsTable.$convertermarkReadOnScrolln.toSql( + markReadOnScroll, + ), + ); + } + if (!nullToAbsent || legacyUpgradeState != null) { + map['legacy_upgrade_state'] = Variable( + $GlobalSettingsTable.$converterlegacyUpgradeStaten.toSql( + legacyUpgradeState, + ), + ); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + markReadOnScroll: markReadOnScroll == null && nullToAbsent + ? const Value.absent() + : Value(markReadOnScroll), + legacyUpgradeState: legacyUpgradeState == null && nullToAbsent + ? const Value.absent() + : Value(legacyUpgradeState), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: $GlobalSettingsTable.$converterthemeSettingn.fromJson( + serializer.fromJson(json['themeSetting']), + ), + browserPreference: $GlobalSettingsTable.$converterbrowserPreferencen + .fromJson(serializer.fromJson(json['browserPreference'])), + visitFirstUnread: $GlobalSettingsTable.$convertervisitFirstUnreadn + .fromJson(serializer.fromJson(json['visitFirstUnread'])), + markReadOnScroll: $GlobalSettingsTable.$convertermarkReadOnScrolln + .fromJson(serializer.fromJson(json['markReadOnScroll'])), + legacyUpgradeState: $GlobalSettingsTable.$converterlegacyUpgradeStaten + .fromJson(serializer.fromJson(json['legacyUpgradeState'])), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson( + $GlobalSettingsTable.$converterthemeSettingn.toJson(themeSetting), + ), + 'browserPreference': serializer.toJson( + $GlobalSettingsTable.$converterbrowserPreferencen.toJson( + browserPreference, + ), + ), + 'visitFirstUnread': serializer.toJson( + $GlobalSettingsTable.$convertervisitFirstUnreadn.toJson( + visitFirstUnread, + ), + ), + 'markReadOnScroll': serializer.toJson( + $GlobalSettingsTable.$convertermarkReadOnScrolln.toJson( + markReadOnScroll, + ), + ), + 'legacyUpgradeState': serializer.toJson( + $GlobalSettingsTable.$converterlegacyUpgradeStaten.toJson( + legacyUpgradeState, + ), + ), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = const Value.absent(), + Value legacyUpgradeState = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: markReadOnScroll.present + ? markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState.present + ? legacyUpgradeState.value + : this.legacyUpgradeState, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: data.markReadOnScroll.present + ? data.markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: data.legacyUpgradeState.present + ? data.legacyUpgradeState.value + : this.legacyUpgradeState, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread && + other.markReadOnScroll == this.markReadOnScroll && + other.legacyUpgradeState == this.legacyUpgradeState); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value visitFirstUnread; + final Value markReadOnScroll; + final Value legacyUpgradeState; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? visitFirstUnread, + Expression? markReadOnScroll, + Expression? legacyUpgradeState, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, + if (legacyUpgradeState != null) + 'legacy_upgrade_state': legacyUpgradeState, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? visitFirstUnread, + Value? markReadOnScroll, + Value? legacyUpgradeState, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState ?? this.legacyUpgradeState, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable( + $GlobalSettingsTable.$converterthemeSettingn.toSql(themeSetting.value), + ); + } + if (browserPreference.present) { + map['browser_preference'] = Variable( + $GlobalSettingsTable.$converterbrowserPreferencen.toSql( + browserPreference.value, + ), + ); + } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable( + $GlobalSettingsTable.$convertervisitFirstUnreadn.toSql( + visitFirstUnread.value, + ), + ); + } + if (markReadOnScroll.present) { + map['mark_read_on_scroll'] = Variable( + $GlobalSettingsTable.$convertermarkReadOnScrolln.toSql( + markReadOnScroll.value, + ), + ); + } + if (legacyUpgradeState.present) { + map['legacy_upgrade_state'] = Variable( + $GlobalSettingsTable.$converterlegacyUpgradeStaten.toSql( + legacyUpgradeState.value, + ), + ); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $BoolGlobalSettingsTable extends BoolGlobalSettings + with TableInfo<$BoolGlobalSettingsTable, BoolGlobalSettingRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $BoolGlobalSettingsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _valueMeta = const VerificationMeta('value'); + @override + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('value')) { + context.handle( + _valueMeta, + value.isAcceptableOrUnknown(data['value']!, _valueMeta), + ); + } else if (isInserting) { + context.missing(_valueMeta); + } + return context; + } + + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingRow( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + $BoolGlobalSettingsTable createAlias(String alias) { + return $BoolGlobalSettingsTable(attachedDatabase, alias); + } +} + +class BoolGlobalSettingRow extends DataClass + implements Insertable { + /// The setting's name, a possible name from [BoolGlobalSetting]. + /// + /// The table may have rows where [name] is not the name of any + /// enum value in [BoolGlobalSetting]. + /// This happens if the app has previously run at a future or modified + /// version which had additional values in that enum, + /// and the user set one of those additional settings. + /// The app ignores any such unknown rows. + final String name; + + /// The user's chosen value for the setting. + /// + /// This is non-nullable; if the user wants to revert to + /// following the app's default for the setting, + /// that can be expressed by deleting the row. + final bool value; + const BoolGlobalSettingRow({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingRow( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingRow copyWith({String? name, bool? value}) => + BoolGlobalSettingRow(name: name ?? this.name, value: value ?? this.value); + BoolGlobalSettingRow copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingRow( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingRow(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingRow && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $IntGlobalSettingsTable extends IntGlobalSettings + with TableInfo<$IntGlobalSettingsTable, IntGlobalSettingRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $IntGlobalSettingsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _valueMeta = const VerificationMeta('value'); + @override + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'int_global_settings'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('value')) { + context.handle( + _valueMeta, + value.isAcceptableOrUnknown(data['value']!, _valueMeta), + ); + } else if (isInserting) { + context.missing(_valueMeta); + } + return context; + } + + @override + Set get $primaryKey => {name}; + @override + IntGlobalSettingRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return IntGlobalSettingRow( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + $IntGlobalSettingsTable createAlias(String alias) { + return $IntGlobalSettingsTable(attachedDatabase, alias); + } +} + +class IntGlobalSettingRow extends DataClass + implements Insertable { + /// The setting's name, a possible name from [IntGlobalSetting]. + /// + /// The table may have rows where [name] is not the name of any + /// enum value in [IntGlobalSetting]. + /// This happens if the app has previously run at a future or modified + /// version which had additional values in that enum, + /// and the user set one of those additional settings. + /// The app ignores any such unknown rows. + final String name; + + /// The user's chosen value for the setting. + final int value; + const IntGlobalSettingRow({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + IntGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return IntGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory IntGlobalSettingRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return IntGlobalSettingRow( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + IntGlobalSettingRow copyWith({String? name, int? value}) => + IntGlobalSettingRow(name: name ?? this.name, value: value ?? this.value); + IntGlobalSettingRow copyWithCompanion(IntGlobalSettingsCompanion data) { + return IntGlobalSettingRow( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('IntGlobalSettingRow(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is IntGlobalSettingRow && + other.name == this.name && + other.value == this.value); +} + +class IntGlobalSettingsCompanion extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const IntGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + IntGlobalSettingsCompanion.insert({ + required String name, + required int value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + IntGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return IntGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('IntGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { @override final GeneratedDatabase attachedDatabase; @@ -13,129 +935,186 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - static const VerificationMeta _realmUrlMeta = - const VerificationMeta('realmUrl'); + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); @override late final GeneratedColumnWithTypeConverter realmUrl = - GeneratedColumn('realm_url', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter($AccountsTable.$converterrealmUrl); + GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter($AccountsTable.$converterrealmUrl); static const VerificationMeta _userIdMeta = const VerificationMeta('userId'); @override late final GeneratedColumn userId = GeneratedColumn( - 'user_id', aliasedName, false, - type: DriftSqlType.int, requiredDuringInsert: true); + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); static const VerificationMeta _emailMeta = const VerificationMeta('email'); @override late final GeneratedColumn email = GeneratedColumn( - 'email', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); static const VerificationMeta _apiKeyMeta = const VerificationMeta('apiKey'); @override late final GeneratedColumn apiKey = GeneratedColumn( - 'api_key', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _zulipVersionMeta = - const VerificationMeta('zulipVersion'); + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _zulipVersionMeta = const VerificationMeta( + 'zulipVersion', + ); @override late final GeneratedColumn zulipVersion = GeneratedColumn( - 'zulip_version', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _zulipMergeBaseMeta = - const VerificationMeta('zulipMergeBase'); + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _zulipMergeBaseMeta = const VerificationMeta( + 'zulipMergeBase', + ); @override late final GeneratedColumn zulipMergeBase = GeneratedColumn( - 'zulip_merge_base', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _zulipFeatureLevelMeta = - const VerificationMeta('zulipFeatureLevel'); + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _zulipFeatureLevelMeta = const VerificationMeta( + 'zulipFeatureLevel', + ); @override late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( - 'zulip_feature_level', aliasedName, false, - type: DriftSqlType.int, requiredDuringInsert: true); - static const VerificationMeta _ackedPushTokenMeta = - const VerificationMeta('ackedPushToken'); + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _ackedPushTokenMeta = const VerificationMeta( + 'ackedPushToken', + ); @override late final GeneratedColumn ackedPushToken = GeneratedColumn( - 'acked_push_token', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ - id, - realmUrl, - userId, - email, - apiKey, - zulipVersion, - zulipMergeBase, - zulipFeatureLevel, - ackedPushToken - ]; + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; static const String $name = 'accounts'; @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } - context.handle(_realmUrlMeta, const VerificationResult.success()); if (data.containsKey('user_id')) { - context.handle(_userIdMeta, - userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); + context.handle( + _userIdMeta, + userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta), + ); } else if (isInserting) { context.missing(_userIdMeta); } if (data.containsKey('email')) { context.handle( - _emailMeta, email.isAcceptableOrUnknown(data['email']!, _emailMeta)); + _emailMeta, + email.isAcceptableOrUnknown(data['email']!, _emailMeta), + ); } else if (isInserting) { context.missing(_emailMeta); } if (data.containsKey('api_key')) { - context.handle(_apiKeyMeta, - apiKey.isAcceptableOrUnknown(data['api_key']!, _apiKeyMeta)); + context.handle( + _apiKeyMeta, + apiKey.isAcceptableOrUnknown(data['api_key']!, _apiKeyMeta), + ); } else if (isInserting) { context.missing(_apiKeyMeta); } if (data.containsKey('zulip_version')) { context.handle( + _zulipVersionMeta, + zulipVersion.isAcceptableOrUnknown( + data['zulip_version']!, _zulipVersionMeta, - zulipVersion.isAcceptableOrUnknown( - data['zulip_version']!, _zulipVersionMeta)); + ), + ); } else if (isInserting) { context.missing(_zulipVersionMeta); } if (data.containsKey('zulip_merge_base')) { context.handle( + _zulipMergeBaseMeta, + zulipMergeBase.isAcceptableOrUnknown( + data['zulip_merge_base']!, _zulipMergeBaseMeta, - zulipMergeBase.isAcceptableOrUnknown( - data['zulip_merge_base']!, _zulipMergeBaseMeta)); + ), + ); } if (data.containsKey('zulip_feature_level')) { context.handle( + _zulipFeatureLevelMeta, + zulipFeatureLevel.isAcceptableOrUnknown( + data['zulip_feature_level']!, _zulipFeatureLevelMeta, - zulipFeatureLevel.isAcceptableOrUnknown( - data['zulip_feature_level']!, _zulipFeatureLevelMeta)); + ), + ); } else if (isInserting) { context.missing(_zulipFeatureLevelMeta); } if (data.containsKey('acked_push_token')) { context.handle( + _ackedPushTokenMeta, + ackedPushToken.isAcceptableOrUnknown( + data['acked_push_token']!, _ackedPushTokenMeta, - ackedPushToken.isAcceptableOrUnknown( - data['acked_push_token']!, _ackedPushTokenMeta)); + ), + ); } return context; } @@ -144,32 +1123,51 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { Set get $primaryKey => {id}; @override List> get uniqueKeys => [ - {realmUrl, userId}, - {realmUrl, email}, - ]; + {realmUrl, userId}, + {realmUrl, email}, + ]; @override Account map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return Account( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - realmUrl: $AccountsTable.$converterrealmUrl.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}realm_url'])!), - userId: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}user_id'])!, - email: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}email'])!, - apiKey: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}api_key'])!, - zulipVersion: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}zulip_version'])!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: $AccountsTable.$converterrealmUrl.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + ), + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}zulip_merge_base']), + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), zulipFeatureLevel: attachedDatabase.typeMapping.read( - DriftSqlType.int, data['${effectivePrefix}zulip_feature_level'])!, + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}acked_push_token']), + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), ); } @@ -206,23 +1204,25 @@ class Account extends DataClass implements Insertable { final String? zulipMergeBase; final int zulipFeatureLevel; final String? ackedPushToken; - const Account( - {required this.id, - required this.realmUrl, - required this.userId, - required this.email, - required this.apiKey, - required this.zulipVersion, - this.zulipMergeBase, - required this.zulipFeatureLevel, - this.ackedPushToken}); + const Account({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); { - map['realm_url'] = - Variable($AccountsTable.$converterrealmUrl.toSql(realmUrl)); + map['realm_url'] = Variable( + $AccountsTable.$converterrealmUrl.toSql(realmUrl), + ); } map['user_id'] = Variable(userId); map['email'] = Variable(email); @@ -256,8 +1256,10 @@ class Account extends DataClass implements Insertable { ); } - factory Account.fromJson(Map json, - {ValueSerializer? serializer}) { + factory Account.fromJson( + Map json, { + ValueSerializer? serializer, + }) { serializer ??= driftRuntimeOptions.defaultSerializer; return Account( id: serializer.fromJson(json['id']), @@ -287,29 +1289,31 @@ class Account extends DataClass implements Insertable { }; } - Account copyWith( - {int? id, - Uri? realmUrl, - int? userId, - String? email, - String? apiKey, - String? zulipVersion, - Value zulipMergeBase = const Value.absent(), - int? zulipFeatureLevel, - Value ackedPushToken = const Value.absent()}) => - Account( - id: id ?? this.id, - realmUrl: realmUrl ?? this.realmUrl, - userId: userId ?? this.userId, - email: email ?? this.email, - apiKey: apiKey ?? this.apiKey, - zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, - zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, - ); + Account copyWith({ + int? id, + Uri? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => Account( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); Account copyWithCompanion(AccountsCompanion data) { return Account( id: data.id.present ? data.id.value : this.id, @@ -349,8 +1353,17 @@ class Account extends DataClass implements Insertable { } @override - int get hashCode => Object.hash(id, realmUrl, userId, email, apiKey, - zulipVersion, zulipMergeBase, zulipFeatureLevel, ackedPushToken); + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -397,12 +1410,12 @@ class AccountsCompanion extends UpdateCompanion { this.zulipMergeBase = const Value.absent(), required int zulipFeatureLevel, this.ackedPushToken = const Value.absent(), - }) : realmUrl = Value(realmUrl), - userId = Value(userId), - email = Value(email), - apiKey = Value(apiKey), - zulipVersion = Value(zulipVersion), - zulipFeatureLevel = Value(zulipFeatureLevel); + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); static Insertable custom({ Expression? id, Expression? realmUrl, @@ -427,16 +1440,17 @@ class AccountsCompanion extends UpdateCompanion { }); } - AccountsCompanion copyWith( - {Value? id, - Value? realmUrl, - Value? userId, - Value? email, - Value? apiKey, - Value? zulipVersion, - Value? zulipMergeBase, - Value? zulipFeatureLevel, - Value? ackedPushToken}) { + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { return AccountsCompanion( id: id ?? this.id, realmUrl: realmUrl ?? this.realmUrl, @@ -458,7 +1472,8 @@ class AccountsCompanion extends UpdateCompanion { } if (realmUrl.present) { map['realm_url'] = Variable( - $AccountsTable.$converterrealmUrl.toSql(realmUrl.value)); + $AccountsTable.$converterrealmUrl.toSql(realmUrl.value), + ); } if (userId.present) { map['user_id'] = Variable(userId.value); @@ -504,36 +1519,606 @@ class AccountsCompanion extends UpdateCompanion { abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $GlobalSettingsTable globalSettings = $GlobalSettingsTable(this); + late final $BoolGlobalSettingsTable boolGlobalSettings = + $BoolGlobalSettingsTable(this); + late final $IntGlobalSettingsTable intGlobalSettings = + $IntGlobalSettingsTable(this); late final $AccountsTable accounts = $AccountsTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => [accounts]; + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + intGlobalSettings, + accounts, + ]; +} + +typedef $$GlobalSettingsTableCreateCompanionBuilder = + GlobalSettingsCompanion Function({ + Value themeSetting, + Value browserPreference, + Value visitFirstUnread, + Value markReadOnScroll, + Value legacyUpgradeState, + Value rowid, + }); +typedef $$GlobalSettingsTableUpdateCompanionBuilder = + GlobalSettingsCompanion Function({ + Value themeSetting, + Value browserPreference, + Value visitFirstUnread, + Value markReadOnScroll, + Value legacyUpgradeState, + Value rowid, + }); + +class $$GlobalSettingsTableFilterComposer + extends Composer<_$AppDatabase, $GlobalSettingsTable> { + $$GlobalSettingsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnWithTypeConverterFilters + get themeSetting => $composableBuilder( + column: $table.themeSetting, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters + get browserPreference => $composableBuilder( + column: $table.browserPreference, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters< + VisitFirstUnreadSetting?, + VisitFirstUnreadSetting, + String + > + get visitFirstUnread => $composableBuilder( + column: $table.visitFirstUnread, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters< + MarkReadOnScrollSetting?, + MarkReadOnScrollSetting, + String + > + get markReadOnScroll => $composableBuilder( + column: $table.markReadOnScroll, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters< + LegacyUpgradeState?, + LegacyUpgradeState, + String + > + get legacyUpgradeState => $composableBuilder( + column: $table.legacyUpgradeState, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); +} + +class $$GlobalSettingsTableOrderingComposer + extends Composer<_$AppDatabase, $GlobalSettingsTable> { + $$GlobalSettingsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get themeSetting => $composableBuilder( + column: $table.themeSetting, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get browserPreference => $composableBuilder( + column: $table.browserPreference, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get visitFirstUnread => $composableBuilder( + column: $table.visitFirstUnread, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get markReadOnScroll => $composableBuilder( + column: $table.markReadOnScroll, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get legacyUpgradeState => $composableBuilder( + column: $table.legacyUpgradeState, + builder: (column) => ColumnOrderings(column), + ); } -typedef $$AccountsTableCreateCompanionBuilder = AccountsCompanion Function({ - Value id, - required Uri realmUrl, - required int userId, - required String email, - required String apiKey, - required String zulipVersion, - Value zulipMergeBase, - required int zulipFeatureLevel, - Value ackedPushToken, -}); -typedef $$AccountsTableUpdateCompanionBuilder = AccountsCompanion Function({ - Value id, - Value realmUrl, - Value userId, - Value email, - Value apiKey, - Value zulipVersion, - Value zulipMergeBase, - Value zulipFeatureLevel, - Value ackedPushToken, -}); +class $$GlobalSettingsTableAnnotationComposer + extends Composer<_$AppDatabase, $GlobalSettingsTable> { + $$GlobalSettingsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumnWithTypeConverter get themeSetting => + $composableBuilder( + column: $table.themeSetting, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter + get browserPreference => $composableBuilder( + column: $table.browserPreference, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter + get visitFirstUnread => $composableBuilder( + column: $table.visitFirstUnread, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter + get markReadOnScroll => $composableBuilder( + column: $table.markReadOnScroll, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter + get legacyUpgradeState => $composableBuilder( + column: $table.legacyUpgradeState, + builder: (column) => column, + ); +} + +class $$GlobalSettingsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $GlobalSettingsTable, + GlobalSettingsData, + $$GlobalSettingsTableFilterComposer, + $$GlobalSettingsTableOrderingComposer, + $$GlobalSettingsTableAnnotationComposer, + $$GlobalSettingsTableCreateCompanionBuilder, + $$GlobalSettingsTableUpdateCompanionBuilder, + ( + GlobalSettingsData, + BaseReferences< + _$AppDatabase, + $GlobalSettingsTable, + GlobalSettingsData + >, + ), + GlobalSettingsData, + PrefetchHooks Function() + > { + $$GlobalSettingsTableTableManager( + _$AppDatabase db, + $GlobalSettingsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$GlobalSettingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$GlobalSettingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$GlobalSettingsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value themeSetting = const Value.absent(), + Value browserPreference = + const Value.absent(), + Value visitFirstUnread = + const Value.absent(), + Value markReadOnScroll = + const Value.absent(), + Value legacyUpgradeState = + const Value.absent(), + Value rowid = const Value.absent(), + }) => GlobalSettingsCompanion( + themeSetting: themeSetting, + browserPreference: browserPreference, + visitFirstUnread: visitFirstUnread, + markReadOnScroll: markReadOnScroll, + legacyUpgradeState: legacyUpgradeState, + rowid: rowid, + ), + createCompanionCallback: + ({ + Value themeSetting = const Value.absent(), + Value browserPreference = + const Value.absent(), + Value visitFirstUnread = + const Value.absent(), + Value markReadOnScroll = + const Value.absent(), + Value legacyUpgradeState = + const Value.absent(), + Value rowid = const Value.absent(), + }) => GlobalSettingsCompanion.insert( + themeSetting: themeSetting, + browserPreference: browserPreference, + visitFirstUnread: visitFirstUnread, + markReadOnScroll: markReadOnScroll, + legacyUpgradeState: legacyUpgradeState, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$GlobalSettingsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $GlobalSettingsTable, + GlobalSettingsData, + $$GlobalSettingsTableFilterComposer, + $$GlobalSettingsTableOrderingComposer, + $$GlobalSettingsTableAnnotationComposer, + $$GlobalSettingsTableCreateCompanionBuilder, + $$GlobalSettingsTableUpdateCompanionBuilder, + ( + GlobalSettingsData, + BaseReferences<_$AppDatabase, $GlobalSettingsTable, GlobalSettingsData>, + ), + GlobalSettingsData, + PrefetchHooks Function() + >; +typedef $$BoolGlobalSettingsTableCreateCompanionBuilder = + BoolGlobalSettingsCompanion Function({ + required String name, + required bool value, + Value rowid, + }); +typedef $$BoolGlobalSettingsTableUpdateCompanionBuilder = + BoolGlobalSettingsCompanion Function({ + Value name, + Value value, + Value rowid, + }); + +class $$BoolGlobalSettingsTableFilterComposer + extends Composer<_$AppDatabase, $BoolGlobalSettingsTable> { + $$BoolGlobalSettingsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnFilters(column), + ); +} + +class $$BoolGlobalSettingsTableOrderingComposer + extends Composer<_$AppDatabase, $BoolGlobalSettingsTable> { + $$BoolGlobalSettingsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$BoolGlobalSettingsTableAnnotationComposer + extends Composer<_$AppDatabase, $BoolGlobalSettingsTable> { + $$BoolGlobalSettingsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); +} + +class $$BoolGlobalSettingsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $BoolGlobalSettingsTable, + BoolGlobalSettingRow, + $$BoolGlobalSettingsTableFilterComposer, + $$BoolGlobalSettingsTableOrderingComposer, + $$BoolGlobalSettingsTableAnnotationComposer, + $$BoolGlobalSettingsTableCreateCompanionBuilder, + $$BoolGlobalSettingsTableUpdateCompanionBuilder, + ( + BoolGlobalSettingRow, + BaseReferences< + _$AppDatabase, + $BoolGlobalSettingsTable, + BoolGlobalSettingRow + >, + ), + BoolGlobalSettingRow, + PrefetchHooks Function() + > { + $$BoolGlobalSettingsTableTableManager( + _$AppDatabase db, + $BoolGlobalSettingsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$BoolGlobalSettingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$BoolGlobalSettingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$BoolGlobalSettingsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value name = const Value.absent(), + Value value = const Value.absent(), + Value rowid = const Value.absent(), + }) => BoolGlobalSettingsCompanion( + name: name, + value: value, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String name, + required bool value, + Value rowid = const Value.absent(), + }) => BoolGlobalSettingsCompanion.insert( + name: name, + value: value, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$BoolGlobalSettingsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $BoolGlobalSettingsTable, + BoolGlobalSettingRow, + $$BoolGlobalSettingsTableFilterComposer, + $$BoolGlobalSettingsTableOrderingComposer, + $$BoolGlobalSettingsTableAnnotationComposer, + $$BoolGlobalSettingsTableCreateCompanionBuilder, + $$BoolGlobalSettingsTableUpdateCompanionBuilder, + ( + BoolGlobalSettingRow, + BaseReferences< + _$AppDatabase, + $BoolGlobalSettingsTable, + BoolGlobalSettingRow + >, + ), + BoolGlobalSettingRow, + PrefetchHooks Function() + >; +typedef $$IntGlobalSettingsTableCreateCompanionBuilder = + IntGlobalSettingsCompanion Function({ + required String name, + required int value, + Value rowid, + }); +typedef $$IntGlobalSettingsTableUpdateCompanionBuilder = + IntGlobalSettingsCompanion Function({ + Value name, + Value value, + Value rowid, + }); + +class $$IntGlobalSettingsTableFilterComposer + extends Composer<_$AppDatabase, $IntGlobalSettingsTable> { + $$IntGlobalSettingsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnFilters(column), + ); +} + +class $$IntGlobalSettingsTableOrderingComposer + extends Composer<_$AppDatabase, $IntGlobalSettingsTable> { + $$IntGlobalSettingsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$IntGlobalSettingsTableAnnotationComposer + extends Composer<_$AppDatabase, $IntGlobalSettingsTable> { + $$IntGlobalSettingsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); +} + +class $$IntGlobalSettingsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $IntGlobalSettingsTable, + IntGlobalSettingRow, + $$IntGlobalSettingsTableFilterComposer, + $$IntGlobalSettingsTableOrderingComposer, + $$IntGlobalSettingsTableAnnotationComposer, + $$IntGlobalSettingsTableCreateCompanionBuilder, + $$IntGlobalSettingsTableUpdateCompanionBuilder, + ( + IntGlobalSettingRow, + BaseReferences< + _$AppDatabase, + $IntGlobalSettingsTable, + IntGlobalSettingRow + >, + ), + IntGlobalSettingRow, + PrefetchHooks Function() + > { + $$IntGlobalSettingsTableTableManager( + _$AppDatabase db, + $IntGlobalSettingsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$IntGlobalSettingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$IntGlobalSettingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$IntGlobalSettingsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value name = const Value.absent(), + Value value = const Value.absent(), + Value rowid = const Value.absent(), + }) => IntGlobalSettingsCompanion( + name: name, + value: value, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String name, + required int value, + Value rowid = const Value.absent(), + }) => IntGlobalSettingsCompanion.insert( + name: name, + value: value, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$IntGlobalSettingsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $IntGlobalSettingsTable, + IntGlobalSettingRow, + $$IntGlobalSettingsTableFilterComposer, + $$IntGlobalSettingsTableOrderingComposer, + $$IntGlobalSettingsTableAnnotationComposer, + $$IntGlobalSettingsTableCreateCompanionBuilder, + $$IntGlobalSettingsTableUpdateCompanionBuilder, + ( + IntGlobalSettingRow, + BaseReferences< + _$AppDatabase, + $IntGlobalSettingsTable, + IntGlobalSettingRow + >, + ), + IntGlobalSettingRow, + PrefetchHooks Function() + >; +typedef $$AccountsTableCreateCompanionBuilder = + AccountsCompanion Function({ + Value id, + required Uri realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + Value zulipMergeBase, + required int zulipFeatureLevel, + Value ackedPushToken, + }); +typedef $$AccountsTableUpdateCompanionBuilder = + AccountsCompanion Function({ + Value id, + Value realmUrl, + Value userId, + Value email, + Value apiKey, + Value zulipVersion, + Value zulipMergeBase, + Value zulipFeatureLevel, + Value ackedPushToken, + }); class $$AccountsTableFilterComposer extends Composer<_$AppDatabase, $AccountsTable> { @@ -545,36 +2130,50 @@ class $$AccountsTableFilterComposer super.$removeJoinBuilderFromRootComposer, }); ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); + column: $table.id, + builder: (column) => ColumnFilters(column), + ); ColumnWithTypeConverterFilters get realmUrl => $composableBuilder( - column: $table.realmUrl, - builder: (column) => ColumnWithTypeConverterFilters(column)); + column: $table.realmUrl, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); ColumnFilters get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnFilters(column)); + column: $table.userId, + builder: (column) => ColumnFilters(column), + ); ColumnFilters get email => $composableBuilder( - column: $table.email, builder: (column) => ColumnFilters(column)); + column: $table.email, + builder: (column) => ColumnFilters(column), + ); ColumnFilters get apiKey => $composableBuilder( - column: $table.apiKey, builder: (column) => ColumnFilters(column)); + column: $table.apiKey, + builder: (column) => ColumnFilters(column), + ); ColumnFilters get zulipVersion => $composableBuilder( - column: $table.zulipVersion, builder: (column) => ColumnFilters(column)); + column: $table.zulipVersion, + builder: (column) => ColumnFilters(column), + ); ColumnFilters get zulipMergeBase => $composableBuilder( - column: $table.zulipMergeBase, - builder: (column) => ColumnFilters(column)); + column: $table.zulipMergeBase, + builder: (column) => ColumnFilters(column), + ); ColumnFilters get zulipFeatureLevel => $composableBuilder( - column: $table.zulipFeatureLevel, - builder: (column) => ColumnFilters(column)); + column: $table.zulipFeatureLevel, + builder: (column) => ColumnFilters(column), + ); ColumnFilters get ackedPushToken => $composableBuilder( - column: $table.ackedPushToken, - builder: (column) => ColumnFilters(column)); + column: $table.ackedPushToken, + builder: (column) => ColumnFilters(column), + ); } class $$AccountsTableOrderingComposer @@ -587,35 +2186,49 @@ class $$AccountsTableOrderingComposer super.$removeJoinBuilderFromRootComposer, }); ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); ColumnOrderings get realmUrl => $composableBuilder( - column: $table.realmUrl, builder: (column) => ColumnOrderings(column)); + column: $table.realmUrl, + builder: (column) => ColumnOrderings(column), + ); ColumnOrderings get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnOrderings(column)); + column: $table.userId, + builder: (column) => ColumnOrderings(column), + ); ColumnOrderings get email => $composableBuilder( - column: $table.email, builder: (column) => ColumnOrderings(column)); + column: $table.email, + builder: (column) => ColumnOrderings(column), + ); ColumnOrderings get apiKey => $composableBuilder( - column: $table.apiKey, builder: (column) => ColumnOrderings(column)); + column: $table.apiKey, + builder: (column) => ColumnOrderings(column), + ); ColumnOrderings get zulipVersion => $composableBuilder( - column: $table.zulipVersion, - builder: (column) => ColumnOrderings(column)); + column: $table.zulipVersion, + builder: (column) => ColumnOrderings(column), + ); ColumnOrderings get zulipMergeBase => $composableBuilder( - column: $table.zulipMergeBase, - builder: (column) => ColumnOrderings(column)); + column: $table.zulipMergeBase, + builder: (column) => ColumnOrderings(column), + ); ColumnOrderings get zulipFeatureLevel => $composableBuilder( - column: $table.zulipFeatureLevel, - builder: (column) => ColumnOrderings(column)); + column: $table.zulipFeatureLevel, + builder: (column) => ColumnOrderings(column), + ); ColumnOrderings get ackedPushToken => $composableBuilder( - column: $table.ackedPushToken, - builder: (column) => ColumnOrderings(column)); + column: $table.ackedPushToken, + builder: (column) => ColumnOrderings(column), + ); } class $$AccountsTableAnnotationComposer @@ -643,32 +2256,44 @@ class $$AccountsTableAnnotationComposer $composableBuilder(column: $table.apiKey, builder: (column) => column); GeneratedColumn get zulipVersion => $composableBuilder( - column: $table.zulipVersion, builder: (column) => column); + column: $table.zulipVersion, + builder: (column) => column, + ); GeneratedColumn get zulipMergeBase => $composableBuilder( - column: $table.zulipMergeBase, builder: (column) => column); + column: $table.zulipMergeBase, + builder: (column) => column, + ); GeneratedColumn get zulipFeatureLevel => $composableBuilder( - column: $table.zulipFeatureLevel, builder: (column) => column); + column: $table.zulipFeatureLevel, + builder: (column) => column, + ); GeneratedColumn get ackedPushToken => $composableBuilder( - column: $table.ackedPushToken, builder: (column) => column); + column: $table.ackedPushToken, + builder: (column) => column, + ); } -class $$AccountsTableTableManager extends RootTableManager< - _$AppDatabase, - $AccountsTable, - Account, - $$AccountsTableFilterComposer, - $$AccountsTableOrderingComposer, - $$AccountsTableAnnotationComposer, - $$AccountsTableCreateCompanionBuilder, - $$AccountsTableUpdateCompanionBuilder, - (Account, BaseReferences<_$AppDatabase, $AccountsTable, Account>), - Account, - PrefetchHooks Function()> { +class $$AccountsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $AccountsTable, + Account, + $$AccountsTableFilterComposer, + $$AccountsTableOrderingComposer, + $$AccountsTableAnnotationComposer, + $$AccountsTableCreateCompanionBuilder, + $$AccountsTableUpdateCompanionBuilder, + (Account, BaseReferences<_$AppDatabase, $AccountsTable, Account>), + Account, + PrefetchHooks Function() + > { $$AccountsTableTableManager(_$AppDatabase db, $AccountsTable table) - : super(TableManagerState( + : super( + TableManagerState( db: db, table: table, createFilteringComposer: () => @@ -677,73 +2302,82 @@ class $$AccountsTableTableManager extends RootTableManager< $$AccountsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => $$AccountsTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value realmUrl = const Value.absent(), - Value userId = const Value.absent(), - Value email = const Value.absent(), - Value apiKey = const Value.absent(), - Value zulipVersion = const Value.absent(), - Value zulipMergeBase = const Value.absent(), - Value zulipFeatureLevel = const Value.absent(), - Value ackedPushToken = const Value.absent(), - }) => - AccountsCompanion( - id: id, - realmUrl: realmUrl, - userId: userId, - email: email, - apiKey: apiKey, - zulipVersion: zulipVersion, - zulipMergeBase: zulipMergeBase, - zulipFeatureLevel: zulipFeatureLevel, - ackedPushToken: ackedPushToken, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - required Uri realmUrl, - required int userId, - required String email, - required String apiKey, - required String zulipVersion, - Value zulipMergeBase = const Value.absent(), - required int zulipFeatureLevel, - Value ackedPushToken = const Value.absent(), - }) => - AccountsCompanion.insert( - id: id, - realmUrl: realmUrl, - userId: userId, - email: email, - apiKey: apiKey, - zulipVersion: zulipVersion, - zulipMergeBase: zulipMergeBase, - zulipFeatureLevel: zulipFeatureLevel, - ackedPushToken: ackedPushToken, - ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value realmUrl = const Value.absent(), + Value userId = const Value.absent(), + Value email = const Value.absent(), + Value apiKey = const Value.absent(), + Value zulipVersion = const Value.absent(), + Value zulipMergeBase = const Value.absent(), + Value zulipFeatureLevel = const Value.absent(), + Value ackedPushToken = const Value.absent(), + }) => AccountsCompanion( + id: id, + realmUrl: realmUrl, + userId: userId, + email: email, + apiKey: apiKey, + zulipVersion: zulipVersion, + zulipMergeBase: zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel, + ackedPushToken: ackedPushToken, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required Uri realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + Value zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsCompanion.insert( + id: id, + realmUrl: realmUrl, + userId: userId, + email: email, + apiKey: apiKey, + zulipVersion: zulipVersion, + zulipMergeBase: zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel, + ackedPushToken: ackedPushToken, + ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) .toList(), prefetchHooksCallback: null, - )); + ), + ); } -typedef $$AccountsTableProcessedTableManager = ProcessedTableManager< - _$AppDatabase, - $AccountsTable, - Account, - $$AccountsTableFilterComposer, - $$AccountsTableOrderingComposer, - $$AccountsTableAnnotationComposer, - $$AccountsTableCreateCompanionBuilder, - $$AccountsTableUpdateCompanionBuilder, - (Account, BaseReferences<_$AppDatabase, $AccountsTable, Account>), - Account, - PrefetchHooks Function()>; +typedef $$AccountsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $AccountsTable, + Account, + $$AccountsTableFilterComposer, + $$AccountsTableOrderingComposer, + $$AccountsTableAnnotationComposer, + $$AccountsTableCreateCompanionBuilder, + $$AccountsTableUpdateCompanionBuilder, + (Account, BaseReferences<_$AppDatabase, $AccountsTable, Account>), + Account, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; $AppDatabaseManager(this._db); + $$GlobalSettingsTableTableManager get globalSettings => + $$GlobalSettingsTableTableManager(_db, _db.globalSettings); + $$BoolGlobalSettingsTableTableManager get boolGlobalSettings => + $$BoolGlobalSettingsTableTableManager(_db, _db.boolGlobalSettings); + $$IntGlobalSettingsTableTableManager get intGlobalSettings => + $$IntGlobalSettingsTableTableManager(_db, _db.intGlobalSettings); $$AccountsTableTableManager get accounts => $$AccountsTableTableManager(_db, _db.accounts); } diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index b0ec5f7324..8d6a1cab0e 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -18,9 +18,9 @@ sealed class EmojiDisplay { EmojiDisplay({required this.emojiName}); - EmojiDisplay resolve(UserSettings? userSettings) { // TODO(server-5) + EmojiDisplay resolve(UserSettings userSettings) { if (this is TextEmojiDisplay) return this; - if (userSettings?.emojiset == Emojiset.text) { + if (userSettings.emojiset == Emojiset.text) { return TextEmojiDisplay(emojiName: emojiName); } return this; @@ -74,11 +74,23 @@ final class EmojiCandidate { /// This might not be the only name this emoji has; see [aliases]. final String emojiName; + /// [emojiName], but via [AutocompleteQuery.lowercaseAndStripDiacritics] + /// to support fuzzy matching. + String get normalizedEmojiName => _normalizedEmojiName + ??= AutocompleteQuery.lowercaseAndStripDiacritics(emojiName); + String? _normalizedEmojiName; + /// Additional Zulip "emoji name" values for this emoji, /// to show in the emoji picker UI. Iterable get aliases => _aliases ?? const []; final List? _aliases; + /// [aliases], but via [AutocompleteQuery.lowercaseAndStripDiacritics] + /// to support fuzzy matching. + Iterable get normalizedAliases => _normalizedAliases + ??= aliases.map((alias) => AutocompleteQuery.lowercaseAndStripDiacritics(alias)); + Iterable? _normalizedAliases; + final EmojiDisplay emojiDisplay; EmojiCandidate({ @@ -115,21 +127,43 @@ mixin EmojiStore { /// /// See description in the web code: /// https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L3-L21 - // Someday this list may start varying rather than being hard-coded, - // and then this will become a non-static member on EmojiStore. - // For now, though, the fact it's constant is convenient when writing - // tests of the logic that uses this data; so we guarantee it in the API. - static Iterable get popularEmojiCandidates { - return EmojiStoreImpl._popularCandidates; - } + Iterable popularEmojiCandidates(); Iterable allEmojiCandidates(); + String? getUnicodeEmojiNameByCode(String emojiCode); + // TODO cut debugServerEmojiData once we can query for lists of emoji; // have tests make those queries end-to-end Map>? get debugServerEmojiData; +} + +mixin ProxyEmojiStore on EmojiStore { + @protected + EmojiStore get emojiStore; + + @override + EmojiDisplay emojiDisplayFor({ + required ReactionType emojiType, + required String emojiCode, + required String emojiName + }) { + return emojiStore.emojiDisplayFor( + emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName); + } + + @override + Iterable popularEmojiCandidates() => emojiStore.popularEmojiCandidates(); + + @override + Iterable allEmojiCandidates() => emojiStore.allEmojiCandidates(); + + @override + String? getUnicodeEmojiNameByCode(String emojiCode) => + emojiStore.getUnicodeEmojiNameByCode(emojiCode); - void setServerEmojiData(ServerEmojiData data); + @override + Map>? get debugServerEmojiData => emojiStore.debugServerEmojiData; } /// The implementation of [EmojiStore] that does the work. @@ -137,15 +171,12 @@ mixin EmojiStore { /// Generally the only code that should need this class is [PerAccountStore] /// itself. Other code accesses this functionality through [PerAccountStore], /// or through the mixin [EmojiStore] which describes its interface. -class EmojiStoreImpl with EmojiStore { +class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { EmojiStoreImpl({ - required this.realmUrl, + required super.core, required this.allRealmEmoji, }) : _serverEmojiData = null; // TODO(#974) maybe start from a hard-coded baseline - /// The same as [PerAccountStore.realmUrl]. - final Uri realmUrl; - /// The realm's custom emoji, indexed by their [RealmEmojiItem.emojiCode], /// including deactivated emoji not available for new uses. /// @@ -195,19 +226,19 @@ class EmojiStoreImpl with EmojiStore { required String? stillUrl, required String emojiName, }) { - final source = Uri.tryParse(sourceUrl); - if (source == null) return TextEmojiDisplay(emojiName: emojiName); + final resolvedUrl = this.tryResolveUrl(sourceUrl); + if (resolvedUrl == null) return TextEmojiDisplay(emojiName: emojiName); - Uri? still; + Uri? resolvedStillUrl; if (stillUrl != null) { - still = Uri.tryParse(stillUrl); - if (still == null) return TextEmojiDisplay(emojiName: emojiName); + resolvedStillUrl = this.tryResolveUrl(stillUrl); + if (resolvedStillUrl == null) return TextEmojiDisplay(emojiName: emojiName); } return ImageEmojiDisplay( emojiName: emojiName, - resolvedUrl: realmUrl.resolveUri(source), - resolvedStillUrl: still == null ? null : realmUrl.resolveUri(still), + resolvedUrl: resolvedUrl, + resolvedStillUrl: resolvedStillUrl, ); } @@ -221,36 +252,53 @@ class EmojiStoreImpl with EmojiStore { /// retrieving the data. Map>? _serverEmojiData; - static final _popularCandidates = _generatePopularCandidates(); + List? _popularCandidates; - static List _generatePopularCandidates() { - EmojiCandidate candidate(String emojiCode, String emojiUnicode, - List names) { - final emojiName = names.removeAt(0); - assert(emojiUnicode == tryParseEmojiCodeToUnicode(emojiCode)); + @override + Iterable popularEmojiCandidates() { + return _popularCandidates ??= _generatePopularCandidates(); + } + + List _generatePopularCandidates() { + EmojiCandidate candidate(String emojiCode, List names) { + final [emojiName, ...aliases] = names; + final emojiUnicode = tryParseEmojiCodeToUnicode(emojiCode)!; return EmojiCandidate(emojiType: ReactionType.unicodeEmoji, - emojiCode: emojiCode, emojiName: emojiName, aliases: names, + emojiCode: emojiCode, emojiName: emojiName, aliases: aliases, emojiDisplay: UnicodeEmojiDisplay( emojiName: emojiName, emojiUnicode: emojiUnicode)); } - return [ - // This list should match web: - // https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L22-L29 - candidate('1f44d', '👍', ['+1', 'thumbs_up', 'like']), - candidate('1f389', '🎉', ['tada']), - candidate('1f642', '🙂', ['smile']), - candidate( '2764', '❤', ['heart', 'love', 'love_you']), - candidate('1f6e0', '🛠', ['working_on_it', 'hammer_and_wrench', 'tools']), - candidate('1f419', '🐙', ['octopus']), - ]; + if (_serverEmojiData == null) return []; + + final result = []; + for (final emojiCode in _popularEmojiCodesList) { + final names = _serverEmojiData![emojiCode]; + if (names == null) continue; // TODO(log) + result.add(candidate(emojiCode, names)); + } + return result; } - static final _popularEmojiCodes = (() { - assert(_popularCandidates.every((c) => - c.emojiType == ReactionType.unicodeEmoji)); - return Set.of(_popularCandidates.map((c) => c.emojiCode)); + /// Codes for the popular emoji, in order; all are Unicode emoji. + // This list should match web: + // https://github.com/zulip/zulip/blob/9feba0f16/web/shared/src/typeahead.ts#L22-L29 + static final List _popularEmojiCodesList = (() { + String check(String emojiCode, String emojiUnicode) { + assert(emojiUnicode == tryParseEmojiCodeToUnicode(emojiCode)); + return emojiCode; + } + return [ + check('1f44d', '👍'), + check('1f389', '🎉'), + check('1f642', '🙂'), + check('2764', '❤'), + check('1f6e0', '🛠'), + check('1f419', '🐙'), + ]; })(); + static final Set _popularEmojiCodes = Set.of(_popularEmojiCodesList); + static bool _isPopularEmoji(EmojiCandidate candidate) { return candidate.emojiType == ReactionType.unicodeEmoji && _popularEmojiCodes.contains(candidate.emojiCode); @@ -310,7 +358,7 @@ class EmojiStoreImpl with EmojiStore { // Include the "popular" emoji, in their canonical order // relative to each other. - results.addAll(_popularCandidates); + results.addAll(popularEmojiCandidates()); final namesOverridden = { for (final emoji in activeRealmEmoji) emoji.name, @@ -367,8 +415,12 @@ class EmojiStoreImpl with EmojiStore { } @override + String? getUnicodeEmojiNameByCode(String emojiCode) => + _serverEmojiData?[emojiCode]?.first; // TODO(log) if null + void setServerEmojiData(ServerEmojiData data) { _serverEmojiData = data.codeToNames; + _popularCandidates = null; _allEmojiCandidates = null; } @@ -461,9 +513,8 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery { static const _separator = '_'; - static String _adjustQuery(String raw) { - return raw.toLowerCase().replaceAll(' ', '_'); // TODO(#1067) remove diacritics too - } + static String _adjustQuery(String raw) => + AutocompleteQuery.lowercaseAndStripDiacritics(raw.replaceAll(' ', '_')); @override EmojiAutocompleteView initViewModel({ @@ -493,29 +544,28 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery { } } - EmojiMatchQuality? result = _matchName(candidate.emojiName); - for (final alias in candidate.aliases) { + EmojiMatchQuality? result = _matchName(candidate.normalizedEmojiName); + for (final normalizedAlias in candidate.normalizedAliases) { if (result == EmojiMatchQuality.best) return result; - result = EmojiMatchQuality.bestOf(result, _matchName(alias)); + result = EmojiMatchQuality.bestOf(result, _matchName(normalizedAlias)); } return result; } - EmojiMatchQuality? _matchName(String emojiName) { + EmojiMatchQuality? _matchName(String normalizedName) { // Compare query_matches_string_in_order in Zulip web:shared/src/typeahead.ts // for a Boolean version of this logic (match vs. no match), // and triage_raw in the same file web:shared/src/typeahead.ts // for the finer distinctions. // See also commentary in [_rankResult]. - // TODO(#1067) this assumes emojiName is already lower-case (and no diacritics) - if (emojiName == _adjusted) return EmojiMatchQuality.exact; - if (emojiName.startsWith(_adjusted)) return EmojiMatchQuality.prefix; - if (emojiName.contains(_sepAdjusted)) return EmojiMatchQuality.wordAligned; + if (normalizedName == _adjusted) return EmojiMatchQuality.exact; + if (normalizedName.startsWith(_adjusted)) return EmojiMatchQuality.prefix; + if (normalizedName.contains(_sepAdjusted)) return EmojiMatchQuality.wordAligned; if (!_adjusted.contains(_separator)) { // If the query is a single token (doesn't contain a separator), // allow a match anywhere in the string, too. - if (emojiName.contains(_adjusted)) return EmojiMatchQuality.other; + if (normalizedName.contains(_adjusted)) return EmojiMatchQuality.other; } else { // Otherwise, require at least a word-aligned match. } diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart index db11115cf3..d46942f6d5 100644 --- a/lib/model/internal_link.dart +++ b/lib/model/internal_link.dart @@ -59,8 +59,8 @@ String? decodeHashComponent(String str) { // you do so by passing the `anchor` param. Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { // TODO(server-7) - final apiNarrow = resolveDmElements( - narrow.apiEncode(), store.connection.zulipFeatureLevel!); + final apiNarrow = resolveApiNarrowForServer( + narrow.apiEncode(), store.zulipFeatureLevel); final fragment = StringBuffer('narrow'); for (ApiNarrowElement element in apiNarrow) { fragment.write('/'); @@ -71,7 +71,7 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { fragment.write('${element.operator}/'); switch (element) { - case ApiNarrowStream(): + case ApiNarrowChannel(): final streamId = element.operand; final name = store.streams[streamId]?.name ?? 'unknown'; final slugifiedName = _encodeHashComponent(name.replaceAll(' ', '-')); @@ -86,10 +86,14 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { fragment.write('${element.operand.join(',')}-$suffix'); case ApiNarrowDm(): assert(false, 'ApiNarrowDm should have been resolved'); + case ApiNarrowWith(): + fragment.write(element.operand.toString()); case ApiNarrowIs(): fragment.write(element.operand.toString()); case ApiNarrowMessageId(): fragment.write(element.operand.toString()); + case ApiNarrowSearch(): + fragment.write(_encodeHashComponent(element.operand)); } } @@ -107,22 +111,43 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { return result; } -/// A [Narrow] from a given URL, on `store`'s realm. +/// The result of parsing some URL within a Zulip realm, +/// when the URL corresponds to some page in this app. +sealed class InternalLink { + InternalLink({required this.realmUrl}); + + final Uri realmUrl; +} + +/// The result of parsing some URL that points to a narrow on a Zulip realm, +/// when the narrow is of a type that this app understands. +class NarrowLink extends InternalLink { + NarrowLink(this.narrow, this.nearMessageId, {required super.realmUrl}); + + final Narrow narrow; + final int? nearMessageId; +} + +/// Try to parse the given URL as a page in this app, on `store`'s realm. /// /// `url` must already be a result from [PerAccountStore.tryResolveUrl] /// on `store`. /// -/// Returns `null` if any of the operator/operand pairs are invalid. +/// Returns null if the URL isn't on this realm, +/// or isn't a valid Zulip URL, +/// or isn't currently supported as leading to a page in this app. /// +/// In particular this will return null if `url` is a `/#narrow/…` URL +/// and any of the operator/operand pairs are invalid. /// Since narrow links can combine operators in ways our [Narrow] type can't /// represent, this can also return null for valid narrow links. /// /// This can also return null for some valid narrow links that our Narrow /// type *could* accurately represent. We should try to understand these -/// better, but some kinds will be rare, even unheard-of: +/// better, but some kinds will be rare, even unheard-of. For example: /// #narrow/stream/1-announce/stream/1-announce (duplicated operator) -// TODO(#252): handle all valid narrow links, returning a search narrow -Narrow? parseInternalLink(Uri url, PerAccountStore store) { +// TODO(#1661): handle all valid narrow links, returning a search narrow +InternalLink? parseInternalLink(Uri url, PerAccountStore store) { if (!_isInternalLink(url, store.realmUrl)) return null; final (category, segments) = _getCategoryAndSegmentsFromFragment(url.fragment); @@ -153,14 +178,16 @@ bool _isInternalLink(Uri url, Uri realmUrl) { return (category, segments); } -Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { +NarrowLink? _interpretNarrowSegments(List segments, PerAccountStore store) { assert(segments.isNotEmpty); assert(segments.length.isEven); - ApiNarrowStream? streamElement; + ApiNarrowChannel? channelElement; ApiNarrowTopic? topicElement; ApiNarrowDm? dmElement; + ApiNarrowWith? withElement; Set isElementOperands = {}; + int? nearMessageId; for (var i = 0; i < segments.length; i += 2) { final (operator, negated) = _parseOperator(segments[i]); @@ -169,10 +196,10 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { switch (operator) { case _NarrowOperator.stream: case _NarrowOperator.channel: - if (streamElement != null) return null; + if (channelElement != null) return null; final streamId = _parseStreamOperand(operand, store); if (streamId == null) return null; - streamElement = ApiNarrowStream(streamId, negated: negated); + channelElement = ApiNarrowChannel(streamId, negated: negated); case _NarrowOperator.topic: case _NarrowOperator.subject: @@ -188,27 +215,38 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { if (dmIds == null) return null; dmElement = ApiNarrowDm(dmIds, negated: negated); + case _NarrowOperator.with_: + if (withElement != null) return null; + final messageId = int.tryParse(operand, radix: 10); + if (messageId == null) return null; + withElement = ApiNarrowWith(messageId); + case _NarrowOperator.is_: // It is fine to have duplicates of the same [IsOperand]. isElementOperands.add(IsOperand.fromRawString(operand)); - case _NarrowOperator.near: // TODO(#82): support for near - case _NarrowOperator.with_: // TODO(#683): support for with - continue; + case _NarrowOperator.near: + if (nearMessageId != null) return null; + final messageId = int.tryParse(operand, radix: 10); + if (messageId == null) return null; + nearMessageId = messageId; case _NarrowOperator.unknown: return null; } } + final Narrow? narrow; if (isElementOperands.isNotEmpty) { - if (streamElement != null || topicElement != null || dmElement != null) return null; + if (channelElement != null || topicElement != null || dmElement != null || withElement != null) { + return null; + } if (isElementOperands.length > 1) return null; switch (isElementOperands.single) { case IsOperand.mentioned: - return const MentionsNarrow(); + narrow = const MentionsNarrow(); case IsOperand.starred: - return const StarredMessagesNarrow(); + narrow = const StarredMessagesNarrow(); case IsOperand.dm: case IsOperand.private: case IsOperand.alerted: @@ -219,17 +257,21 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { return null; } } else if (dmElement != null) { - if (streamElement != null || topicElement != null) return null; - return DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId); - } else if (streamElement != null) { - final streamId = streamElement.operand; + if (channelElement != null || topicElement != null || withElement != null) return null; + narrow = DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId); + } else if (channelElement != null) { + final streamId = channelElement.operand; if (topicElement != null) { - return TopicNarrow(streamId, topicElement.operand); + narrow = TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand); } else { - return ChannelNarrow(streamId); + if (withElement != null) return null; + narrow = ChannelNarrow(streamId); } + } else { + return null; } - return null; + + return NarrowLink(narrow, nearMessageId, realmUrl: store.realmUrl); } @JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true) diff --git a/lib/model/katex.dart b/lib/model/katex.dart new file mode 100644 index 0000000000..e4bd080e95 --- /dev/null +++ b/lib/model/katex.dart @@ -0,0 +1,1198 @@ +import 'package:collection/collection.dart'; +import 'package:convert/convert.dart'; +import 'package:csslib/parser.dart' as css_parser; +import 'package:csslib/visitor.dart' as css_visitor; +import 'package:flutter/foundation.dart'; +import 'package:html/dom.dart' as dom; + +import '../log.dart'; +import 'binding.dart'; +import 'content.dart'; +import 'settings.dart'; + +/// The failure reason in case the KaTeX parser encountered a +/// `_KatexHtmlParseError` exception. +/// +/// Generally this means that parser encountered an unexpected HTML structure, +/// an unsupported HTML node, or an unexpected inline CSS style or CSS class on +/// a specific node. +class KatexParserHardFailReason { + const KatexParserHardFailReason({ + required this.message, + required this.stackTrace, + }); + + final String? message; + final StackTrace stackTrace; +} + +/// The failure reason in case the KaTeX parser found an unsupported +/// CSS class or unsupported inline CSS style property. +class KatexParserSoftFailReason { + const KatexParserSoftFailReason({ + this.unsupportedCssClasses = const [], + this.unsupportedInlineCssProperties = const [], + }); + + final List unsupportedCssClasses; + final List unsupportedInlineCssProperties; +} + +class MathParserResult { + const MathParserResult({ + required this.texSource, + required this.nodes, + this.hardFailReason, + this.softFailReason, + }); + + final String texSource; + + /// Parsed KaTeX node tree to be used for rendering the KaTeX content. + /// + /// It will be null if the parser encounters an unsupported HTML element or + /// CSS style, indicating that the widget should render the [texSource] as a + /// fallback instead. + final List? nodes; + + final KatexParserHardFailReason? hardFailReason; + final KatexParserSoftFailReason? softFailReason; +} + +/// Parses the HTML spans containing KaTeX HTML tree. +/// +/// The element should be either `` if parsing +/// inline content, otherwise `` when +/// parsing block content. +/// +/// Returns null if it encounters an unexpected root KaTeX HTML element. +MathParserResult? parseMath(dom.Element element, { required bool 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'); + + if (element.nodes case [ + dom.Element(localName: 'span', className: 'katex') && final child, + ]) { + katexElement = child; + } else { + return null; + } + } + + if (katexElement.nodes case [ + dom.Element(localName: 'span', className: 'katex-mathml', nodes: [ + dom.Element( + localName: 'math', + namespaceUri: 'http://www.w3.org/1998/Math/MathML') + && final mathElement, + ]), + dom.Element(localName: 'span', className: 'katex-html', nodes: [...]) + && final katexHtmlElement, + ]) { + if (mathElement.attributes['display'] != (block ? 'block' : null)) { + return null; + } + + final String texSource; + if (mathElement.nodes case [ + dom.Element(localName: 'semantics', nodes: [ + ..., + dom.Element( + localName: 'annotation', + attributes: {'encoding': 'application/x-tex'}, + :final text), + ]), + ]) { + texSource = text.trim(); + } else { + return null; + } + + // The GlobalStore should be ready well before we reach the + // content parsing stage here, thus the `!` here. + final globalStore = ZulipBinding.instance.getGlobalStoreSync()!; + final globalSettings = globalStore.settings; + final flagForceRenderKatex = + globalSettings.getBool(BoolGlobalSetting.forceRenderKatex); + + KatexParserHardFailReason? hardFailReason; + KatexParserSoftFailReason? softFailReason; + List? nodes; + final parser = _KatexParser(); + try { + nodes = parser.parseKatexHtml(katexHtmlElement); + } on _KatexHtmlParseError catch (e, st) { + assert(debugLog('$e\n$st')); + hardFailReason = KatexParserHardFailReason( + message: e.message, + stackTrace: st); + } + + if (parser.hasError && !flagForceRenderKatex) { + nodes = null; + softFailReason = KatexParserSoftFailReason( + unsupportedCssClasses: parser.unsupportedCssClasses, + unsupportedInlineCssProperties: parser.unsupportedInlineCssProperties); + } + + return MathParserResult( + nodes: nodes, + texSource: texSource, + hardFailReason: hardFailReason, + softFailReason: softFailReason); + } else { + return null; + } +} + +class _KatexParser { + bool get hasError => _hasError; + bool _hasError = false; + + final unsupportedCssClasses = []; + final unsupportedInlineCssProperties = []; + + List parseKatexHtml(dom.Element element) { + assert(element.localName == 'span'); + assert(element.className == 'katex-html'); + return _parseChildSpans(element.nodes); + } + + List _parseChildSpans(List nodes) { + var resultSpans = QueueList(); + for (final node in nodes.reversed) { + if (node is! dom.Element || node.localName != 'span') { + throw _KatexHtmlParseError( + node is dom.Element + ? 'unsupported html node: ${node.localName}' + : 'unsupported html node'); + } + + var span = _parseSpan(node); + final negativeRightMarginEm = switch (span) { + KatexSpanNode(styles: KatexSpanStyles(:final marginRightEm?)) + when marginRightEm.isNegative => marginRightEm, + _ => null, + }; + final negativeLeftMarginEm = switch (span) { + KatexSpanNode(styles: KatexSpanStyles(:final marginLeftEm?)) + when marginLeftEm.isNegative => marginLeftEm, + _ => null, + }; + if (span is KatexSpanNode) { + if (negativeRightMarginEm != null || negativeLeftMarginEm != null) { + span = KatexSpanNode( + styles: span.styles.filter( + marginRightEm: negativeRightMarginEm == null, + marginLeftEm: negativeLeftMarginEm == null), + text: span.text, + nodes: span.nodes); + } + } + + if (negativeRightMarginEm != null) { + final previousSpans = resultSpans; + resultSpans = QueueList(); + resultSpans.addFirst(KatexNegativeMarginNode( + leftOffsetEm: negativeRightMarginEm, + nodes: previousSpans)); + } + + resultSpans.addFirst(span); + + if (negativeLeftMarginEm != null) { + final previousSpans = resultSpans; + resultSpans = QueueList(); + resultSpans.addFirst(KatexNegativeMarginNode( + leftOffsetEm: negativeLeftMarginEm, + nodes: previousSpans)); + } + } + return resultSpans; + } + + KatexNode _parseSpan(dom.Element element) { + assert(element.localName == 'span'); + // TODO maybe check if the sequence of ancestors matter for spans. + + if (element.className == 'strut') { + return _parseStrut(element); + } + + if (element.className == 'vlist-t' + || element.className == 'vlist-t vlist-t2') { + return _parseVlist(element); + } + + return _parseGenericSpan(element); + } + + KatexNode _parseStrut(dom.Element element) { + assert(element.localName == 'span'); + assert(element.className == 'strut'); + if (element.nodes.isNotEmpty) throw _KatexHtmlParseError(); + + final styles = _parseInlineStyles(element); + if (styles == null) throw _KatexHtmlParseError(); + final heightEm = _takeStyleEm(styles, 'height'); + if (heightEm == null) throw _KatexHtmlParseError(); + final verticalAlignEm = _takeStyleEm(styles, 'vertical-align'); + if (styles.isNotEmpty) throw _KatexHtmlParseError(); + + return KatexStrutNode( + heightEm: heightEm, + verticalAlignEm: verticalAlignEm, + debugHtmlNode: kDebugMode ? element : null); + } + + KatexNode _parseVlist(dom.Element element) { + assert(element.localName == 'span'); + assert(element.className == 'vlist-t' + || element.className == 'vlist-t vlist-t2'); + final vlistT = element; + if (vlistT.nodes.isEmpty) throw _KatexHtmlParseError(); + if (vlistT.attributes.containsKey('style')) throw _KatexHtmlParseError(); + + // A .vlist-t node is a CSS table; .vlist-r are rows, and + // .vlist and .vlist-s are cells. See the CSS rules: + // https://github.com/KaTeX/KaTeX/blob/9fb63136e/src/styles/katex.scss#L183-L231 + // + // The structure of a .vlist-t node is very specific. + // See the code that generates it: + // https://github.com/KaTeX/KaTeX/blob/9fb63136e/src/buildCommon.js#L589-L620 + // As seen in that code, there are either two rows totalling three cells + // (namely [[vlist, topStrut], [depthStrut]]), or one row with one cell + // ([[vlist]]), where "vlist" is a .vlist node which contains all the + // real content of the .vlist-t. + // + // The extra cells "topStrut" and "depthStrut" are workarounds for + // Safari circa 2017, as seen in comments in the linked KaTeX code. + // When those are present, the .vlist-t node has another class `vlist-t2`, + // whose only effect is to cancel out part of the effect of "topStrut" + // (which is a .vlist-s); see the commit that added `vlist-t2`: + // https://github.com/KaTeX/KaTeX/commit/766487bfe + // So we ignore that part of the CSS. We ignore the rest of those + // two "strut" cells too, because they don't seem to have any effect + // in rendering on the web (in e.g. a modern Chrome, in 2025). + + final hasVlistT2 = vlistT.className == 'vlist-t vlist-t2'; + if (!hasVlistT2 && vlistT.nodes.length != 1) throw _KatexHtmlParseError(); + if (hasVlistT2) { + if (vlistT.nodes case [ + _, // this row should have two cells [vlist, topStrut]; see details above + dom.Element(localName: 'span', className: 'vlist-r', nodes: [ + dom.Element(localName: 'span', className: 'vlist', nodes: [ + dom.Element(localName: 'span', className: '', nodes: []), + ]) && final depthStrut, + ]), + ]) { + // This "depthStrut" cell will have a "height" inline style, + // which we ignore because it doesn't seem to have any effect in web; + // see detailed comment above. + // Make sure there aren't any other, unexpected, inline styles present. + final styles = _parseInlineStyles(depthStrut); + if (styles != null && styles.keys.any((p) => p != 'height')) { + throw _KatexHtmlParseError(); + } + } else { + throw _KatexHtmlParseError(); + } + } + + if (vlistT.nodes.first + case dom.Element(localName: 'span', className: 'vlist-r') && + final vlistR) { + if (vlistR.attributes.containsKey('style')) throw _KatexHtmlParseError(); + + if (vlistR.nodes.first + case dom.Element(localName: 'span', className: 'vlist') && + final vlist) { + // Same as above for the "depthStrut" node, the main "vlist" cell + // will have a "height" inline style which we ignore because + // it doesn't seem to have any effect in rendering on the web. + // Make sure there aren't any other, unexpected, inline styles present. + final vlistStyles = _parseInlineStyles(vlist); + if (vlistStyles != null && vlistStyles.keys.any((p) => p != 'height')) { + throw _KatexHtmlParseError(); + } + + final rows = []; + + for (final innerSpan in vlist.nodes) { + if (innerSpan case dom.Element( + localName: 'span', + nodes: [ + dom.Element(localName: 'span', className: 'pstrut') && + final pstrutSpan, + ...final otherSpans, + ], + )) { + if (innerSpan.className != '') { + throw _KatexHtmlParseError('unexpected CSS class for ' + 'vlist inner span: ${innerSpan.className}'); + } + + final inlineStyles = _parseInlineStyles(innerSpan); + if (inlineStyles == null) throw _KatexHtmlParseError(); + final marginLeftEm = _takeStyleEm(inlineStyles, 'margin-left'); + final marginLeftIsNegative = marginLeftEm?.isNegative ?? false; + final marginRightEm = _takeStyleEm(inlineStyles, 'margin-right'); + if (marginRightEm?.isNegative ?? false) throw _KatexHtmlParseError(); + final styles = KatexSpanStyles( + marginLeftEm: marginLeftIsNegative ? null : marginLeftEm, + marginRightEm: marginRightEm, + ); + final topEm = _takeStyleEm(inlineStyles, 'top'); + if (inlineStyles.isNotEmpty) throw _KatexHtmlParseError(); + + final pstrutStyles = _parseInlineStyles(pstrutSpan); + if (pstrutStyles == null) throw _KatexHtmlParseError(); + final pstrutHeightEm = _takeStyleEm(pstrutStyles, 'height'); + if (pstrutHeightEm == null) throw _KatexHtmlParseError(); + if (pstrutStyles.isNotEmpty) throw _KatexHtmlParseError(); + + KatexSpanNode child = KatexSpanNode( + styles: styles, + nodes: _parseChildSpans(otherSpans)); + + if (marginLeftIsNegative) { + child = KatexSpanNode( + nodes: [KatexNegativeMarginNode( + leftOffsetEm: marginLeftEm!, + nodes: [child])]); + } + + rows.add(KatexVlistRowNode( + verticalOffsetEm: (topEm ?? 0) + pstrutHeightEm, + debugHtmlNode: kDebugMode ? innerSpan : null, + node: child)); + } else { + throw _KatexHtmlParseError(); + } + } + + return KatexVlistNode( + rows: rows, + debugHtmlNode: kDebugMode ? element : null, + ); + } else { + throw _KatexHtmlParseError(); + } + } else { + throw _KatexHtmlParseError(); + } + } + + static final _resetSizeClassRegExp = RegExp(r'^reset-size(\d\d?)$'); + static final _sizeClassRegExp = RegExp(r'^size(\d\d?)$'); + + KatexNode _parseGenericSpan(dom.Element element) { + assert(element.localName == 'span'); + + // Aggregate the CSS styles that apply, in the same order as the CSS + // classes specified for this span, mimicking the behaviour on web. + // + // Each case in the switch block below is a separate CSS class definition + // in the same order as in katex.scss : + // https://github.com/KaTeX/KaTeX/blob/2fe1941b/src/styles/katex.scss + // A copy of class definition (where possible) is accompanied in a comment + // with each case statement to keep track of updates. + final spanClasses = element.className != '' + ? List.unmodifiable(element.className.split(' ')) + : const []; + double? widthEm; + String? fontFamily; + double? fontSizeEm; + KatexSpanFontWeight? fontWeight; + KatexSpanFontStyle? fontStyle; + KatexSpanTextAlign? textAlign; + var index = 0; + while (index < spanClasses.length) { + final spanClass = spanClasses[index++]; + switch (spanClass) { + case 'base': + // .base { ... } + // Do nothing, it has properties that don't need special handling. + break; + + case 'strut': + // .strut { ... } + // We expect the 'strut' class to be the only class in a span, + // in which case we handle it separately and emit `KatexStrutNode`. + throw _KatexHtmlParseError(); + + case 'textbf': + // .textbf { font-weight: bold; } + fontWeight = KatexSpanFontWeight.bold; + + case 'textit': + // .textit { font-style: italic; } + fontStyle = KatexSpanFontStyle.italic; + + case 'textrm': + // .textrm { font-family: KaTeX_Main; } + fontFamily = 'KaTeX_Main'; + + // case 'textsf': + // // .textsf { font-family: KaTeX_SansSerif; } + // This CSS rule has no effect, because the other `.textsf` rule below + // has the exact same list of declarations. Handle it there instead. + + case 'texttt': + // .texttt { font-family: KaTeX_Typewriter; } + fontFamily = 'KaTeX_Typewriter'; + + case 'mathnormal': + // .mathnormal { font-family: KaTeX_Math; font-style: italic; } + fontFamily = 'KaTeX_Math'; + fontStyle = KatexSpanFontStyle.italic; + + case 'mathit': + // .mathit { font-family: KaTeX_Main; font-style: italic; } + fontFamily = 'KaTeX_Main'; + fontStyle = KatexSpanFontStyle.italic; + + case 'mathrm': + // .mathrm { font-style: normal; } + fontStyle = KatexSpanFontStyle.normal; + + case 'mathbf': + // .mathbf { font-family: KaTeX_Main; font-weight: bold; } + fontFamily = 'KaTeX_Main'; + fontWeight = KatexSpanFontWeight.bold; + + case 'boldsymbol': + // .boldsymbol { font-family: KaTeX_Math; font-weight: bold; font-style: italic; } + fontFamily = 'KaTeX_Math'; + fontWeight = KatexSpanFontWeight.bold; + fontStyle = KatexSpanFontStyle.italic; + + case 'amsrm': + // .amsrm { font-family: KaTeX_AMS; } + fontFamily = 'KaTeX_AMS'; + + case 'mathbb': + case 'textbb': + // .mathbb, + // .textbb { font-family: KaTeX_AMS; } + fontFamily = 'KaTeX_AMS'; + + case 'mathcal': + // .mathcal { font-family: KaTeX_Caligraphic; } + fontFamily = 'KaTeX_Caligraphic'; + + case 'mathfrak': + case 'textfrak': + // .mathfrak, + // .textfrak { font-family: KaTeX_Fraktur; } + fontFamily = 'KaTeX_Fraktur'; + + case 'mathboldfrak': + case 'textboldfrak': + // .mathboldfrak, + // .textboldfrak { font-family: KaTeX_Fraktur; font-weight: bold; } + fontFamily = 'KaTeX_Fraktur'; + fontWeight = KatexSpanFontWeight.bold; + + case 'mathtt': + // .mathtt { font-family: KaTeX_Typewriter; } + fontFamily = 'KaTeX_Typewriter'; + + case 'mathscr': + case 'textscr': + // .mathscr, + // .textscr { font-family: KaTeX_Script; } + fontFamily = 'KaTeX_Script'; + + case 'mathsf': + case 'textsf': + // .mathsf, + // .textsf { font-family: KaTeX_SansSerif; } + fontFamily = 'KaTeX_SansSerif'; + + case 'mathboldsf': + case 'textboldsf': + // .mathboldsf, + // .textboldsf { font-family: KaTeX_SansSerif; font-weight: bold; } + fontFamily = 'KaTeX_SansSerif'; + fontWeight = KatexSpanFontWeight.bold; + + case 'mathsfit': + case 'mathitsf': + case 'textitsf': + // .mathsfit, + // .mathitsf, + // .textitsf { font-family: KaTeX_SansSerif; font-style: italic; } + fontFamily = 'KaTeX_SansSerif'; + fontStyle = KatexSpanFontStyle.italic; + + case 'mainrm': + // .mainrm { font-family: KaTeX_Main; font-style: normal; } + fontFamily = 'KaTeX_Main'; + fontStyle = KatexSpanFontStyle.normal; + + // TODO handle skipped class declarations between .mainrm and + // .mspace . + + case 'mspace': + // .mspace { display: inline-block; } + // A .mspace span's children are always either empty, + // a no-break space " " (== "\xa0"), + // or one span.mtight containing a no-break space. + // TODO enforce that constraint on .mspace spans in parsing + // So `display: inline-block` has no effect compared to + // the initial `display: inline`. + break; + + // TODO handle skipped class declarations between .mspace and + // .msupsub . + + case 'msupsub': + // .msupsub { text-align: left; } + textAlign = KatexSpanTextAlign.left; + + // TODO handle skipped class declarations between .msupsub and + // .sizing . + + case 'sizing': + case 'fontsize-ensurer': + // .sizing, + // .fontsize-ensurer { ... } + if (index + 2 > spanClasses.length) throw _KatexHtmlParseError(); + final resetSizeClass = spanClasses[index++]; + final sizeClass = spanClasses[index++]; + + final resetSizeClassSuffix = _resetSizeClassRegExp.firstMatch(resetSizeClass)?.group(1); + if (resetSizeClassSuffix == null) throw _KatexHtmlParseError(); + final sizeClassSuffix = _sizeClassRegExp.firstMatch(sizeClass)?.group(1); + if (sizeClassSuffix == null) throw _KatexHtmlParseError(); + + const sizes = [0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.2, 1.44, 1.728, 2.074, 2.488]; + + final resetSizeIdx = int.parse(resetSizeClassSuffix, radix: 10); + final sizeIdx = int.parse(sizeClassSuffix, radix: 10); + + // These indexes start at 1. + if (resetSizeIdx > sizes.length) throw _KatexHtmlParseError(); + if (sizeIdx > sizes.length) throw _KatexHtmlParseError(); + fontSizeEm = sizes[sizeIdx - 1] / sizes[resetSizeIdx - 1]; + + case 'delimsizing': + // .delimsizing { ... } + if (index + 1 > spanClasses.length) throw _KatexHtmlParseError(); + fontFamily = switch (spanClasses[index++]) { + 'size1' => 'KaTeX_Size1', + 'size2' => 'KaTeX_Size2', + 'size3' => 'KaTeX_Size3', + 'size4' => 'KaTeX_Size4', + 'mult' => + // TODO handle nested spans with `.delim-size{1,4}` class. + throw _KatexHtmlParseError('unimplemented CSS class pair: .delimsizing.mult'), + _ => throw _KatexHtmlParseError(), + }; + + case 'nulldelimiter': + // .nulldelimiter { display: inline-block; width: 0.12em; } + widthEm = 0.12; + + // TODO .delimcenter . + + case 'op-symbol': + // .op-symbol { ... } + if (index + 1 > spanClasses.length) throw _KatexHtmlParseError(); + fontFamily = switch (spanClasses[index++]) { + 'small-op' => 'KaTeX_Size1', + 'large-op' => 'KaTeX_Size2', + _ => throw _KatexHtmlParseError(), + }; + + // TODO handle more classes from katex.scss + + case 'mord': + case 'mopen': + case 'mtight': + case 'text': + case 'mrel': + case 'mop': + case 'mclose': + case 'minner': + case 'mbin': + case 'mpunct': + case 'nobreak': + case 'allowbreak': + case 'mathdefault': + // Ignore these classes because they don't have a CSS definition + // in katex.scss, but we encounter them in the generated HTML. + // (Why are they there if they're not used? The story seems to be: + // they were used in KaTeX's CSS in the past, before 2020 or so; and + // they're still used internally by KaTeX in producing the HTML. + // https://github.com/KaTeX/KaTeX/issues/2194#issuecomment-584703052 + // https://github.com/KaTeX/KaTeX/issues/3344 + // ) + break; + + default: + assert(debugLog('KaTeX: Unsupported CSS class: $spanClass')); + unsupportedCssClasses.add(spanClass); + _hasError = true; + } + } + + final inlineStyles = _parseInlineStyles(element); + final styles = KatexSpanStyles( + widthEm: widthEm, + fontFamily: fontFamily, + fontSizeEm: fontSizeEm, + fontWeight: fontWeight, + fontStyle: fontStyle, + textAlign: textAlign, + heightEm: _takeStyleEm(inlineStyles, 'height'), + topEm: _takeStyleEm(inlineStyles, 'top'), + marginLeftEm: _takeStyleEm(inlineStyles, 'margin-left'), + marginRightEm: _takeStyleEm(inlineStyles, 'margin-right'), + color: _takeStyleColor(inlineStyles, 'color'), + position: _takeStylePosition(inlineStyles, 'position'), + // TODO handle more CSS properties + ); + if (inlineStyles != null && inlineStyles.isNotEmpty) { + for (final property in inlineStyles.keys) { + assert(debugLog('KaTeX: Unexpected inline CSS property: $property')); + unsupportedInlineCssProperties.add(property); + _hasError = true; + } + } + if (styles.topEm != null && styles.position != KatexSpanPosition.relative) { + // The meaning of `top` would be different without `position: relative`. + throw _KatexHtmlParseError( + 'unsupported inline CSS property "top" given "position: ${styles.position}"'); + } + + String? text; + List? spans; + if (element.nodes case [dom.Text(:final data)]) { + text = data; + } else { + spans = _parseChildSpans(element.nodes); + } + if (text == null && spans == null) throw _KatexHtmlParseError(); + + return KatexSpanNode( + styles: styles, + text: text, + nodes: spans, + debugHtmlNode: kDebugMode ? element : null); + } + + /// Parse the inline CSS styles from the given element. + /// + /// To interpret the resulting map, consider [_takeStyleEm]. + static Map? _parseInlineStyles(dom.Element element) { + final styleStr = element.attributes['style']; + if (styleStr == null) return null; + + // `package:csslib` doesn't seem to have a way to parse inline styles: + // https://github.com/dart-lang/tools/issues/1173 + // So, work around that by wrapping it in a universal declaration. + final stylesheet = css_parser.parse('*{$styleStr}'); + if (stylesheet.topLevels case [css_visitor.RuleSet() && final ruleSet]) { + final result = {}; + for (final declaration in ruleSet.declarationGroup.declarations) { + if (declaration case css_visitor.Declaration( + :final property, + expression: css_visitor.Expressions( + expressions: [css_visitor.Expression() && final expression]), + )) { + result.update(property, ifAbsent: () => expression, + (_) => throw _KatexHtmlParseError( + 'duplicate inline CSS property: $property')); + } else { + throw _KatexHtmlParseError('unexpected shape of inline CSS'); + } + } + return result; + } else { + throw _KatexHtmlParseError(); + } + } + + /// Remove the given property from the given style map, + /// and parse as a length in ems. + /// + /// If the property is present but is not a length in ems, + /// record an error and return null. + /// + /// If the property is absent, return null with no error. + /// + /// If the map is null, treat it as empty. + /// + /// To produce the map this method expects, see [_parseInlineStyles]. + double? _takeStyleEm(Map? styles, String property) { + final expression = styles?.remove(property); + if (expression == null) return null; + if (expression is css_visitor.EmTerm && expression.value is num) { + return (expression.value as num).toDouble(); + } + assert(debugLog('KaTeX: Unsupported value for CSS property $property,' + ' expected a length in em: ${expression.toDebugString()}')); + unsupportedInlineCssProperties.add(property); + _hasError = true; + return null; + } + + /// Remove the given property from the given style map, + /// and parse as a color value. + /// + /// If the property is present but is not a valid CSS Hex color, + /// or is not one of the CSS named color, record an error + /// and return null. + /// + /// If the property is absent, return null with no error. + /// + /// If the map is null, treat it as empty. + /// + /// To produce the map this method expects, see [_parseInlineStyles]. + KatexSpanColor? _takeStyleColor(Map? styles, String property) { + final expression = styles?.remove(property); + if (expression == null) return null; + + // `package:csslib` parser emits a HexColorTerm for the `color` + // attribute. It automatically resolves the named CSS colors to + // their hex values. The `HexColorTerm.value` is the hex + // encoded in an integer in the same sequence as the input hex + // string. But it also allows some non-conformant CSS hex color + // notations, like #f, #ff, #fffff, #fffffff. + // See: + // https://drafts.csswg.org/css-color/#hex-notation. + // https://github.com/dart-lang/tools/blob/2a2a2d611/pkgs/csslib/lib/parser.dart#L2714-L2743 + // + // So, we try to parse the value of `color` attribute ourselves + // only allowing conformant CSS hex color notations, mapping + // named CSS colors to their corresponding values, generating a + // typed result (KatexSpanColor(r, g, b, a)) to be used later + // while rendering. + final valueStr = expression.span?.text; + if (valueStr != null) { + if (valueStr.startsWith('#')) { + final color = parseCssHexColor(valueStr); + if (color != null) return color; + } else { + final color = _cssNamedColorsMap[valueStr]; + if (color != null) return color; + } + } + assert(debugLog('KaTeX: Unsupported value for CSS property $property,' + ' expected a color: ${expression.toDebugString()}')); + unsupportedInlineCssProperties.add(property); + _hasError = true; + return null; + } + + /// Remove the given property from the given style map, + /// and parse as a CSS position value. + /// + /// If the property is present but is not a valid CSS position value, + /// record an error and return null. + /// + /// If the property is absent, return null with no error. + /// + /// If the map is null, treat it as empty. + /// + /// To produce the map this method expects, see [_parseInlineStyles]. + KatexSpanPosition? _takeStylePosition(Map? styles, String property) { + final expression = styles?.remove(property); + if (expression == null) return null; + if (expression case css_visitor.LiteralTerm(:final value)) { + if (value case css_visitor.Identifier(:final name)) { + if (name == 'relative') { + return KatexSpanPosition.relative; + } + } + } + assert(debugLog('KaTeX: Unsupported value for CSS property $property,' + ' expected a CSS position value: ${expression.toDebugString()}')); + unsupportedInlineCssProperties.add(property); + _hasError = true; + return null; + } +} + +enum KatexSpanFontWeight { + bold, +} + +enum KatexSpanFontStyle { + normal, + italic, +} + +enum KatexSpanTextAlign { + left, + center, + right, +} + +enum KatexSpanPosition { + relative, +} + +class KatexSpanColor { + const KatexSpanColor(this.r, this.g, this.b, this.a); + + final int r; + final int g; + final int b; + final int a; + + @override + bool operator ==(Object other) { + return other is KatexSpanColor && + other.r == r && + other.g == g && + other.b == b && + other.a == a; + } + + @override + int get hashCode => Object.hash('KatexSpanColor', r, g, b, a); + + @override + String toString() { + return '${objectRuntimeType(this, 'KatexSpanColor')}($r, $g, $b, $a)'; + } +} + +@immutable +class KatexSpanStyles { + final double? widthEm; + + // TODO(#1674) does height actually appear on generic spans? + // In a corpus, the only occurrences that we don't already handle separately + // (i.e. occurrences other than on struts, vlists, etc) seem to be within + // accents; so after #1674 we might be handling those separately too. + final double? heightEm; + + // We expect `vertical-align` inline style to be only present on a + // `strut` span, for which we emit `KatexStrutNode` separately. + // final double? verticalAlignEm; + + final double? topEm; + + final double? marginRightEm; + final double? marginLeftEm; + + final String? fontFamily; + final double? fontSizeEm; + final KatexSpanFontWeight? fontWeight; + final KatexSpanFontStyle? fontStyle; + final KatexSpanTextAlign? textAlign; + + final KatexSpanColor? color; + final KatexSpanPosition? position; + + const KatexSpanStyles({ + this.widthEm, + this.heightEm, + this.topEm, + this.marginRightEm, + this.marginLeftEm, + this.fontFamily, + this.fontSizeEm, + this.fontWeight, + this.fontStyle, + this.textAlign, + this.color, + this.position, + }); + + @override + int get hashCode => Object.hash( + 'KatexSpanStyles', + widthEm, + heightEm, + topEm, + marginRightEm, + marginLeftEm, + fontFamily, + fontSizeEm, + fontWeight, + fontStyle, + textAlign, + color, + position, + ); + + @override + bool operator ==(Object other) { + return other is KatexSpanStyles && + other.widthEm == widthEm && + other.heightEm == heightEm && + other.topEm == topEm && + other.marginRightEm == marginRightEm && + other.marginLeftEm == marginLeftEm && + other.fontFamily == fontFamily && + other.fontSizeEm == fontSizeEm && + other.fontWeight == fontWeight && + other.fontStyle == fontStyle && + other.textAlign == textAlign && + other.color == color && + other.position == position; + } + + @override + String toString() { + final args = []; + if (widthEm != null) args.add('widthEm: $widthEm'); + if (heightEm != null) args.add('heightEm: $heightEm'); + if (topEm != null) args.add('topEm: $topEm'); + if (marginRightEm != null) args.add('marginRightEm: $marginRightEm'); + if (marginLeftEm != null) args.add('marginLeftEm: $marginLeftEm'); + if (fontFamily != null) args.add('fontFamily: $fontFamily'); + if (fontSizeEm != null) args.add('fontSizeEm: $fontSizeEm'); + if (fontWeight != null) args.add('fontWeight: $fontWeight'); + if (fontStyle != null) args.add('fontStyle: $fontStyle'); + if (textAlign != null) args.add('textAlign: $textAlign'); + if (color != null) args.add('color: $color'); + if (position != null) args.add('position: $position'); + return '${objectRuntimeType(this, 'KatexSpanStyles')}(${args.join(', ')})'; + } + + KatexSpanStyles filter({ + bool widthEm = true, + bool heightEm = true, + bool verticalAlignEm = true, + bool topEm = true, + bool marginRightEm = true, + bool marginLeftEm = true, + bool fontFamily = true, + bool fontSizeEm = true, + bool fontWeight = true, + bool fontStyle = true, + bool textAlign = true, + bool color = true, + bool position = true, + }) { + return KatexSpanStyles( + widthEm: widthEm ? this.widthEm : null, + heightEm: heightEm ? this.heightEm : null, + topEm: topEm ? this.topEm : null, + marginRightEm: marginRightEm ? this.marginRightEm : null, + marginLeftEm: marginLeftEm ? this.marginLeftEm : null, + fontFamily: fontFamily ? this.fontFamily : null, + fontSizeEm: fontSizeEm ? this.fontSizeEm : null, + fontWeight: fontWeight ? this.fontWeight : null, + fontStyle: fontStyle ? this.fontStyle : null, + textAlign: textAlign ? this.textAlign : null, + color: color ? this.color : null, + position: position ? this.position : null, + ); + } +} + +final _hexColorRegExp = + RegExp(r'^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$'); + +/// Parses the CSS hex color notation. +/// +/// See: https://drafts.csswg.org/css-color/#hex-notation +@visibleForTesting +KatexSpanColor? parseCssHexColor(String hexStr) { + final match = _hexColorRegExp.firstMatch(hexStr); + if (match == null) return null; + + String hexValue = match.group(1)!; + switch (hexValue.length) { + case 3: + hexValue = '${hexValue[0]}${hexValue[0]}' + '${hexValue[1]}${hexValue[1]}' + '${hexValue[2]}${hexValue[2]}' + 'ff'; + case 4: + hexValue = '${hexValue[0]}${hexValue[0]}' + '${hexValue[1]}${hexValue[1]}' + '${hexValue[2]}${hexValue[2]}' + '${hexValue[3]}${hexValue[3]}'; + case 6: + hexValue += 'ff'; + } + + try { + final [r, g, b, a] = hex.decode(hexValue); + return KatexSpanColor(r, g, b, a); + } catch (_) { + return null; // TODO(log) + } +} + +// CSS named colors: https://drafts.csswg.org/css-color/#named-colors +// Map adapted from the following source file: +// https://github.com/w3c/csswg-drafts/blob/1942d0918/css-color-4/Overview.bs#L1562-L1859 +const _cssNamedColorsMap = { + 'transparent': KatexSpanColor(0, 0, 0, 0), // https://drafts.csswg.org/css-color/#transparent-color + 'aliceblue': KatexSpanColor(240, 248, 255, 255), + 'antiquewhite': KatexSpanColor(250, 235, 215, 255), + 'aqua': KatexSpanColor(0, 255, 255, 255), + 'aquamarine': KatexSpanColor(127, 255, 212, 255), + 'azure': KatexSpanColor(240, 255, 255, 255), + 'beige': KatexSpanColor(245, 245, 220, 255), + 'bisque': KatexSpanColor(255, 228, 196, 255), + 'black': KatexSpanColor(0, 0, 0, 255), + 'blanchedalmond': KatexSpanColor(255, 235, 205, 255), + 'blue': KatexSpanColor(0, 0, 255, 255), + 'blueviolet': KatexSpanColor(138, 43, 226, 255), + 'brown': KatexSpanColor(165, 42, 42, 255), + 'burlywood': KatexSpanColor(222, 184, 135, 255), + 'cadetblue': KatexSpanColor(95, 158, 160, 255), + 'chartreuse': KatexSpanColor(127, 255, 0, 255), + 'chocolate': KatexSpanColor(210, 105, 30, 255), + 'coral': KatexSpanColor(255, 127, 80, 255), + 'cornflowerblue': KatexSpanColor(100, 149, 237, 255), + 'cornsilk': KatexSpanColor(255, 248, 220, 255), + 'crimson': KatexSpanColor(220, 20, 60, 255), + 'cyan': KatexSpanColor(0, 255, 255, 255), + 'darkblue': KatexSpanColor(0, 0, 139, 255), + 'darkcyan': KatexSpanColor(0, 139, 139, 255), + 'darkgoldenrod': KatexSpanColor(184, 134, 11, 255), + 'darkgray': KatexSpanColor(169, 169, 169, 255), + 'darkgreen': KatexSpanColor(0, 100, 0, 255), + 'darkgrey': KatexSpanColor(169, 169, 169, 255), + 'darkkhaki': KatexSpanColor(189, 183, 107, 255), + 'darkmagenta': KatexSpanColor(139, 0, 139, 255), + 'darkolivegreen': KatexSpanColor(85, 107, 47, 255), + 'darkorange': KatexSpanColor(255, 140, 0, 255), + 'darkorchid': KatexSpanColor(153, 50, 204, 255), + 'darkred': KatexSpanColor(139, 0, 0, 255), + 'darksalmon': KatexSpanColor(233, 150, 122, 255), + 'darkseagreen': KatexSpanColor(143, 188, 143, 255), + 'darkslateblue': KatexSpanColor(72, 61, 139, 255), + 'darkslategray': KatexSpanColor(47, 79, 79, 255), + 'darkslategrey': KatexSpanColor(47, 79, 79, 255), + 'darkturquoise': KatexSpanColor(0, 206, 209, 255), + 'darkviolet': KatexSpanColor(148, 0, 211, 255), + 'deeppink': KatexSpanColor(255, 20, 147, 255), + 'deepskyblue': KatexSpanColor(0, 191, 255, 255), + 'dimgray': KatexSpanColor(105, 105, 105, 255), + 'dimgrey': KatexSpanColor(105, 105, 105, 255), + 'dodgerblue': KatexSpanColor(30, 144, 255, 255), + 'firebrick': KatexSpanColor(178, 34, 34, 255), + 'floralwhite': KatexSpanColor(255, 250, 240, 255), + 'forestgreen': KatexSpanColor(34, 139, 34, 255), + 'fuchsia': KatexSpanColor(255, 0, 255, 255), + 'gainsboro': KatexSpanColor(220, 220, 220, 255), + 'ghostwhite': KatexSpanColor(248, 248, 255, 255), + 'gold': KatexSpanColor(255, 215, 0, 255), + 'goldenrod': KatexSpanColor(218, 165, 32, 255), + 'gray': KatexSpanColor(128, 128, 128, 255), + 'green': KatexSpanColor(0, 128, 0, 255), + 'greenyellow': KatexSpanColor(173, 255, 47, 255), + 'grey': KatexSpanColor(128, 128, 128, 255), + 'honeydew': KatexSpanColor(240, 255, 240, 255), + 'hotpink': KatexSpanColor(255, 105, 180, 255), + 'indianred': KatexSpanColor(205, 92, 92, 255), + 'indigo': KatexSpanColor(75, 0, 130, 255), + 'ivory': KatexSpanColor(255, 255, 240, 255), + 'khaki': KatexSpanColor(240, 230, 140, 255), + 'lavender': KatexSpanColor(230, 230, 250, 255), + 'lavenderblush': KatexSpanColor(255, 240, 245, 255), + 'lawngreen': KatexSpanColor(124, 252, 0, 255), + 'lemonchiffon': KatexSpanColor(255, 250, 205, 255), + 'lightblue': KatexSpanColor(173, 216, 230, 255), + 'lightcoral': KatexSpanColor(240, 128, 128, 255), + 'lightcyan': KatexSpanColor(224, 255, 255, 255), + 'lightgoldenrodyellow': KatexSpanColor(250, 250, 210, 255), + 'lightgray': KatexSpanColor(211, 211, 211, 255), + 'lightgreen': KatexSpanColor(144, 238, 144, 255), + 'lightgrey': KatexSpanColor(211, 211, 211, 255), + 'lightpink': KatexSpanColor(255, 182, 193, 255), + 'lightsalmon': KatexSpanColor(255, 160, 122, 255), + 'lightseagreen': KatexSpanColor(32, 178, 170, 255), + 'lightskyblue': KatexSpanColor(135, 206, 250, 255), + 'lightslategray': KatexSpanColor(119, 136, 153, 255), + 'lightslategrey': KatexSpanColor(119, 136, 153, 255), + 'lightsteelblue': KatexSpanColor(176, 196, 222, 255), + 'lightyellow': KatexSpanColor(255, 255, 224, 255), + 'lime': KatexSpanColor(0, 255, 0, 255), + 'limegreen': KatexSpanColor(50, 205, 50, 255), + 'linen': KatexSpanColor(250, 240, 230, 255), + 'magenta': KatexSpanColor(255, 0, 255, 255), + 'maroon': KatexSpanColor(128, 0, 0, 255), + 'mediumaquamarine': KatexSpanColor(102, 205, 170, 255), + 'mediumblue': KatexSpanColor(0, 0, 205, 255), + 'mediumorchid': KatexSpanColor(186, 85, 211, 255), + 'mediumpurple': KatexSpanColor(147, 112, 219, 255), + 'mediumseagreen': KatexSpanColor(60, 179, 113, 255), + 'mediumslateblue': KatexSpanColor(123, 104, 238, 255), + 'mediumspringgreen': KatexSpanColor(0, 250, 154, 255), + 'mediumturquoise': KatexSpanColor(72, 209, 204, 255), + 'mediumvioletred': KatexSpanColor(199, 21, 133, 255), + 'midnightblue': KatexSpanColor(25, 25, 112, 255), + 'mintcream': KatexSpanColor(245, 255, 250, 255), + 'mistyrose': KatexSpanColor(255, 228, 225, 255), + 'moccasin': KatexSpanColor(255, 228, 181, 255), + 'navajowhite': KatexSpanColor(255, 222, 173, 255), + 'navy': KatexSpanColor(0, 0, 128, 255), + 'oldlace': KatexSpanColor(253, 245, 230, 255), + 'olive': KatexSpanColor(128, 128, 0, 255), + 'olivedrab': KatexSpanColor(107, 142, 35, 255), + 'orange': KatexSpanColor(255, 165, 0, 255), + 'orangered': KatexSpanColor(255, 69, 0, 255), + 'orchid': KatexSpanColor(218, 112, 214, 255), + 'palegoldenrod': KatexSpanColor(238, 232, 170, 255), + 'palegreen': KatexSpanColor(152, 251, 152, 255), + 'paleturquoise': KatexSpanColor(175, 238, 238, 255), + 'palevioletred': KatexSpanColor(219, 112, 147, 255), + 'papayawhip': KatexSpanColor(255, 239, 213, 255), + 'peachpuff': KatexSpanColor(255, 218, 185, 255), + 'peru': KatexSpanColor(205, 133, 63, 255), + 'pink': KatexSpanColor(255, 192, 203, 255), + 'plum': KatexSpanColor(221, 160, 221, 255), + 'powderblue': KatexSpanColor(176, 224, 230, 255), + 'purple': KatexSpanColor(128, 0, 128, 255), + 'rebeccapurple': KatexSpanColor(102, 51, 153, 255), + 'red': KatexSpanColor(255, 0, 0, 255), + 'rosybrown': KatexSpanColor(188, 143, 143, 255), + 'royalblue': KatexSpanColor(65, 105, 225, 255), + 'saddlebrown': KatexSpanColor(139, 69, 19, 255), + 'salmon': KatexSpanColor(250, 128, 114, 255), + 'sandybrown': KatexSpanColor(244, 164, 96, 255), + 'seagreen': KatexSpanColor(46, 139, 87, 255), + 'seashell': KatexSpanColor(255, 245, 238, 255), + 'sienna': KatexSpanColor(160, 82, 45, 255), + 'silver': KatexSpanColor(192, 192, 192, 255), + 'skyblue': KatexSpanColor(135, 206, 235, 255), + 'slateblue': KatexSpanColor(106, 90, 205, 255), + 'slategray': KatexSpanColor(112, 128, 144, 255), + 'slategrey': KatexSpanColor(112, 128, 144, 255), + 'snow': KatexSpanColor(255, 250, 250, 255), + 'springgreen': KatexSpanColor(0, 255, 127, 255), + 'steelblue': KatexSpanColor(70, 130, 180, 255), + 'tan': KatexSpanColor(210, 180, 140, 255), + 'teal': KatexSpanColor(0, 128, 128, 255), + 'thistle': KatexSpanColor(216, 191, 216, 255), + 'tomato': KatexSpanColor(255, 99, 71, 255), + 'turquoise': KatexSpanColor(64, 224, 208, 255), + 'violet': KatexSpanColor(238, 130, 238, 255), + 'wheat': KatexSpanColor(245, 222, 179, 255), + 'white': KatexSpanColor(255, 255, 255, 255), + 'whitesmoke': KatexSpanColor(245, 245, 245, 255), + 'yellow': KatexSpanColor(255, 255, 0, 255), + 'yellowgreen': KatexSpanColor(154, 205, 50, 255), +}; + +class _KatexHtmlParseError extends Error { + final String? message; + + _KatexHtmlParseError([this.message]); + + @override + String toString() { + if (message != null) { + return 'Katex HTML parse error: $message'; + } + return 'Katex HTML parse error'; + } +} diff --git a/lib/model/legacy_app_data.dart b/lib/model/legacy_app_data.dart new file mode 100644 index 0000000000..5f6197f0fc --- /dev/null +++ b/lib/model/legacy_app_data.dart @@ -0,0 +1,508 @@ +/// Logic for reading from the legacy app's data, on upgrade to this app. +/// +/// Many of the details here correspond to specific parts of the +/// legacy app's source code. +/// See . +// TODO(#1593): write tests for this file +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/drift.dart' as drift; +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sqlite3/sqlite3.dart'; + +import '../log.dart'; +import 'database.dart'; +import 'settings.dart'; + +part 'legacy_app_data.g.dart'; + +Future migrateLegacyAppData(AppDatabase db) async { + assert(debugLog("Migrating legacy app data...")); + final legacyData = await readLegacyAppData(); + if (legacyData == null) { + assert(debugLog("... no legacy app data found.")); + await _setLegacyUpgradeState(db, LegacyUpgradeState.noLegacy); + return; + } + + assert(debugLog("Found settings: ${legacyData.settings?.toJson()}")); + await _setLegacyUpgradeState(db, LegacyUpgradeState.found); + final settings = legacyData.settings; + if (settings != null) { + await db.update(db.globalSettings).write(GlobalSettingsCompanion( + // TODO(#1139) apply settings.language + themeSetting: switch (settings.theme) { + // The legacy app has just two values for this setting: light and dark, + // where light is the default. Map that default to the new default, + // which is to follow the system-wide setting. + // We planned the same change for the legacy app (but were + // foiled by React Native): + // https://github.com/zulip/zulip-mobile/issues/5533 + // More-recent discussion: + // https://github.com/zulip/zulip-flutter/pull/1588#discussion_r2147418577 + LegacyAppThemeSetting.default_ => drift.Value.absent(), + LegacyAppThemeSetting.night => drift.Value(ThemeSetting.dark), + }, + browserPreference: switch (settings.browser) { + LegacyAppBrowserPreference.embedded => drift.Value(BrowserPreference.inApp), + LegacyAppBrowserPreference.external => drift.Value(BrowserPreference.external), + LegacyAppBrowserPreference.default_ => drift.Value.absent(), + }, + markReadOnScroll: switch (settings.markMessagesReadOnScroll) { + // The legacy app's default was "always". + // In this app, that would mix poorly with the VisitFirstUnreadSetting + // default of "conversations"; so translate the old default + // to the new default of "conversations". + LegacyAppMarkMessagesReadOnScroll.always => + drift.Value(MarkReadOnScrollSetting.conversations), + LegacyAppMarkMessagesReadOnScroll.never => + drift.Value(MarkReadOnScrollSetting.never), + LegacyAppMarkMessagesReadOnScroll.conversationViewsOnly => + drift.Value(MarkReadOnScrollSetting.conversations), + }, + )); + } + + assert(debugLog("Found ${legacyData.accounts?.length} accounts:")); + for (final account in legacyData.accounts ?? []) { + assert(debugLog(" account: ${account.toJson()..['apiKey'] = 'redacted'}")); + if (account.apiKey.isEmpty) { + // This represents the user having logged out of this account. + // (See `Auth.apiKey` in src/api/transportTypes.js .) + // In this app, when a user logs out of an account, + // the account is removed from the accounts list. So remove this account. + assert(debugLog(" (account ignored because had been logged out)")); + continue; + } + if (account.userId == null + || account.zulipVersion == null + || account.zulipFeatureLevel == null) { + // The legacy app either never loaded server data for this account, + // or last did so on an ancient version of the app. + // (See docs and comments on these properties in src/types.js . + // Specifically, the latest added of these was userId, in commit 4fdefb09b + // (#M4968), released in v27.170 in 2021-09.) + // Drop the account. + assert(debugLog(" (account ignored because missing metadata)")); + continue; + } + try { + await db.createAccount(AccountsCompanion.insert( + realmUrl: account.realm, + userId: account.userId!, + email: account.email, + apiKey: account.apiKey, + zulipVersion: account.zulipVersion!, + // no zulipMergeBase; legacy app didn't record it + zulipFeatureLevel: account.zulipFeatureLevel!, + // This app doesn't yet maintain ackedPushToken (#322), so avoid recording + // a value that would then be allowed to get stale. See discussion: + // https://github.com/zulip/zulip-flutter/pull/1588#discussion_r2148817025 + // TODO(#322): apply ackedPushToken + // ackedPushToken: drift.Value(account.ackedPushToken), + )); + } on AccountAlreadyExistsException { + // There's one known way this can actually happen: the legacy app doesn't + // prevent duplicates on (realm, userId), only on (realm, email). + // + // So if e.g. the user changed their email on an account at some point + // in the past, and didn't go and delete the old version from the + // list of accounts, then the old version (the one later in the list, + // since the legacy app orders accounts by recency) will get dropped here. + assert(debugLog(" (account ignored because duplicate)")); + continue; + } + } + + assert(debugLog("Done migrating legacy app data.")); + await _setLegacyUpgradeState(db, LegacyUpgradeState.migrated); +} + +Future _setLegacyUpgradeState(AppDatabase db, LegacyUpgradeState value) async { + await db.update(db.globalSettings).write(GlobalSettingsCompanion( + legacyUpgradeState: drift.Value(value))); +} + +Future readLegacyAppData() async { + final LegacyAppDatabase db; + try { + final sqlDb = sqlite3.open(await LegacyAppDatabase._filename()); + + // For writing tests (but more refactoring needed): + // sqlDb = sqlite3.openInMemory(); + + db = LegacyAppDatabase(sqlDb); + } catch (_) { + // Presumably the legacy database just doesn't exist, + // e.g. because this is a fresh install, not an upgrade from the legacy app. + return null; + } + + try { + if (db.migrationVersion() != 1) { + // The data is ancient. + return null; // TODO(log) + } + + final migrationsState = db.getDecodedItem('reduxPersist:migrations', + LegacyAppMigrationsState.fromJson); + final migrationsVersion = migrationsState?.version; + if (migrationsVersion == null) { + // The data never got written in the first place, + // at least not coherently. + return null; // TODO(log) + } + if (migrationsVersion < 58) { + // The data predates a migration that affected data we'll try to read. + // Namely migration 58, from commit 49ed2ef5d, PR #5656, 2023-02. + return null; // TODO(log) + } + if (migrationsVersion > 66) { + // The data is from a future schema version this app is unaware of. + return null; // TODO(log) + } + + final settingsStr = db.getItem('reduxPersist:settings'); + final accountsStr = db.getItem('reduxPersist:accounts'); + try { + return LegacyAppData.fromJson({ + 'settings': settingsStr == null ? null : jsonDecode(settingsStr), + 'accounts': accountsStr == null ? null : jsonDecode(accountsStr), + }); + } catch (_) { + return null; // TODO(log) + } + } on SqliteException { + return null; // TODO(log) + } +} + +class LegacyAppDatabase { + LegacyAppDatabase(this._db); + + final Database _db; + + static Future _filename() async { + const baseName = 'zulip.db'; // from AsyncStorageImpl._initDb + + final dir = await switch (defaultTargetPlatform) { + // See node_modules/expo-sqlite/android/src/main/java/expo/modules/sqlite/SQLiteModule.kt + // and the method SQLiteModule.pathForDatabaseName there: + // works out to "${mContext.filesDir}/SQLite/$name", + // so starting from: + // https://developer.android.com/reference/kotlin/android/content/Context#getFilesDir() + // That's what path_provider's getApplicationSupportDirectory gives. + // (The latter actually has a fallback when Android's getFilesDir + // returns null. But the Android docs say that can't happen. If it does, + // SQLiteModule would just fail to make a database, and the legacy app + // wouldn't have managed to store anything in the first place.) + TargetPlatform.android => getApplicationSupportDirectory(), + + // See node_modules/expo-sqlite/ios/EXSQLite/EXSQLite.m + // and the method `pathForDatabaseName:` there: + // works out to "${fileSystem.documentDirectory}/SQLite/$name", + // The base directory there comes from: + // node_modules/expo-modules-core/ios/Interfaces/FileSystem/EXFileSystemInterface.h + // node_modules/expo-file-system/ios/EXFileSystem/EXFileSystem.m + // so ultimately from an expression: + // NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) + // which means here: + // https://developer.apple.com/documentation/foundation/nssearchpathfordirectoriesindomains(_:_:_:)?language=objc + // https://developer.apple.com/documentation/foundation/filemanager/searchpathdirectory/documentdirectory?language=objc + // That's what path_provider's getApplicationDocumentsDirectory gives. + TargetPlatform.iOS => getApplicationDocumentsDirectory(), + + // On other platforms, there is no Zulip legacy app that this app replaces. + // So there's nothing to migrate. + _ => throw Exception(), + }; + + return '${dir.path}/SQLite/$baseName'; + } + + /// The migration version of the AsyncStorage database as a whole + /// (not to be confused with the version within `state.migrations`). + /// + /// This is always 1 since it was introduced, + /// in commit caf3bf999 in 2022-04. + /// + /// Corresponds to portions of AsyncStorageImpl._migrate . + int migrationVersion() { + final rows = _db.select('SELECT version FROM migration LIMIT 1'); + return rows.single.values.single as int; + } + + T? getDecodedItem(String key, T Function(Map) fromJson) { + final valueStr = getItem(key); + if (valueStr == null) return null; + + try { + return fromJson(jsonDecode(valueStr) as Map); + } catch (_) { + return null; // TODO(log) + } + } + + /// Corresponds to CompressedAsyncStorage.getItem. + String? getItem(String key) { + final item = getItemRaw(key); + if (item == null) return null; + if (item.startsWith('z')) { + // A leading 'z' marks Zulip compression. + // (It can't be the original uncompressed value, because all our values + // are JSON, and no JSON encoding starts with a 'z'.) + + if (defaultTargetPlatform != TargetPlatform.android) { + return null; // TODO(log) + } + + /// Corresponds to `header` in android/app/src/main/java/com/zulipmobile/TextCompression.kt . + const header = 'z|zlib base64|'; + if (!item.startsWith(header)) { + return null; // TODO(log) + } + + // These steps correspond to `decompress` in android/app/src/main/java/com/zulipmobile/TextCompression.kt . + final encodedSplit = item.substring(header.length); + // Not sure how newlines get there into the data; but empirically + // they do, after each 76 characters of `encodedSplit`. + final encoded = encodedSplit.replaceAll('\n', ''); + try { + final compressedBytes = base64Decode(encoded); + final uncompressedBytes = zlib.decoder.convert(compressedBytes); + return utf8.decode(uncompressedBytes); + } catch (_) { + return null; // TODO(log) + } + } + return item; + } + + /// Corresponds to AsyncStorageImpl.getItem. + String? getItemRaw(String key) { + final rows = _db.select('SELECT value FROM keyvalue WHERE key = ?', [key]); + final row = rows.firstOrNull; + if (row == null) return null; + return row.values.single as String; + } + + /// Corresponds to AsyncStorageImpl.getAllKeys. + List getAllKeys() { + final rows = _db.select('SELECT key FROM keyvalue'); + return [for (final r in rows) r.values.single as String]; + } +} + +/// Represents the data from the legacy app's database, +/// so far as it's relevant for this app. +/// +/// The full set of data in the legacy app's in-memory store is described by +/// the type `GlobalState` in src/reduxTypes.js . +/// Within that, the data it stores in the database is the data at the keys +/// listed in `storeKeys` and `cacheKeys` in src/boot/store.js . +/// The data under `cacheKeys` lives on the server and the app re-fetches it +/// upon each startup anyway; +/// so only the data under `storeKeys` is relevant for migrating to this app. +/// +/// Within the data under `storeKeys`, some portions are also ignored +/// for specific reasons described explicitly in comments on these types. +@JsonSerializable() +class LegacyAppData { + // The `state.migrations` data gets read and used before attempting to + // deserialize the data that goes into this class. + // final LegacyAppMigrationsState migrations; // handled separately + + final LegacyAppGlobalSettingsState? settings; + final List? accounts; + + // final Map drafts; // ignore; inherently transient + + // final List outbox; // ignore; inherently transient + + LegacyAppData({ + required this.settings, + required this.accounts, + }); + + factory LegacyAppData.fromJson(Map json) => + _$LegacyAppDataFromJson(json); + + Map toJson() => _$LegacyAppDataToJson(this); +} + +/// Corresponds to type `MigrationsState` in src/reduxTypes.js . +@JsonSerializable() +class LegacyAppMigrationsState { + final int? version; + + LegacyAppMigrationsState({required this.version}); + + factory LegacyAppMigrationsState.fromJson(Map json) => + _$LegacyAppMigrationsStateFromJson(json); + + Map toJson() => _$LegacyAppMigrationsStateToJson(this); +} + +/// Corresponds to type `GlobalSettingsState` in src/reduxTypes.js . +/// +/// The remaining data found at key `settings` in the overall data, +/// described by type `PerAccountSettingsState`, lives on the server +/// in the same way as the data under the keys in `cacheKeys`, +/// and so is ignored here. +@JsonSerializable() +class LegacyAppGlobalSettingsState { + final String language; + final LegacyAppThemeSetting theme; + final LegacyAppBrowserPreference browser; + + // Ignored because the legacy app hadn't used it since 2017. + // See discussion in commit zulip-mobile@761e3edb4 (from 2018). + // final bool experimentalFeaturesEnabled; // ignore + + final LegacyAppMarkMessagesReadOnScroll markMessagesReadOnScroll; + + LegacyAppGlobalSettingsState({ + required this.language, + required this.theme, + required this.browser, + required this.markMessagesReadOnScroll, + }); + + factory LegacyAppGlobalSettingsState.fromJson(Map json) => + _$LegacyAppGlobalSettingsStateFromJson(json); + + Map toJson() => _$LegacyAppGlobalSettingsStateToJson(this); +} + +/// Corresponds to type `ThemeSetting` in src/reduxTypes.js . +enum LegacyAppThemeSetting { + @JsonValue('default') + default_, + night; +} + +/// Corresponds to type `BrowserPreference` in src/reduxTypes.js . +enum LegacyAppBrowserPreference { + embedded, + external, + @JsonValue('default') + default_, +} + +/// Corresponds to the type `GlobalSettingsState['markMessagesReadOnScroll']` +/// in src/reduxTypes.js . +@JsonEnum(fieldRename: FieldRename.kebab) +enum LegacyAppMarkMessagesReadOnScroll { + always, never, conversationViewsOnly, +} + +/// Corresponds to type `Account` in src/types.js . +@JsonSerializable() +class LegacyAppAccount { + // These three come from type Auth in src/api/transportTypes.js . + @_LegacyAppUrlJsonConverter() + final Uri realm; + final String apiKey; + final String email; + + final int? userId; + + @_LegacyAppZulipVersionJsonConverter() + final String? zulipVersion; + + final int? zulipFeatureLevel; + + final String? ackedPushToken; + + // These three are ignored because this app doesn't currently have such + // notices or banners for them to control; and because if we later introduce + // such things, it's a pretty mild glitch to have them reappear, once, + // after a once-in-N-years major upgrade to the app. + // final DateTime? lastDismissedServerPushSetupNotice; // ignore + // final DateTime? lastDismissedServerNotifsExpiringBanner; // ignore + // final bool silenceServerPushSetupWarnings; // ignore + + LegacyAppAccount({ + required this.realm, + required this.apiKey, + required this.email, + required this.userId, + required this.zulipVersion, + required this.zulipFeatureLevel, + required this.ackedPushToken, + }); + + factory LegacyAppAccount.fromJson(Map json) => + _$LegacyAppAccountFromJson(json); + + Map toJson() => _$LegacyAppAccountToJson(this); +} + +/// This and its subclasses correspond to portions of src/storage/replaceRevive.js . +/// +/// (The rest of the conversions in that file are for types that don't appear +/// in the portions of the legacy app's state we care about.) +sealed class _LegacyAppJsonConverter extends JsonConverter> { + const _LegacyAppJsonConverter(); + + String get serializedTypeName; + + T fromJsonData(Object? json); + + Object? toJsonData(T value); + + /// Corresponds to `SERIALIZED_TYPE_FIELD_NAME`. + static const _serializedTypeFieldName = '__serializedType__'; + + @override + T fromJson(Map json) { + final actualTypeName = json[_serializedTypeFieldName]; + if (actualTypeName != serializedTypeName) { + throw FormatException("unexpected $_serializedTypeFieldName: $actualTypeName"); + } + return fromJsonData(json['data']); + } + + @override + Map toJson(T object) { + return { + _serializedTypeFieldName: serializedTypeName, + 'data': toJsonData(object), + }; + } +} + +class _LegacyAppUrlJsonConverter extends _LegacyAppJsonConverter { + const _LegacyAppUrlJsonConverter(); + + @override + String get serializedTypeName => 'URL'; + + @override + Uri fromJsonData(Object? json) => Uri.parse(json as String); + + @override + Object? toJsonData(Uri value) => value.toString(); +} + +/// Corresponds to type `ZulipVersion`. +/// +/// This new app skips the parsing logic of the legacy app's ZulipVersion type, +/// and just uses the raw string. +class _LegacyAppZulipVersionJsonConverter extends _LegacyAppJsonConverter { + const _LegacyAppZulipVersionJsonConverter(); + + @override + String get serializedTypeName => 'ZulipVersion'; + + @override + String fromJsonData(Object? json) => json as String; + + @override + Object? toJsonData(String value) => value; +} diff --git a/lib/model/legacy_app_data.g.dart b/lib/model/legacy_app_data.g.dart new file mode 100644 index 0000000000..e619745e38 --- /dev/null +++ b/lib/model/legacy_app_data.g.dart @@ -0,0 +1,116 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'legacy_app_data.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LegacyAppData _$LegacyAppDataFromJson(Map json) => + LegacyAppData( + settings: json['settings'] == null + ? null + : LegacyAppGlobalSettingsState.fromJson( + json['settings'] as Map, + ), + accounts: (json['accounts'] as List?) + ?.map((e) => LegacyAppAccount.fromJson(e as Map)) + .toList(), + ); + +Map _$LegacyAppDataToJson(LegacyAppData instance) => + { + 'settings': instance.settings, + 'accounts': instance.accounts, + }; + +LegacyAppMigrationsState _$LegacyAppMigrationsStateFromJson( + Map json, +) => LegacyAppMigrationsState(version: (json['version'] as num?)?.toInt()); + +Map _$LegacyAppMigrationsStateToJson( + LegacyAppMigrationsState instance, +) => {'version': instance.version}; + +LegacyAppGlobalSettingsState _$LegacyAppGlobalSettingsStateFromJson( + Map json, +) => LegacyAppGlobalSettingsState( + language: json['language'] as String, + theme: $enumDecode(_$LegacyAppThemeSettingEnumMap, json['theme']), + browser: $enumDecode(_$LegacyAppBrowserPreferenceEnumMap, json['browser']), + markMessagesReadOnScroll: $enumDecode( + _$LegacyAppMarkMessagesReadOnScrollEnumMap, + json['markMessagesReadOnScroll'], + ), +); + +Map _$LegacyAppGlobalSettingsStateToJson( + LegacyAppGlobalSettingsState instance, +) => { + 'language': instance.language, + 'theme': _$LegacyAppThemeSettingEnumMap[instance.theme]!, + 'browser': _$LegacyAppBrowserPreferenceEnumMap[instance.browser]!, + 'markMessagesReadOnScroll': + _$LegacyAppMarkMessagesReadOnScrollEnumMap[instance + .markMessagesReadOnScroll]!, +}; + +const _$LegacyAppThemeSettingEnumMap = { + LegacyAppThemeSetting.default_: 'default', + LegacyAppThemeSetting.night: 'night', +}; + +const _$LegacyAppBrowserPreferenceEnumMap = { + LegacyAppBrowserPreference.embedded: 'embedded', + LegacyAppBrowserPreference.external: 'external', + LegacyAppBrowserPreference.default_: 'default', +}; + +const _$LegacyAppMarkMessagesReadOnScrollEnumMap = { + LegacyAppMarkMessagesReadOnScroll.always: 'always', + LegacyAppMarkMessagesReadOnScroll.never: 'never', + LegacyAppMarkMessagesReadOnScroll.conversationViewsOnly: + 'conversation-views-only', +}; + +LegacyAppAccount _$LegacyAppAccountFromJson(Map json) => + LegacyAppAccount( + realm: const _LegacyAppUrlJsonConverter().fromJson( + json['realm'] as Map, + ), + apiKey: json['apiKey'] as String, + email: json['email'] as String, + userId: (json['userId'] as num?)?.toInt(), + zulipVersion: _$JsonConverterFromJson, String>( + json['zulipVersion'], + const _LegacyAppZulipVersionJsonConverter().fromJson, + ), + zulipFeatureLevel: (json['zulipFeatureLevel'] as num?)?.toInt(), + ackedPushToken: json['ackedPushToken'] as String?, + ); + +Map _$LegacyAppAccountToJson(LegacyAppAccount instance) => + { + 'realm': const _LegacyAppUrlJsonConverter().toJson(instance.realm), + 'apiKey': instance.apiKey, + 'email': instance.email, + 'userId': instance.userId, + 'zulipVersion': _$JsonConverterToJson, String>( + instance.zulipVersion, + const _LegacyAppZulipVersionJsonConverter().toJson, + ), + 'zulipFeatureLevel': instance.zulipFeatureLevel, + 'ackedPushToken': instance.ackedPushToken, + }; + +Value? _$JsonConverterFromJson( + Object? json, + Value? Function(Json json) fromJson, +) => json == null ? null : fromJson(json as Json); + +Json? _$JsonConverterToJson( + Value? value, + Json? Function(Value value) toJson, +) => value == null ? null : toJson(value); diff --git a/lib/model/message.dart b/lib/model/message.dart index f228d6da91..9cc4d76f48 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -1,42 +1,271 @@ +import 'dart:async'; import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; + +import '../api/exception.dart'; import '../api/model/events.dart'; +import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; +import '../api/route/messages.dart'; import '../log.dart'; +import 'binding.dart'; +import 'channel.dart'; import 'message_list.dart'; +import 'realm.dart'; +import 'store.dart'; + +const _apiSendMessage = sendMessage; // Bit ugly; for alternatives, see: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20PerAccountStore.20methods/near/1545809 /// The portion of [PerAccountStore] for messages and message lists. -mixin MessageStore { +mixin MessageStore on ChannelStore { /// All known messages, indexed by [Message.id]. Map get messages; + /// [OutboxMessage]s sent by the user, indexed by [OutboxMessage.localMessageId]. + Map get outboxMessages; + Set get debugMessageListViews; void registerMessageList(MessageListView view); void unregisterMessageList(MessageListView view); - /// Reconcile a batch of just-fetched messages with the store, - /// mutating the list. + void markReadFromScroll(Iterable messageIds); + + Future sendMessage({ + required MessageDestination destination, + required String content, + }); + + /// Remove from [outboxMessages] given the [localMessageId], and return + /// the removed [OutboxMessage]. + /// + /// The outbox message to be taken must exist. + /// + /// The state of the outbox message must be either [OutboxMessageState.failed] + /// or [OutboxMessageState.waitPeriodExpired]. + OutboxMessage takeOutboxMessage(int localMessageId); + + /// Whether the current edit request for the given message, if any, has failed. /// - /// This is called after a [getMessages] request to report the result - /// to the store. + /// Will be null if there is no current edit request. + /// Will be false if the current request hasn't failed + /// and the update-message event hasn't arrived. + bool? getEditMessageErrorStatus(int messageId); + + /// Makes an edit-message request and starts an edit-outbox lifecycle. + /// + /// Should only be called when there is no current edit request for [messageId], + /// i.e., [getEditMessageErrorStatus] returns null for [messageId]. + /// + /// The returned [Future] settles when the edit-message response is received. + /// The [Future] resolves if the request succeeded and rejects if it failed, + /// unless the event already arrived or the message was deleted, + /// in which case it resolves. + /// + /// See also: + /// * [getEditMessageErrorStatus] + /// * [takeFailedMessageEdit] + Future editMessage({ + required int messageId, + required String originalRawContent, + required String newContent, + }); + + /// Forgets the failed edit request and returns the attempted new content. + /// + /// Should only be called when there is a failed request, + /// per [getEditMessageErrorStatus]. + ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId); + + /// Whether the user has permission to delete a message, as of [atDate]. /// - /// The list's length will not change, but some entries may be replaced - /// by a different [Message] object with the same [Message.id]. - /// All [Message] objects in the resulting list will be present in - /// [this.messages]. - void reconcileMessages(List messages); + /// For a value of [atDate], use [ZulipBinding.instance.utcNow]. + bool selfCanDeleteMessage(int messageId, {required DateTime atDate}) { + // Compare web's message_delete.get_deletability. + + final message = messages[messageId]; + if (message == null) { + assert(false); // TODO(log) + return true; + } + + final ZulipStream? channel; + if (message is StreamMessage) { + channel = streams[message.streamId]; + if (channel == null) { + assert(false); // TODO(log) + return true; + } + } else { + channel = null; + } + + if (channel != null && channel.isArchived) { + return false; + } + + // TODO(#1850) really the default should be `role:administrators`: + // https://github.com/zulip/zulip-flutter/pull/1842#discussion_r2331362461 + if (realmCanDeleteAnyMessageGroup != null + && selfHasPermissionForGroupSetting(realmCanDeleteAnyMessageGroup!, + GroupSettingType.realm, 'can_delete_any_message_group')) { + return true; + } + + if (channel != null) { + if (channel.canDeleteAnyMessageGroup != null + && selfHasPermissionForGroupSetting(channel.canDeleteAnyMessageGroup!, + GroupSettingType.stream, 'can_delete_any_message_group')) { + return true; + } + } + + final sender = getUser(message.senderId); + if (sender == null) return false; + + if (!( + sender.userId == selfUserId + || (sender.isBot && sender.botOwnerId == selfUserId) + )) { + return false; + } + + // Web returns false here for local-echoed message objects; + // that's impossible here because `message` can't be an [OutboxMessage] + // (it's a [Message] from [MessageStore.messages]). + + if (realmCanDeleteOwnMessageGroup != null) { + if (!selfHasPermissionForGroupSetting(realmCanDeleteOwnMessageGroup!, + GroupSettingType.realm, 'can_delete_own_message_group')) { + if (channel == null) { + // i.e. this is a DM + return false; + } + + if ( + channel.canDeleteOwnMessageGroup == null + || !selfHasPermissionForGroupSetting(channel.canDeleteOwnMessageGroup!, + GroupSettingType.stream, 'can_delete_own_message_group') + ) { + return false; + } + } + } else if (realmDeleteOwnMessagePolicy != null) { + if (!_selfPassesLegacyDeleteMessagePolicy(messageId, atDate: atDate)) { + return false; + } + } else { + assert(false); // TODO(log) + return true; + } + + if (realmMessageContentDeleteLimitSeconds == null) { + // i.e., no limit + return true; + } + return atDate.millisecondsSinceEpoch ~/ 1000 - message.timestamp + <= realmMessageContentDeleteLimitSeconds!; + } + + bool _selfPassesLegacyDeleteMessagePolicy(int messageId, {required DateTime atDate}) { + assert(realmDeleteOwnMessagePolicy != null); + final role = selfUser.role; + + // (Could early-return true on [UserRole.unknown], + // but pre-291 servers shouldn't be giving us an unknown role.) + + switch (realmDeleteOwnMessagePolicy!) { + case RealmDeleteOwnMessagePolicy.everyone: + return true; + case RealmDeleteOwnMessagePolicy.members: + return role.isAtLeast(UserRole.member); + case RealmDeleteOwnMessagePolicy.fullMembers: { + if (!role.isAtLeast(UserRole.member)) return false; + if (role == UserRole.member) { + return hasPassedWaitingPeriod(selfUser, byDate: atDate); + } + return true; + } + case RealmDeleteOwnMessagePolicy.moderators: + return role.isAtLeast(UserRole.moderator); + case RealmDeleteOwnMessagePolicy.admins: + return role.isAtLeast(UserRole.administrator); + } + } +} + +mixin ProxyMessageStore on MessageStore { + @protected + MessageStore get messageStore; + + @override + Map get messages => messageStore.messages; + @override + Map get outboxMessages => messageStore.outboxMessages; + @override + void registerMessageList(MessageListView view) => + messageStore.registerMessageList(view); + @override + void unregisterMessageList(MessageListView view) => + messageStore.unregisterMessageList(view); + @override + void markReadFromScroll(Iterable messageIds) => + messageStore.markReadFromScroll(messageIds); + @override + Future sendMessage({required MessageDestination destination, required String content}) { + return messageStore.sendMessage(destination: destination, content: content); + } + @override + OutboxMessage takeOutboxMessage(int localMessageId) => + messageStore.takeOutboxMessage(localMessageId); + + @override + bool? getEditMessageErrorStatus(int messageId) { + return messageStore.getEditMessageErrorStatus(messageId); + } + @override + Future editMessage({ + required int messageId, + required String originalRawContent, + required String newContent, + }) { + return messageStore.editMessage(messageId: messageId, + originalRawContent: originalRawContent, newContent: newContent); + } + @override + ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId) { + return messageStore.takeFailedMessageEdit(messageId); + } + + @override + Set get debugMessageListViews => messageStore.debugMessageListViews; +} + +class _EditMessageRequestStatus { + _EditMessageRequestStatus({ + required this.hasError, + required this.originalRawContent, + required this.newContent, + }); + + bool hasError; + final String originalRawContent; + final String newContent; } -class MessageStoreImpl with MessageStore { - MessageStoreImpl() - // There are no messages in InitialSnapshot, so we don't have - // a use case for initializing MessageStore with nonempty [messages]. - : messages = {}; +class MessageStoreImpl extends HasChannelStore with MessageStore, _OutboxMessageStore { + MessageStoreImpl({required super.channels}) + : // There are no messages in InitialSnapshot, so we don't have + // a use case for initializing MessageStore with nonempty [messages]. + messages = {}; @override final Map messages; + @override final Set _messageListViews = {}; @override @@ -44,33 +273,137 @@ class MessageStoreImpl with MessageStore { @override void registerMessageList(MessageListView view) { + assert(!_disposed); final added = _messageListViews.add(view); assert(added); } @override void unregisterMessageList(MessageListView view) { + // TODO: Add `assert(!_disposed);` here once we ensure [PerAccountStore] is + // only disposed after [MessageListView]s with references to it are + // disposed. See [dispose] for details. final removed = _messageListViews.remove(view); assert(removed); } + void _notifyMessageListViewsForOneMessage(int messageId) { + for (final view in _messageListViews) { + view.notifyListenersIfMessagePresent(messageId); + } + } + + void _notifyMessageListViews(Iterable messageIds) { + for (final view in _messageListViews) { + view.notifyListenersIfAnyMessagePresent(messageIds); + } + } + void reassemble() { for (final view in _messageListViews) { view.reassemble(); } } + @override + bool _disposed = false; + void dispose() { - // When a MessageListView is disposed, it removes itself from the Set - // `MessageStoreImpl._messageListViews`. Instead of iterating on that Set, - // iterate on a copy, to avoid concurrent modifications. - for (final view in _messageListViews.toList()) { - view.dispose(); + // Not disposing the [MessageListView]s here, because they are owned by + // (i.e., they get [dispose]d by) the [_MessageListState], including in the + // case where the [PerAccountStore] is replaced. + // + // TODO: Add assertions that the [MessageListView]s are indeed disposed, by + // first ensuring that [PerAccountStore] is only disposed after those with + // references to it are disposed, then reinstating this `dispose` method. + // + // We can't add the assertions as-is because the sequence of events + // guarantees that `PerAccountStore` is disposed (when that happens, + // [GlobalStore] notifies its listeners, causing widgets dependent on the + // [InheritedNotifier] to rebuild in the next frame) before the owner's + // `dispose` or `onNewStore` is called. Discussion: + // https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2086893 + + assert(!_disposed); + _disposeOutboxMessages(); + _disposed = true; + } + + static const _markReadOnScrollBatchSize = 1000; + static const _markReadOnScrollDebounceDuration = Duration(milliseconds: 500); + final _markReadOnScrollQueue = _MarkReadOnScrollQueue(); + bool _markReadOnScrollBusy = false; + + /// Returns true on success, false on failure. + Future _sendMarkReadOnScrollRequest(List toSend) async { + assert(toSend.isNotEmpty); + + // TODO(#1581) mark as read locally for latency compensation + // (in Unreads and on the message objects) + try { + await updateMessageFlags(connection, + messages: toSend, + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read); + } on ApiRequestException { + // TODO(#1581) un-mark as read locally? + return false; } + return true; } @override + void markReadFromScroll(Iterable messageIds) async { + assert(!_disposed); + _markReadOnScrollQueue.addAll(messageIds); + if (_markReadOnScrollBusy) return; + + _markReadOnScrollBusy = true; + try { + do { + final toSend = []; + int numFromQueue = 0; + for (final messageId in _markReadOnScrollQueue.iterable) { + if (toSend.length == _markReadOnScrollBatchSize) { + break; + } + final message = messages[messageId]; + if (message != null && !message.flags.contains(MessageFlag.read)) { + toSend.add(message.id); + } + numFromQueue++; + } + + if (toSend.isEmpty || await _sendMarkReadOnScrollRequest(toSend)) { + if (_disposed) return; + _markReadOnScrollQueue.removeFirstN(numFromQueue); + } + if (_disposed) return; + + await Future.delayed(_markReadOnScrollDebounceDuration); + if (_disposed) return; + } while (_markReadOnScrollQueue.isNotEmpty); + } finally { + if (!_disposed) { + _markReadOnScrollBusy = false; + } + } + } + + @override + Future sendMessage({required MessageDestination destination, required String content}) { + assert(!_disposed); + if (!debugOutboxEnable) { + return _apiSendMessage(connection, + destination: destination, + content: content, + readBySender: true); + } + return _outboxSendMessage(destination: destination, content: content); + } + void reconcileMessages(List messages) { + assert(!_disposed); // What to do when some of the just-fetched messages are already known? // This is common and normal: in particular it happens when one message list // overlaps another, e.g. a stream and a topic within it. @@ -88,8 +421,76 @@ class MessageStoreImpl with MessageStore { // those events' changes. So we always stick with the version we have. for (int i = 0; i < messages.length; i++) { final message = messages[i]; - messages[i] = this.messages.putIfAbsent(message.id, () => message); + messages[i] = this.messages.putIfAbsent(message.id, () { + message.matchContent = null; + message.matchTopic = null; + return message; + }); + } + } + + @override + bool? getEditMessageErrorStatus(int messageId) { + assert(!_disposed); + return _editMessageRequests[messageId]?.hasError; + } + + final Map _editMessageRequests = {}; + + @override + Future editMessage({ + required int messageId, + required String originalRawContent, + required String newContent, + }) async { + assert(!_disposed); + if (_editMessageRequests.containsKey(messageId)) { + throw StateError('an edit request is already in progress'); + } + + _editMessageRequests[messageId] = _EditMessageRequestStatus( + hasError: false, originalRawContent: originalRawContent, newContent: newContent); + _notifyMessageListViewsForOneMessage(messageId); + try { + await updateMessage(connection, + messageId: messageId, + content: newContent, + prevContentSha256: sha256.convert(utf8.encode(originalRawContent)).toString()); + // On success, we'll clear the status from _editMessageRequests + // when we get the event. + } catch (e) { + // TODO(log) if e is something unexpected + + if (_disposed) return; + + final status = _editMessageRequests[messageId]; + if (status == null) { + // The event actually arrived before this request failed + // (can happen with network issues). + // Or, the message was deleted. + return; + } + status.hasError = true; + _notifyMessageListViewsForOneMessage(messageId); + rethrow; + } + } + + @override + ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId) { + assert(!_disposed); + final status = _editMessageRequests.remove(messageId); + _notifyMessageListViewsForOneMessage(messageId); + if (status == null) { + throw StateError('called takeFailedMessageEdit, but no edit'); + } + if (!status.hasError) { + throw StateError("called takeFailedMessageEdit, but edit hasn't failed"); } + return ( + originalRawContent: status.originalRawContent, + newContent: status.newContent + ); } void handleUserTopicEvent(UserTopicEvent event) { @@ -98,12 +499,20 @@ class MessageStoreImpl with MessageStore { } } + void handleMutedUsersEvent(MutedUsersEvent event) { + for (final view in _messageListViews) { + view.handleMutedUsersEvent(event); + } + } + void handleMessageEvent(MessageEvent event) { // If the message is one we already know about (from a fetch), // clobber it with the one from the event system. // See [fetchedMessages] for reasoning. messages[event.message.id] = event.message; + _handleMessageEventOutbox(event); + for (final view in _messageListViews) { view.handleMessageEvent(event); } @@ -114,15 +523,11 @@ class MessageStoreImpl with MessageStore { _handleUpdateMessageEventTimestamp(event); _handleUpdateMessageEventContent(event); _handleUpdateMessageEventMove(event); - for (final view in _messageListViews) { - view.notifyListenersIfAnyMessagePresent(event.messageIds); - } + _notifyMessageListViews(event.messageIds); } void _handleUpdateMessageEventTimestamp(UpdateMessageEvent event) { - // TODO(server-5): Cut this fallback; rely on renderingOnly from FL 114 - final isRenderingOnly = event.renderingOnly ?? (event.userId == null); - if (event.editTimestamp == null || isRenderingOnly) { + if (event.renderingOnly) { // A rendering-only update gets omitted from the message edit history, // and [Message.lastEditTimestamp] is the last timestamp of that history. // So on a rendering-only update, the timestamp doesn't get updated. @@ -145,6 +550,12 @@ class MessageStoreImpl with MessageStore { // The message is guaranteed to be edited. // See also: https://zulip.com/api/get-events#update_message message.editState = MessageEditState.edited; + + // Clear the edit-message progress feedback. + // This makes a rare bug where we might clear the feedback too early, + // if the user raced with themself to edit the same message + // from multiple clients. + _editMessageRequests.remove(message.id); } if (event.renderedContent != null) { assert(message.contentType == 'text/html', @@ -161,48 +572,17 @@ class MessageStoreImpl with MessageStore { } void _handleUpdateMessageEventMove(UpdateMessageEvent event) { - // The interaction between the fields of these events are a bit tricky. - // For reference, see: https://zulip.com/api/get-events#update_message - - final origStreamId = event.origStreamId; - final newStreamId = event.newStreamId; // null if topic-only move - final origTopic = event.origTopic; - final newTopic = event.newTopic; - final propagateMode = event.propagateMode; - - if (origTopic == null) { + final messageMove = event.moveData; + if (messageMove == null) { // There was no move. - assert(() { - if (newStreamId != null && origStreamId != null - && newStreamId != origStreamId) { - // This should be impossible; `orig_subject` (aka origTopic) is - // documented to be present when either the stream or topic changed. - debugLog('Malformed UpdateMessageEvent: stream move but no origTopic'); // TODO(log) - } - return true; - }()); return; } - if (newStreamId == null && newTopic == null) { - // If neither the channel nor topic name changed, nothing moved. - // In that case `orig_subject` (aka origTopic) should have been null. - assert(debugLog('Malformed UpdateMessageEvent: move but no newStreamId or newTopic')); // TODO(log) - return; - } - if (origStreamId == null) { - // The `stream_id` field (aka origStreamId) is documented to be present on moves. - assert(debugLog('Malformed UpdateMessageEvent: move but no origStreamId')); // TODO(log) - return; - } - if (propagateMode == null) { - // The `propagate_mode` field (aka propagateMode) is documented to be present on moves. - assert(debugLog('Malformed UpdateMessageEvent: move but no propagateMode')); // TODO(log) - return; - } + final UpdateMessageMoveData( + :origStreamId, :newStreamId, :origTopic, :newTopic) = messageMove; - final wasResolveOrUnresolve = (newStreamId == null - && MessageEditState.topicMoveWasResolveOrUnresolve(origTopic, newTopic!)); + final wasResolveOrUnresolve = newStreamId == origStreamId + && MessageEditState.topicMoveWasResolveOrUnresolve(origTopic, newTopic); for (final messageId in event.messageIds) { final message = messages[messageId]; @@ -213,15 +593,15 @@ class MessageStoreImpl with MessageStore { continue; } - if (newStreamId != null) { - message.streamId = newStreamId; - // See [StreamMessage.displayRecipient] on why the invalidation is + if (newStreamId != origStreamId) { + message.conversation.streamId = newStreamId; + // See [StreamConversation.displayRecipient] on why the invalidation is // needed. - message.displayRecipient = null; + message.conversation.displayRecipient = null; } - if (newTopic != null) { - message.topic = newTopic; + if (newTopic != origTopic) { + message.conversation.topic = newTopic; } if (!wasResolveOrUnresolve @@ -230,21 +610,17 @@ class MessageStoreImpl with MessageStore { } } + // TODO predict outbox message moves using propagateMode + for (final view in _messageListViews) { - view.messagesMoved( - origStreamId: origStreamId, - newStreamId: newStreamId ?? origStreamId, - origTopic: origTopic, - newTopic: newTopic ?? origTopic, - messageIds: event.messageIds, - propagateMode: propagateMode, - ); + view.messagesMoved(messageMove: messageMove, messageIds: event.messageIds); } } void handleDeleteMessageEvent(DeleteMessageEvent event) { for (final messageId in event.messageIds) { messages.remove(messageId); + _editMessageRequests.remove(messageId); } for (final view in _messageListViews) { view.handleDeleteMessageEvent(event); @@ -278,17 +654,15 @@ class MessageStoreImpl with MessageStore { : message.flags.remove(event.flag); } if (anyMessageFound) { - for (final view in _messageListViews) { - view.notifyListenersIfAnyMessagePresent(event.messages); - // TODO(#818): Support MentionsNarrow live-updates when handling - // @-mention flags. - - // To make it easier to re-star a message, we opt-out from supporting - // live-updates when starred flag is removed. - // - // TODO: Support StarredMessagesNarrow live-updates when starred flag - // is added. - } + // TODO(#818): Support MentionsNarrow live-updates when handling + // @-mention flags. + + // To make it easier to re-star a message, we opt-out from supporting + // live-updates when starred flag is removed. + // + // TODO: Support StarredMessagesNarrow live-updates when starred flag + // is added. + _notifyMessageListViews(event.messages); } } } @@ -315,10 +689,7 @@ class MessageStoreImpl with MessageStore { userId: event.userId, ); } - - for (final view in _messageListViews) { - view.notifyListenersIfMessagePresent(event.messageId); - } + _notifyMessageListViewsForOneMessage(event.messageId); } void handleSubmessageEvent(SubmessageEvent event) { @@ -335,4 +706,436 @@ class MessageStoreImpl with MessageStore { // [Poll] is responsible for notifying the affected listeners. poll.handleSubmessageEvent(event); } + + /// In debug mode, controls whether outbox messages should be created when + /// [sendMessage] is called. + /// + /// Outside of debug mode, this is always true and the setter has no effect. + static bool get debugOutboxEnable { + bool result = true; + assert(() { + result = _debugOutboxEnable; + return true; + }()); + return result; + } + static bool _debugOutboxEnable = true; + static set debugOutboxEnable(bool value) { + assert(() { + _debugOutboxEnable = value; + return true; + }()); + } + + @visibleForTesting + static void debugReset() { + _debugOutboxEnable = true; + } +} + +class _MarkReadOnScrollQueue { + _MarkReadOnScrollQueue(); + + bool get isNotEmpty => _queue.isNotEmpty; + + final _set = {}; + final _queue = QueueList(); + + /// Add [messageIds] to the end of the queue, + /// if they aren't already in the queue. + void addAll(Iterable messageIds) { + for (final messageId in messageIds) { + if (_set.add(messageId)) { + _queue.add(messageId); + } + } + } + + Iterable get iterable => _queue; + + void removeFirstN(int n) { + for (int i = 0; i < n; i++) { + if (_queue.isEmpty) break; + _set.remove(_queue.removeFirst()); + } + } +} + +/// The duration an outbox message stays hidden to the user. +/// +/// See [OutboxMessageState.waiting]. +const kLocalEchoDebounceDuration = Duration(milliseconds: 500); // TODO(#1441) find the right value for this + +/// The duration before an outbox message can be restored for resending, since +/// its creation. +/// +/// See [OutboxMessageState.waitPeriodExpired]. +const kSendMessageOfferRestoreWaitPeriod = Duration(seconds: 10); // TODO(#1441) find the right value for this + +/// States of an [OutboxMessage] since its creation from a +/// [MessageStore.sendMessage] call and before its eventual deletion. +/// +/// ``` +/// Got an [ApiRequestException]. +/// ┌──────┬────────────────────────────┬──────────► failed +/// │ │ │ │ +/// │ │ [sendMessage] │ │ +/// (create) │ │ request succeeds. │ │ +/// └► hidden waiting ◄─────────────── waitPeriodExpired ──┴─────► (delete) +/// │ ▲ │ ▲ User restores +/// └──────┘ └─────────────────────┘ the draft. +/// Debounce [sendMessage] request +/// timed out. not finished when +/// wait period timed out. +/// +/// Event received. +/// (any state) ─────────────────► (delete) +/// ``` +/// +/// During its lifecycle, it is guaranteed that the outbox message is deleted +/// as soon a message event with a matching [MessageEvent.localMessageId] +/// arrives. +enum OutboxMessageState { + /// The [sendMessage] HTTP request has started but the resulting + /// [MessageEvent] hasn't arrived, and nor has the request failed. In this + /// state, the outbox message is hidden to the user. + /// + /// This is the initial state when an [OutboxMessage] is created. + hidden, + + /// The [sendMessage] HTTP request has started but hasn't finished, and the + /// outbox message is shown to the user. + /// + /// This state can be reached after staying in [hidden] for + /// [kLocalEchoDebounceDuration], or when the request succeeds after the + /// outbox message reaches [OutboxMessageState.waitPeriodExpired]. + waiting, + + /// The [sendMessage] HTTP request did not finish in time and the user is + /// invited to retry it. + /// + /// This state can be reached when the request has not finished + /// [kSendMessageOfferRestoreWaitPeriod] since the outbox message's creation. + waitPeriodExpired, + + /// The message could not be delivered, and the user is invited to retry it. + /// + /// This state can be reached when we got an [ApiRequestException] from the + /// [sendMessage] HTTP request. + failed, +} + +/// An outstanding request to send a message, aka an outbox-message. +/// +/// This will be shown in the UI in the message list, as a placeholder +/// for the actual [Message] the request is anticipated to produce. +/// +/// A request remains "outstanding" even after the [sendMessage] HTTP request +/// completes, whether with success or failure. +/// The outbox-message persists until either the corresponding [MessageEvent] +/// arrives to replace it, or the user discards it (perhaps to try again). +/// For details, see the state diagram at [OutboxMessageState], +/// and [MessageStore.takeOutboxMessage]. +sealed class OutboxMessage extends MessageBase { + OutboxMessage({ + required this.localMessageId, + required int selfUserId, + required super.timestamp, + required this.contentMarkdown, + }) : _state = OutboxMessageState.hidden, + super(senderId: selfUserId); + + // TODO(dart): This has to be a plain static method, because factories/constructors + // do not support type parameters: https://github.com/dart-lang/language/issues/647 + static OutboxMessage fromConversation(Conversation conversation, { + required int localMessageId, + required int selfUserId, + required int timestamp, + required String contentMarkdown, + }) { + return switch (conversation) { + StreamConversation() => StreamOutboxMessage._( + localMessageId: localMessageId, + selfUserId: selfUserId, + timestamp: timestamp, + conversation: conversation, + contentMarkdown: contentMarkdown), + DmConversation() => DmOutboxMessage._( + localMessageId: localMessageId, + selfUserId: selfUserId, + timestamp: timestamp, + conversation: conversation, + contentMarkdown: contentMarkdown), + }; + } + + /// As in [MessageEvent.localMessageId]. + /// + /// This uniquely identifies this outbox message's corresponding message object + /// in events from the same event queue. + /// + /// See also: + /// * [MessageStoreImpl.sendMessage], where this ID is assigned. + final int localMessageId; + + @override + int? get id => null; + + final String contentMarkdown; + + OutboxMessageState get state => _state; + OutboxMessageState _state; + + /// Whether the [OutboxMessage] is hidden to [MessageListView] or not. + bool get hidden => state == OutboxMessageState.hidden; +} + +class StreamOutboxMessage extends OutboxMessage { + StreamOutboxMessage._({ + required super.localMessageId, + required super.selfUserId, + required super.timestamp, + required this.conversation, + required super.contentMarkdown, + }); + + @override + final StreamConversation conversation; +} + +class DmOutboxMessage extends OutboxMessage { + DmOutboxMessage._({ + required super.localMessageId, + required super.selfUserId, + required super.timestamp, + required this.conversation, + required super.contentMarkdown, + }) : assert(conversation.allRecipientIds.contains(selfUserId)); + + @override + final DmConversation conversation; +} + +/// Manages the outbox messages portion of [MessageStore]. +mixin _OutboxMessageStore on HasRealmStore { + late final UnmodifiableMapView outboxMessages = + UnmodifiableMapView(_outboxMessages); + final Map _outboxMessages = {}; + + /// A map of timers to show outbox messages after a delay, + /// indexed by [OutboxMessage.localMessageId]. + /// + /// If the send message request fails within the time limit, + /// the outbox message's timer gets removed and cancelled. + final Map _outboxMessageDebounceTimers = {}; + + /// A map of timers to update outbox messages state to + /// [OutboxMessageState.waitPeriodExpired] if the [sendMessage] + /// request did not complete in time, + /// indexed by [OutboxMessage.localMessageId]. + /// + /// If the send message request completes within the time limit, + /// the outbox message's timer gets removed and cancelled. + final Map _outboxMessageWaitPeriodTimers = {}; + + /// A fresh ID to use for [OutboxMessage.localMessageId], + /// unique within this instance. + int _nextLocalMessageId = 1; + + /// As in [MessageStoreImpl._messageListViews]. + Set get _messageListViews; + + /// As in [MessageStoreImpl._disposed]. + bool get _disposed; + + /// Update the state of the [OutboxMessage] with the given [localMessageId], + /// and notify listeners if necessary. + /// + /// The outbox message with [localMessageId] must exist. + void _updateOutboxMessage(int localMessageId, { + required OutboxMessageState newState, + }) { + assert(!_disposed); + final outboxMessage = outboxMessages[localMessageId]; + if (outboxMessage == null) { + throw StateError( + 'Removing unknown outbox message with localMessageId: $localMessageId'); + } + final oldState = outboxMessage.state; + // See [OutboxMessageState] for valid state transitions. + final isStateTransitionValid = switch (newState) { + OutboxMessageState.hidden => false, + OutboxMessageState.waiting => + oldState == OutboxMessageState.hidden + || oldState == OutboxMessageState.waitPeriodExpired, + OutboxMessageState.waitPeriodExpired => + oldState == OutboxMessageState.waiting, + OutboxMessageState.failed => + oldState == OutboxMessageState.hidden + || oldState == OutboxMessageState.waiting + || oldState == OutboxMessageState.waitPeriodExpired, + }; + if (!isStateTransitionValid) { + throw StateError('Unexpected state transition: $oldState -> $newState'); + } + + outboxMessage._state = newState; + for (final view in _messageListViews) { + if (oldState == OutboxMessageState.hidden) { + view.addOutboxMessage(outboxMessage); + } else { + view.notifyListenersIfOutboxMessagePresent(localMessageId); + } + } + } + + /// Send a message and create an entry of [OutboxMessage]. + Future _outboxSendMessage({ + required MessageDestination destination, + required String content, + }) async { + assert(!_disposed); + final localMessageId = _nextLocalMessageId++; + assert(!outboxMessages.containsKey(localMessageId)); + + final conversation = switch (destination) { + StreamDestination(:final streamId, :final topic) => + StreamConversation( + streamId, + _processTopicLikeServer(topic), + displayRecipient: null), + DmDestination(:final userIds) => DmConversation(allRecipientIds: userIds), + }; + + _outboxMessages[localMessageId] = OutboxMessage.fromConversation( + conversation, + localMessageId: localMessageId, + selfUserId: selfUserId, + timestamp: ZulipBinding.instance.utcNow().millisecondsSinceEpoch ~/ 1000, + contentMarkdown: content); + + _outboxMessageDebounceTimers[localMessageId] = Timer( + kLocalEchoDebounceDuration, + () => _handleOutboxDebounce(localMessageId)); + + _outboxMessageWaitPeriodTimers[localMessageId] = Timer( + kSendMessageOfferRestoreWaitPeriod, + () => _handleOutboxWaitPeriodExpired(localMessageId)); + + try { + await _apiSendMessage(connection, + destination: destination, + content: content, + readBySender: true, + queueId: queueId, + localId: localMessageId.toString()); + } catch (e) { + if (_disposed) return; + if (!_outboxMessages.containsKey(localMessageId)) { + // The message event already arrived; the failure is probably due to + // networking issues. Don't rethrow; the send succeeded + // (we got the event) so we don't want to show an error dialog. + return; + } + _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.failed); + rethrow; + } + if (_disposed) return; + if (!_outboxMessages.containsKey(localMessageId)) { + // The message event already arrived; nothing to do. + return; + } + // The send request succeeded, so the message was definitely sent. + // Cancel the timer that would have had us start presuming that the + // send might have failed. + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + if (_outboxMessages[localMessageId]!.state + == OutboxMessageState.waitPeriodExpired) { + // The user was offered to restore the message since the request did not + // complete for a while. Since the request was successful, we expect the + // message event to arrive eventually. Stop inviting the the user to + // retry, to avoid double-sends. + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.waiting); + } + } + + TopicName _processTopicLikeServer(TopicName topic) { + // Processing this just once on creating the outbox message + // allows an uncommon bug, because either of the values + // [zulipFeatureLevel] or [realmEmptyTopicDisplayName] can change. + // During the outbox message's life, a topic processed from + // "(no topic)" could become stale/wrong when zulipFeatureLevel + // changes; a topic processed from "general chat" could become + // stale/wrong when realmEmptyTopicDisplayName changes. + // + // Shrug. The same effect is caused by an unavoidable race: + // an admin could change the name of "general chat" + // (i.e. the value of realmEmptyTopicDisplayName) + // concurrently with the user making the send request, + // so that the setting in effect by the time the request arrives + // is different from the setting the client last heard about. + return processTopicLikeServer(topic); + } + + void _handleOutboxDebounce(int localMessageId) { + assert(!_disposed); + assert(outboxMessages.containsKey(localMessageId), + 'The timer should have been canceled when the outbox message was removed.'); + _outboxMessageDebounceTimers.remove(localMessageId); + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.waiting); + } + + void _handleOutboxWaitPeriodExpired(int localMessageId) { + assert(!_disposed); + assert(outboxMessages.containsKey(localMessageId), + 'The timer should have been canceled when the outbox message was removed.'); + assert(!_outboxMessageDebounceTimers.containsKey(localMessageId), + 'The debounce timer should have been removed before the wait period timer expires.'); + _outboxMessageWaitPeriodTimers.remove(localMessageId); + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.waitPeriodExpired); + } + + OutboxMessage takeOutboxMessage(int localMessageId) { + assert(!_disposed); + final removed = _outboxMessages.remove(localMessageId); + _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + if (removed == null) { + throw StateError( + 'Removing unknown outbox message with localMessageId: $localMessageId'); + } + if (removed.state != OutboxMessageState.failed + && removed.state != OutboxMessageState.waitPeriodExpired + ) { + throw StateError('Unexpected state when restoring draft: ${removed.state}'); + } + for (final view in _messageListViews) { + view.removeOutboxMessage(removed); + } + return removed; + } + + void _handleMessageEventOutbox(MessageEvent event) { + if (event.localMessageId != null) { + final localMessageId = int.parse(event.localMessageId!, radix: 10); + // The outbox message can be missing if the user removes it before the + // event arrives. Nothing to do in that case. + _outboxMessages.remove(localMessageId); + _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + } + } + + /// Cancel [_OutboxMessageStore]'s timers. + void _disposeOutboxMessages() { + assert(!_disposed); + for (final timer in _outboxMessageDebounceTimers.values) { + timer.cancel(); + } + for (final timer in _outboxMessageWaitPeriodTimers.values) { + timer.cancel(); + } + } } diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 670785ac4e..fcb6943905 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -10,8 +10,12 @@ import '../api/route/messages.dart'; import 'algorithms.dart'; import 'channel.dart'; import 'content.dart'; +import 'message.dart'; import 'narrow.dart'; import 'store.dart'; +import 'user.dart'; + +export '../api/route/messages.dart' show Anchor, AnchorCode, NumericAnchor; /// The number of messages to fetch in each request. const kMessageListFetchBatchSize = 100; // TODO tune @@ -24,99 +28,156 @@ sealed class MessageListItem { } class MessageListRecipientHeaderItem extends MessageListItem { - final Message message; + final MessageBase message; MessageListRecipientHeaderItem(this.message); } class MessageListDateSeparatorItem extends MessageListItem { - final Message message; + final MessageBase message; MessageListDateSeparatorItem(this.message); } -/// A message to show in the message list. -class MessageListMessageItem extends MessageListItem { - final Message message; - ZulipMessageContent content; +/// A [MessageBase] to show in the message list. +sealed class MessageListMessageBaseItem extends MessageListItem { + MessageBase get message; + ZulipMessageContent get content; bool showSender; bool isLastInBlock; - MessageListMessageItem( - this.message, - this.content, { + MessageListMessageBaseItem({ required this.showSender, required this.isLastInBlock, }); } -/// Indicates the app is loading more messages at the top. -// TODO(#80): or loading at the bottom, by adding a [MessageListDirection.newer] -class MessageListLoadingItem extends MessageListItem { - final MessageListDirection direction; +/// A [Message] to show in the message list. +class MessageListMessageItem extends MessageListMessageBaseItem { + @override + final Message message; + @override + ZulipMessageContent content; - const MessageListLoadingItem(this.direction); + MessageListMessageItem( + this.message, + this.content, { + required super.showSender, + required super.isLastInBlock, + }); } -enum MessageListDirection { older } +/// An [OutboxMessage] to show in the message list. +class MessageListOutboxMessageItem extends MessageListMessageBaseItem { + @override + final OutboxMessage message; + @override + final ZulipContent content; + + MessageListOutboxMessageItem( + this.message, { + required super.showSender, + required super.isLastInBlock, + }) : content = ZulipContent(nodes: [ + ParagraphNode(links: null, nodes: [TextNode(message.contentMarkdown)]), + ]); +} + +/// The status of outstanding or recent fetch requests from a [MessageListView]. +enum FetchingStatus { + /// The model has not made any fetch requests (since its last reset, if any). + unstarted, + + /// The model has made a `fetchInitial` request, which hasn't succeeded. + fetchInitial, -/// Indicates we've reached the oldest message in the narrow. -class MessageListHistoryStartItem extends MessageListItem { - const MessageListHistoryStartItem(); + /// The model made a successful `fetchInitial` request, + /// and has no outstanding requests or backoff. + idle, + + /// The model has an active `fetchOlder` or `fetchNewer` request. + fetchingMore, + + /// The model is in a backoff period from a failed request. + backoff, } /// The sequence of messages in a message list, and how to display them. /// /// This comprises much of the guts of [MessageListView]. mixin _MessageSequence { + /// Whether each message should have its own recipient header, + /// even if it's in the same conversation as the previous message. + /// + /// In some message-list views, notably "Mentions" and "Starred", + /// it would be misleading to give the impression that consecutive messages + /// in the same conversation were sent one after the other + /// with no other messages in between. + /// By giving each message its own recipient header (a `true` value for this), + /// we intend to avoid giving that impression. + @visibleForTesting + bool get oneMessagePerBlock; + /// A sequence number for invalidating stale fetches. int generation = 0; - /// The messages. + /// The known messages in the list. + /// + /// This may or may not represent all the message history that + /// conceptually belongs in this message list. + /// That information is expressed in [fetched], [haveOldest], [haveNewest]. + /// + /// See also [middleMessage], an index which divides this list + /// into a top slice and a bottom slice. /// /// See also [contents] and [items]. final List messages = []; + /// An index into [messages] dividing it into a top slice and a bottom slice. + /// + /// The indices 0 to before [middleMessage] are the top slice of [messages], + /// and the indices from [middleMessage] to the end are the bottom slice. + /// + /// The corresponding item index is [middleItem]. + int middleMessage = 0; + /// Whether [messages] and [items] represent the results of a fetch. /// /// This allows the UI to distinguish "still working on fetching messages" /// from "there are in fact no messages here". - bool get fetched => _fetched; - bool _fetched = false; + bool get fetched => switch (_status) { + FetchingStatus.unstarted || FetchingStatus.fetchInitial => false, + _ => true, + }; /// Whether we know we have the oldest messages for this narrow. /// - /// (Currently we always have the newest messages for the narrow, - /// once [fetched] is true, because we start from the newest.) + /// See also [haveNewest]. bool get haveOldest => _haveOldest; bool _haveOldest = false; - /// Whether we are currently fetching the next batch of older messages. - /// - /// When this is true, [fetchOlder] is a no-op. - /// That method is called frequently by Flutter's scrolling logic, - /// and this field helps us avoid spamming the same request just to get - /// the same response each time. + /// Whether we know we have the newest messages for this narrow. /// - /// See also [fetchOlderCoolingDown]. - bool get fetchingOlder => _fetchingOlder; - bool _fetchingOlder = false; + /// See also [haveOldest]. + bool get haveNewest => _haveNewest; + bool _haveNewest = false; - /// Whether [fetchOlder] had a request error recently. - /// - /// When this is true, [fetchOlder] is a no-op. - /// That method is called frequently by Flutter's scrolling logic, - /// and this field mitigates spamming the same request and getting - /// the same error each time. - /// - /// "Recently" is decided by a [BackoffMachine] that resets - /// when a [fetchOlder] request succeeds. + /// Whether this message list is currently busy when it comes to + /// fetching more messages. /// - /// See also [fetchingOlder]. - bool get fetchOlderCoolingDown => _fetchOlderCoolingDown; - bool _fetchOlderCoolingDown = false; + /// Here "busy" means a new call to fetch more messages would do nothing, + /// rather than make any request to the server, + /// as a result of an existing recent request. + /// This is true both when the recent request is still outstanding, + /// and when it failed and the backoff from that is still in progress. + bool get busyFetchingMore => switch (_status) { + FetchingStatus.fetchingMore || FetchingStatus.backoff => true, + _ => false, + }; - BackoffMachine? _fetchOlderCooldownBackoffMachine; + FetchingStatus _status = FetchingStatus.unstarted; + + BackoffMachine? _fetchBackoffMachine; /// The parsed message contents, as a list parallel to [messages]. /// @@ -126,17 +187,45 @@ mixin _MessageSequence { /// It exists as an optimization, to memoize the work of parsing. final List contents = []; + /// The [OutboxMessage]s sent by the self-user, retrieved from + /// [MessageStore.outboxMessages]. + /// + /// See also [items]. + /// + /// O(N) iterations through this list are acceptable + /// because it won't normally have more than a few items. + final List outboxMessages = []; + /// The messages and their siblings in the UI, in order. /// /// This has a [MessageListMessageItem] corresponding to each element /// of [messages], in order. It may have additional items interspersed - /// before, between, or after the messages. + /// before, between, or after the messages. Then, similarly, + /// [MessageListOutboxMessageItem]s corresponding to [outboxMessages]. /// - /// This information is completely derived from [messages] and - /// the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown]. + /// This information is completely derived from [messages], [outboxMessages], + /// and the flags [haveOldest], [haveNewest], and [busyFetchingMore]. /// It exists as an optimization, to memoize that computation. + /// + /// See also [middleItem], an index which divides this list + /// into a top slice and a bottom slice. final QueueList items = QueueList(); + /// An index into [items] dividing it into a top slice and a bottom slice. + /// + /// The indices 0 to before [middleItem] are the top slice of [items], + /// and the indices from [middleItem] to the end are the bottom slice. + /// + /// The top slice of [items] corresponds to the top slice of [messages]. + /// The bottom slice of [items] corresponds to the bottom slice of [messages] + /// plus any [outboxMessages]. + /// + /// The bottom slice will either be empty + /// or start with a [MessageListMessageBaseItem]. + /// It will not start with a [MessageListDateSeparatorItem] + /// or a [MessageListRecipientHeaderItem]. + int middleItem = 0; + int _findMessageWithId(int messageId) { return binarySearchByKey(messages, messageId, (message, messageId) => message.id.compareTo(messageId)); @@ -146,30 +235,32 @@ mixin _MessageSequence { return binarySearchByKey(items, messageId, _compareItemToMessageId); } + Iterable? getMessagesRange(int firstMessageId, int lastMessageId) { + assert(firstMessageId <= lastMessageId); + final firstIndex = _findMessageWithId(firstMessageId); + final lastIndex = _findMessageWithId(lastMessageId); + if (firstIndex == -1 || lastIndex == -1) { + // TODO(log) + return null; + } + return messages.getRange(firstIndex, lastIndex + 1); + } + static int _compareItemToMessageId(MessageListItem item, int messageId) { switch (item) { - case MessageListHistoryStartItem(): return -1; - case MessageListLoadingItem(): - switch (item.direction) { - case MessageListDirection.older: return -1; - } case MessageListRecipientHeaderItem(:var message): case MessageListDateSeparatorItem(:var message): - return (message.id <= messageId) ? -1 : 1; + if (message.id == null) return 1; + return message.id! <= messageId ? -1 : 1; case MessageListMessageItem(:var message): return message.id.compareTo(messageId); + case MessageListOutboxMessageItem(): return 1; } } - ZulipMessageContent _parseMessageContent(Message message) { - final poll = message.poll; - if (poll != null) return PollContent(poll); - return parseContent(message.content); - } - /// Update data derived from the content of the index-th message. void _reparseContent(int index) { final message = messages[index]; - final content = _parseMessageContent(message); + final content = parseMessageContent(message); contents[index] = content; final itemIndex = findItemWithMessageId(message.id); @@ -186,7 +277,7 @@ mixin _MessageSequence { void _addMessage(Message message) { assert(contents.length == messages.length); messages.add(message); - contents.add(_parseMessageContent(message)); + contents.add(parseMessageContent(message)); assert(contents.length == messages.length); _processMessage(messages.length - 1); } @@ -208,6 +299,7 @@ mixin _MessageSequence { candidate++; assert(contents.length == messages.length); while (candidate < messages.length) { + if (candidate == middleMessage) middleMessage = target; if (test(messages[candidate])) { candidate++; continue; @@ -216,6 +308,7 @@ mixin _MessageSequence { contents[target] = contents[candidate]; target++; candidate++; } + if (candidate == middleMessage) middleMessage = target; messages.length = target; contents.length = target; assert(contents.length == messages.length); @@ -238,6 +331,13 @@ mixin _MessageSequence { } if (messagesToRemoveById.isEmpty) return false; + if (middleMessage == messages.length) { + middleMessage -= messagesToRemoveById.length; + } else { + final middleMessageId = messages[middleMessage].id; + middleMessage -= messagesToRemoveById + .where((id) => id < middleMessageId).length; + } assert(contents.length == messages.length); messages.removeWhere((message) => messagesToRemoveById.contains(message.id)); contents.removeWhere((content) => contentToRemove.contains(content)); @@ -252,128 +352,217 @@ mixin _MessageSequence { // On a Pixel 5, a batch of 100 messages takes ~15-20ms in _insertAllMessages. // (Before that, ~2-5ms in jsonDecode and 0ms in fromJson, // so skip worrying about those steps.) + final oldLength = messages.length; assert(contents.length == messages.length); messages.insertAll(index, toInsert); contents.insertAll(index, toInsert.map( - (message) => _parseMessageContent(message))); + (message) => parseMessageContent(message))); assert(contents.length == messages.length); + if (index <= middleMessage) { + middleMessage += messages.length - oldLength; + } _reprocessAll(); } + /// Append [outboxMessage] to [outboxMessages] and update derived data + /// accordingly. + /// + /// The caller is responsible for ensuring this is an appropriate thing to do + /// given [narrow] and other concerns. + void _addOutboxMessage(OutboxMessage outboxMessage) { + assert(haveNewest); + assert(!outboxMessages.contains(outboxMessage)); + outboxMessages.add(outboxMessage); + _processOutboxMessage(outboxMessages.length - 1); + } + + /// Remove the [outboxMessage] from the view. + /// + /// Returns true if the outbox message was removed, false otherwise. + bool _removeOutboxMessage(OutboxMessage outboxMessage) { + if (!outboxMessages.remove(outboxMessage)) { + return false; + } + _reprocessOutboxMessages(); + return true; + } + + /// Remove all outbox messages that satisfy [test] from [outboxMessages]. + /// + /// Returns true if any outbox messages were removed, false otherwise. + bool _removeOutboxMessagesWhere(bool Function(OutboxMessage) test) { + final count = outboxMessages.length; + outboxMessages.removeWhere(test); + if (outboxMessages.length == count) { + return false; + } + _reprocessOutboxMessages(); + return true; + } + /// Reset all [_MessageSequence] data, and cancel any active fetches. void _reset() { generation += 1; messages.clear(); - _fetched = false; + middleMessage = 0; + outboxMessages.clear(); _haveOldest = false; - _fetchingOlder = false; - _fetchOlderCoolingDown = false; - _fetchOlderCooldownBackoffMachine = null; + _haveNewest = false; + _status = FetchingStatus.unstarted; + _fetchBackoffMachine = null; contents.clear(); items.clear(); + middleItem = 0; } /// Redo all computations from scratch, based on [messages]. void _recompute() { assert(contents.length == messages.length); contents.clear(); - contents.addAll(messages.map((message) => _parseMessageContent(message))); + contents.addAll(messages.map((message) => parseMessageContent(message))); assert(contents.length == messages.length); _reprocessAll(); } - /// Append to [items] based on the index-th message and its content. + /// Append to [items] based on [message] and [prevMessage]. /// - /// The previous messages in the list must already have been processed. - /// This message must already have been parsed and reflected in [contents]. - void _processMessage(int index) { - // This will get more complicated to handle the ways that messages interact - // with the display of neighboring messages: sender headings #175 - // and date separators #173. - final message = messages[index]; - final content = contents[index]; - bool canShareSender; - if (index == 0 || !haveSameRecipient(messages[index - 1], message)) { + /// This appends a recipient header or a date separator to [items], + /// depending on how [prevMessage] relates to [message], + /// and then the result of [buildItem], updating [middleItem] if desired. + /// + /// See [middleItem] to determine the value of [shouldSetMiddleItem]. + /// + /// [prevMessage] should be the message that visually appears before [message]. + /// + /// The caller must ensure that [prevMessage] and all messages before it + /// have been processed. + void _addItemsForMessage(MessageBase message, { + required bool shouldSetMiddleItem, + required MessageBase? prevMessage, + required MessageListMessageBaseItem Function(bool canShareSender) buildItem, + }) { + final bool canShareSender; + if ( + prevMessage == null + || oneMessagePerBlock + || !haveSameRecipient(prevMessage, message) + ) { items.add(MessageListRecipientHeaderItem(message)); canShareSender = false; } else { - assert(items.last is MessageListMessageItem); - final prevMessageItem = items.last as MessageListMessageItem; - assert(identical(prevMessageItem.message, messages[index - 1])); + assert(items.last is MessageListMessageBaseItem); + final prevMessageItem = items.last as MessageListMessageBaseItem; + assert(identical(prevMessageItem.message, prevMessage)); assert(prevMessageItem.isLastInBlock); prevMessageItem.isLastInBlock = false; if (!messagesSameDay(prevMessageItem.message, message)) { items.add(MessageListDateSeparatorItem(message)); canShareSender = false; + } else if (prevMessageItem.message.senderId == message.senderId) { + canShareSender = messagesCloseInTime(prevMessage, message); } else { - canShareSender = (prevMessageItem.message.senderId == message.senderId); + canShareSender = false; } } - items.add(MessageListMessageItem(message, content, - showSender: !canShareSender, isLastInBlock: true)); + final item = buildItem(canShareSender); + assert(identical(item.message, message)); + assert(item.showSender == !canShareSender); + assert(item.isLastInBlock); + if (shouldSetMiddleItem) { + middleItem = items.length; + } + items.add(item); } - /// Update [items] to include markers at start and end as appropriate. - void _updateEndMarkers() { - assert(fetched); - assert(!(fetchingOlder && fetchOlderCoolingDown)); - final effectiveFetchingOlder = fetchingOlder || fetchOlderCoolingDown; - assert(!(effectiveFetchingOlder && haveOldest)); - final startMarker = switch ((effectiveFetchingOlder, haveOldest)) { - (true, _) => const MessageListLoadingItem(MessageListDirection.older), - (_, true) => const MessageListHistoryStartItem(), - (_, _) => null, - }; - final hasStartMarker = switch (items.firstOrNull) { - MessageListLoadingItem() => true, - MessageListHistoryStartItem() => true, - _ => false, - }; - switch ((startMarker != null, hasStartMarker)) { - case (true, true): items[0] = startMarker!; - case (true, _ ): items.addFirst(startMarker!); - case (_, true): items.removeFirst(); - case (_, _ ): break; - } - } - - /// Recompute [items] from scratch, based on [messages], [contents], and flags. + /// Append to [items] based on the index-th message and its content. + /// + /// The previous messages in the list must already have been processed. + /// This message must already have been parsed and reflected in [contents]. + void _processMessage(int index) { + assert(items.lastOrNull is! MessageListOutboxMessageItem); + final prevMessage = index == 0 ? null : messages[index - 1]; + final message = messages[index]; + final content = contents[index]; + + _addItemsForMessage(message, + shouldSetMiddleItem: index == middleMessage, + prevMessage: prevMessage, + buildItem: (bool canShareSender) => MessageListMessageItem( + message, content, showSender: !canShareSender, isLastInBlock: true)); + } + + /// Append to [items] based on the index-th message in [outboxMessages]. + /// + /// All [messages] and previous messages in [outboxMessages] must already have + /// been processed. + void _processOutboxMessage(int index) { + final prevMessage = index == 0 ? messages.lastOrNull + : outboxMessages[index - 1]; + final message = outboxMessages[index]; + + _addItemsForMessage(message, + // The first outbox message item becomes the middle item + // when the bottom slice of [messages] is empty. + shouldSetMiddleItem: index == 0 && middleMessage == messages.length, + prevMessage: prevMessage, + buildItem: (bool canShareSender) => MessageListOutboxMessageItem( + message, showSender: !canShareSender, isLastInBlock: true)); + } + + /// Remove items associated with [outboxMessages] from [items]. + /// + /// This is designed to be idempotent; repeated calls will not change the + /// content of [items]. + /// + /// This is efficient due to the expected small size of [outboxMessages]. + void _removeOutboxMessageItems() { + // This loop relies on the assumption that all items that follow + // the last [MessageListMessageItem] are derived from outbox messages. + while (items.isNotEmpty && items.last is! MessageListMessageItem) { + items.removeLast(); + } + + if (items.isNotEmpty) { + final lastItem = items.last as MessageListMessageItem; + lastItem.isLastInBlock = true; + } + if (middleMessage == messages.length) middleItem = items.length; + } + + /// Recompute the portion of [items] derived from outbox messages, + /// based on [outboxMessages] and [messages]. + /// + /// All [messages] should have been processed when this is called. + void _reprocessOutboxMessages() { + assert(haveNewest); + _removeOutboxMessageItems(); + for (var i = 0; i < outboxMessages.length; i++) { + _processOutboxMessage(i); + } + } + + /// Recompute [items] from scratch, based on [messages], [contents], + /// [outboxMessages] and flags. void _reprocessAll() { items.clear(); for (var i = 0; i < messages.length; i++) { _processMessage(i); } - _updateEndMarkers(); + if (middleMessage == messages.length) middleItem = items.length; + for (var i = 0; i < outboxMessages.length; i++) { + _processOutboxMessage(i); + } } } @visibleForTesting -bool haveSameRecipient(Message prevMessage, Message message) { - if (prevMessage is StreamMessage && message is StreamMessage) { - if (prevMessage.streamId != message.streamId) return false; - if (prevMessage.topic.canonicalize() != message.topic.canonicalize()) return false; - } else if (prevMessage is DmMessage && message is DmMessage) { - if (!_equalIdSequences(prevMessage.allRecipientIds, message.allRecipientIds)) { - return false; - } - } else { - return false; - } - return true; - - // switch ((prevMessage, message)) { - // case (StreamMessage(), StreamMessage()): - // // TODO(dart-3): this doesn't type-narrow prevMessage and message - // case (DmMessage(), DmMessage()): - // // … - // default: - // return false; - // } +bool haveSameRecipient(MessageBase prevMessage, MessageBase message) { + return prevMessage.conversation.isSameAs(message.conversation); } @visibleForTesting -bool messagesSameDay(Message prevMessage, Message message) { +bool messagesSameDay(MessageBase prevMessage, MessageBase message) { // TODO memoize [DateTime]s... also use memoized for showing date/time in msglist final prevTime = DateTime.fromMillisecondsSinceEpoch(prevMessage.timestamp * 1000); final time = DateTime.fromMillisecondsSinceEpoch(message.timestamp * 1000); @@ -381,14 +570,10 @@ bool messagesSameDay(Message prevMessage, Message message) { return true; } -// Intended for [Message.allRecipientIds]. Assumes efficient `length`. -bool _equalIdSequences(Iterable xs, Iterable ys) { - if (xs.length != ys.length) return false; - final xs_ = xs.iterator; final ys_ = ys.iterator; - while (xs_.moveNext() && ys_.moveNext()) { - if (xs_.current != ys_.current) return false; - } - return true; +@visibleForTesting +bool messagesCloseInTime(MessageBase prevMessage, MessageBase message) { + final diffSeconds = (message.timestamp - prevMessage.timestamp).abs(); + return diffSeconds <= 10 * 60; } bool _sameDay(DateTime date1, DateTime date2) { @@ -413,13 +598,51 @@ bool _sameDay(DateTime date1, DateTime date2) { /// * When the object will no longer be used, call [dispose] to free /// resources on the [PerAccountStore]. class MessageListView with ChangeNotifier, _MessageSequence { - MessageListView._({required this.store, required this.narrow}); + factory MessageListView.init({ + required PerAccountStore store, + required Narrow narrow, + required Anchor anchor, + }) { + return MessageListView._(store: store, narrow: narrow, anchor: anchor) + .._register(); + } + + MessageListView._({ + required this.store, + required Narrow narrow, + required Anchor anchor, + }) : _narrow = narrow, _anchor = anchor; + + final PerAccountStore store; + + /// The narrow shown in this message list. + /// + /// This can change over time, notably if showing a topic that gets moved, + /// or if [renarrowAndFetch] is called. + Narrow get narrow => _narrow; + Narrow _narrow; + + /// Set [narrow] to [newNarrow], reset, [notifyListeners], and [fetchInitial]. + void renarrowAndFetch(Narrow newNarrow) { + _narrow = newNarrow; + _reset(); + notifyListeners(); + fetchInitial(); + } - factory MessageListView.init( - {required PerAccountStore store, required Narrow narrow}) { - final view = MessageListView._(store: store, narrow: narrow); - store.registerMessageList(view); - return view; + /// The anchor point this message list starts from in the message history. + /// + /// This is passed to the server in the get-messages request + /// sent by [fetchInitial]. + /// That includes not only the original [fetchInitial] call made by + /// the message-list widget, but any additional [fetchInitial] calls + /// which might be made internally by this class in order to + /// fetch the messages from scratch, e.g. after certain events. + Anchor get anchor => _anchor; + Anchor _anchor; + + void _register() { + store.registerMessageList(this); } @override @@ -428,8 +651,15 @@ class MessageListView with ChangeNotifier, _MessageSequence { super.dispose(); } - final PerAccountStore store; - Narrow narrow; + @override bool get oneMessagePerBlock => switch (narrow) { + CombinedFeedNarrow() + || ChannelNarrow() + || TopicNarrow() + || DmNarrow() => false, + MentionsNarrow() + || StarredMessagesNarrow() + || KeywordSearchNarrow() => true, + }; /// Whether [message] should actually appear in this message list, /// given that it does belong to the narrow. @@ -438,102 +668,279 @@ class MessageListView with ChangeNotifier, _MessageSequence { /// one way or another. /// /// See also [_allMessagesVisible]. - bool _messageVisible(Message message) { + bool _messageVisible(MessageBase message) { switch (narrow) { case CombinedFeedNarrow(): - return switch (message) { - StreamMessage() => - store.isTopicVisible(message.streamId, message.topic), - DmMessage() => true, + final conversation = message.conversation; + return switch (conversation) { + StreamConversation(:final streamId, :final topic) => + store.isTopicVisible(streamId, topic), + DmConversation() => !store.shouldMuteDmConversation( + DmNarrow.ofConversation(conversation, selfUserId: store.selfUserId)), }; case ChannelNarrow(:final streamId): - assert(message is StreamMessage && message.streamId == streamId); - if (message is! StreamMessage) return false; - return store.isTopicVisibleInStream(streamId, message.topic); + assert(message is MessageBase + && message.conversation.streamId == streamId); + if (message is! MessageBase) return false; + return store.isTopicVisibleInStream(streamId, message.conversation.topic); case TopicNarrow(): case DmNarrow(): + return true; + case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + if (message.conversation case DmConversation(:final allRecipientIds)) { + return !store.shouldMuteDmConversation(DmNarrow( + allRecipientIds: allRecipientIds, selfUserId: store.selfUserId)); + } + return true; + } + } + + /// Whether [_messageVisible] is true for all possible messages. + /// + /// This is useful for an optimization. + bool get _allMessagesVisible { + switch (narrow) { + case CombinedFeedNarrow(): + case ChannelNarrow(): + return false; + + case TopicNarrow(): + case DmNarrow(): return true; + + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + return false; } } /// Whether this event could affect the result that [_messageVisible] /// would ever have returned for any possible message in this message list. - VisibilityEffect _canAffectVisibility(UserTopicEvent event) { + UserTopicVisibilityEffect _canAffectVisibility(UserTopicEvent event) { switch (narrow) { case CombinedFeedNarrow(): return store.willChangeIfTopicVisible(event); case ChannelNarrow(:final streamId): - if (event.streamId != streamId) return VisibilityEffect.none; + if (event.streamId != streamId) return UserTopicVisibilityEffect.none; return store.willChangeIfTopicVisibleInStream(event); case TopicNarrow(): case DmNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): - return VisibilityEffect.none; + case KeywordSearchNarrow(): + return UserTopicVisibilityEffect.none; } } - /// Whether [_messageVisible] is true for all possible messages. - /// - /// This is useful for an optimization. - bool get _allMessagesVisible { - switch (narrow) { + /// Whether this event could affect the result that [_messageVisible] + /// would ever have returned for any possible message in this message list. + MutedUsersVisibilityEffect _mutedUsersEventCanAffectVisibility(MutedUsersEvent event) { + switch(narrow) { case CombinedFeedNarrow(): - case ChannelNarrow(): - return false; + return store.mightChangeShouldMuteDmConversation(event); + case ChannelNarrow(): case TopicNarrow(): case DmNarrow(): + return MutedUsersVisibilityEffect.none; + case MentionsNarrow(): case StarredMessagesNarrow(): - return true; + case KeywordSearchNarrow(): + return store.mightChangeShouldMuteDmConversation(event); } } + void _setStatus(FetchingStatus value, {FetchingStatus? was}) { + assert(was == null || _status == was); + _status = value; + if (!fetched) return; + notifyListeners(); + } + /// Fetch messages, starting from scratch. Future fetchInitial() async { - // TODO(#80): fetch from anchor firstUnread, instead of newest - // TODO(#82): fetch from a given message ID as anchor - assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown); + assert(!fetched && !haveOldest && !haveNewest && !busyFetchingMore); assert(messages.isEmpty && contents.isEmpty); + + if (narrow case KeywordSearchNarrow(keyword: '')) { + // The server would reject an empty keyword search; skip the request. + // TODO this seems like an awkward layer to handle this at -- + // probably better if the UI code doesn't take it to this point. + _haveOldest = true; + _haveNewest = true; + _setStatus(FetchingStatus.idle, was: FetchingStatus.unstarted); + return; + } + + _setStatus(FetchingStatus.fetchInitial, was: FetchingStatus.unstarted); // TODO schedule all this in another isolate final generation = this.generation; final result = await getMessages(store.connection, narrow: narrow.apiEncode(), - anchor: AnchorCode.newest, + anchor: anchor, numBefore: kMessageListFetchBatchSize, - numAfter: 0, + numAfter: kMessageListFetchBatchSize, + allowEmptyTopicName: true, ); if (this.generation > generation) return; + + _adjustNarrowForTopicPermalink(result.messages.firstOrNull); + store.reconcileMessages(result.messages); store.recentSenders.handleMessages(result.messages); // TODO(#824) + + // The bottom slice will start at the "anchor message". + // This is the first visible message at or past [anchor] if any, + // else the last visible message if any. [reachedAnchor] helps track that. + bool reachedAnchor = false; for (final message in result.messages) { - if (_messageVisible(message)) { - _addMessage(message); + if (!_messageVisible(message)) continue; + if (!reachedAnchor) { + // Push the previous message into the top slice. + middleMessage = messages.length; + // We could interpret [anchor] for ourselves; but the server has already + // done that work, reducing it to an int, `result.anchor`. So use that. + reachedAnchor = message.id >= result.anchor; } + _addMessage(message); } - _fetched = true; _haveOldest = result.foundOldest; - _updateEndMarkers(); - notifyListeners(); + _haveNewest = result.foundNewest; + + if (haveNewest) { + _syncOutboxMessagesFromStore(); + } + + _setStatus(FetchingStatus.idle, was: FetchingStatus.fetchInitial); + } + + /// Update [narrow] for the result of a "with" narrow (topic permalink) fetch. + /// + /// To avoid an extra round trip, the server handles [ApiNarrowWith] + /// by returning results from the indicated message's current stream/topic + /// (if the user has access), + /// even if that differs from the narrow's stream/topic filters + /// because the message was moved. + /// + /// If such a "redirect" happened, this helper updates the stream and topic + /// in [narrow] to match the message's current conversation. + /// It also removes the "with" component from [narrow] + /// whether or not a redirect happened. + /// + /// See API doc: + /// https://zulip.com/api/construct-narrow#message-ids + void _adjustNarrowForTopicPermalink(Message? someFetchedMessageOrNull) { + final narrow = this.narrow; + if (narrow is! TopicNarrow || narrow.with_ == null) return; + + switch (someFetchedMessageOrNull) { + case null: + // This can't be a redirect; a redirect can't produce an empty result. + // (The server only redirects if the message is accessible to the user, + // and if it is, it'll appear in the result, making it non-empty.) + _narrow = narrow.sansWith(); + case StreamMessage(): + _narrow = TopicNarrow.ofMessage(someFetchedMessageOrNull); + case DmMessage(): // TODO(log) + assert(false); + } } /// Fetch the next batch of older messages, if applicable. + /// + /// If there are no older messages to fetch (i.e. if [haveOldest]), + /// or if this message list is already busy fetching more messages + /// (i.e. if [busyFetchingMore], which includes backoff from failed requests), + /// then this method does nothing and immediately returns. + /// That makes this method suitable to call frequently, e.g. every frame, + /// whenever it looks likely to be useful to have more messages. Future fetchOlder() async { if (haveOldest) return; - if (fetchingOlder) return; - if (fetchOlderCoolingDown) return; + if (busyFetchingMore) return; assert(fetched); assert(messages.isNotEmpty); - _fetchingOlder = true; - _updateEndMarkers(); - notifyListeners(); + await _fetchMore( + anchor: NumericAnchor(messages[0].id), + numBefore: kMessageListFetchBatchSize, + numAfter: 0, + processResult: (result) { + if (result.messages.isNotEmpty + && result.messages.last.id == messages[0].id) { + // TODO(server-6): includeAnchor should make this impossible + result.messages.removeLast(); + } + + store.reconcileMessages(result.messages); + store.recentSenders.handleMessages(result.messages); // TODO(#824) + + final fetchedMessages = _allMessagesVisible + ? result.messages // Avoid unnecessarily copying the list. + : result.messages.where(_messageVisible); + + _insertAllMessages(0, fetchedMessages); + _haveOldest = result.foundOldest; + }); + } + + /// Fetch the next batch of newer messages, if applicable. + /// + /// If there are no newer messages to fetch (i.e. if [haveNewest]), + /// or if this message list is already busy fetching more messages + /// (i.e. if [busyFetchingMore], which includes backoff from failed requests), + /// then this method does nothing and immediately returns. + /// That makes this method suitable to call frequently, e.g. every frame, + /// whenever it looks likely to be useful to have more messages. + Future fetchNewer() async { + if (haveNewest) return; + if (busyFetchingMore) return; + assert(fetched); + assert(messages.isNotEmpty); + await _fetchMore( + anchor: NumericAnchor(messages.last.id), + numBefore: 0, + numAfter: kMessageListFetchBatchSize, + processResult: (result) { + if (result.messages.isNotEmpty + && result.messages.first.id == messages.last.id) { + // TODO(server-6): includeAnchor should make this impossible + result.messages.removeAt(0); + } + + store.reconcileMessages(result.messages); + store.recentSenders.handleMessages(result.messages); // TODO(#824) + + for (final message in result.messages) { + if (_messageVisible(message)) { + _addMessage(message); + } + } + _haveNewest = result.foundNewest; + + if (haveNewest) { + _syncOutboxMessagesFromStore(); + } + }); + } + + Future _fetchMore({ + required Anchor anchor, + required int numBefore, + required int numAfter, + required void Function(GetMessagesResult) processResult, + }) async { + assert(narrow is! TopicNarrow + // We only intend to send "with" in [fetchInitial]; see there. + || (narrow as TopicNarrow).with_ == null); + _setStatus(FetchingStatus.fetchingMore, was: FetchingStatus.idle); final generation = this.generation; bool hasFetchError = false; try { @@ -541,10 +948,11 @@ class MessageListView with ChangeNotifier, _MessageSequence { try { result = await getMessages(store.connection, narrow: narrow.apiEncode(), - anchor: NumericAnchor(messages[0].id), + anchor: anchor, includeAnchor: false, - numBefore: kMessageListFetchBatchSize, - numAfter: 0, + numBefore: numBefore, + numAfter: numAfter, + allowEmptyTopicName: true, ); } catch (e) { hasFetchError = true; @@ -552,57 +960,136 @@ class MessageListView with ChangeNotifier, _MessageSequence { } if (this.generation > generation) return; - if (result.messages.isNotEmpty - && result.messages.last.id == messages[0].id) { - // TODO(server-6): includeAnchor should make this impossible - result.messages.removeLast(); - } - - store.reconcileMessages(result.messages); - store.recentSenders.handleMessages(result.messages); // TODO(#824) - - final fetchedMessages = _allMessagesVisible - ? result.messages // Avoid unnecessarily copying the list. - : result.messages.where(_messageVisible); - - _insertAllMessages(0, fetchedMessages); - _haveOldest = result.foundOldest; + processResult(result); } finally { if (this.generation == generation) { - _fetchingOlder = false; if (hasFetchError) { - assert(!fetchOlderCoolingDown); - _fetchOlderCoolingDown = true; - unawaited((_fetchOlderCooldownBackoffMachine ??= BackoffMachine()) + _setStatus(FetchingStatus.backoff, was: FetchingStatus.fetchingMore); + unawaited((_fetchBackoffMachine ??= BackoffMachine()) .wait().then((_) { if (this.generation != generation) return; - _fetchOlderCoolingDown = false; - _updateEndMarkers(); - notifyListeners(); + _setStatus(FetchingStatus.idle, was: FetchingStatus.backoff); })); } else { - _fetchOlderCooldownBackoffMachine = null; + _setStatus(FetchingStatus.idle, was: FetchingStatus.fetchingMore); + _fetchBackoffMachine = null; } - _updateEndMarkers(); - notifyListeners(); } } } + /// Reset this view to start from the newest messages. + /// + /// This will set [anchor] to [AnchorCode.newest], + /// and cause messages to be re-fetched from scratch. + void jumpToEnd() { + assert(fetched); + assert(!haveNewest); + assert(anchor != AnchorCode.newest); + _anchor = AnchorCode.newest; + _reset(); + notifyListeners(); + fetchInitial(); + } + + bool _shouldAddOutboxMessage(OutboxMessage outboxMessage) { + assert(haveNewest); + return !outboxMessage.hidden + && narrow.containsMessage(outboxMessage) == true + && _messageVisible(outboxMessage); + } + + /// Reads [MessageStore.outboxMessages] and copies to [outboxMessages] + /// the ones belonging to this view. + /// + /// This should only be called when [haveNewest] is true + /// because outbox messages are considered newer than regular messages. + /// + /// This does not call [notifyListeners]. + void _syncOutboxMessagesFromStore() { + assert(haveNewest); + assert(outboxMessages.isEmpty); + for (final outboxMessage in store.outboxMessages.values) { + if (_shouldAddOutboxMessage(outboxMessage)) { + _addOutboxMessage(outboxMessage); + } + } + } + + /// Add [outboxMessage] if it belongs to the view. + void addOutboxMessage(OutboxMessage outboxMessage) { + // We don't have the newest messages; + // we shouldn't show any outbox messages until we do. + if (!haveNewest) return; + + assert(outboxMessages.none( + (message) => message.localMessageId == outboxMessage.localMessageId)); + if (_shouldAddOutboxMessage(outboxMessage)) { + _addOutboxMessage(outboxMessage); + notifyListeners(); + } + } + + /// Remove the [outboxMessage] from the view. + /// + /// This is a no-op if the message is not found. + /// + /// This should only be called from [MessageStore.takeOutboxMessage]. + void removeOutboxMessage(OutboxMessage outboxMessage) { + if (_removeOutboxMessage(outboxMessage)) { + notifyListeners(); + } + } + void handleUserTopicEvent(UserTopicEvent event) { switch (_canAffectVisibility(event)) { - case VisibilityEffect.none: + case UserTopicVisibilityEffect.none: + return; + + case UserTopicVisibilityEffect.muted: + bool removed = _removeMessagesWhere((message) => + message is StreamMessage + && message.streamId == event.streamId + && message.topic == event.topicName); + + removed |= _removeOutboxMessagesWhere((message) => + message is StreamOutboxMessage + && message.conversation.streamId == event.streamId + && message.conversation.topic == event.topicName); + + if (removed) { + notifyListeners(); + } + + case UserTopicVisibilityEffect.unmuted: + // TODO get the newly-unmuted messages from the message store + // For now, we simplify the task by just refetching this message list + // from scratch. + if (fetched) { + _reset(); + notifyListeners(); + fetchInitial(); + } + } + } + + void handleMutedUsersEvent(MutedUsersEvent event) { + switch (_mutedUsersEventCanAffectVisibility(event)) { + case MutedUsersVisibilityEffect.none: return; - case VisibilityEffect.muted: - if (_removeMessagesWhere((message) => - (message is StreamMessage - && message.streamId == event.streamId - && message.topic == event.topicName))) { + case MutedUsersVisibilityEffect.muted: + final anyRemoved = _removeMessagesWhere((message) { + if (message is! DmMessage) return false; + final narrow = DmNarrow.ofMessage(message, selfUserId: store.selfUserId); + return store.shouldMuteDmConversation(narrow, event: event); + }); + if (anyRemoved) { notifyListeners(); } - case VisibilityEffect.unmuted: + case MutedUsersVisibilityEffect.mixed: + case MutedUsersVisibilityEffect.unmuted: // TODO get the newly-unmuted messages from the message store // For now, we simplify the task by just refetching this message list // from scratch. @@ -623,15 +1110,37 @@ class MessageListView with ChangeNotifier, _MessageSequence { /// Add [MessageEvent.message] to this view, if it belongs here. void handleMessageEvent(MessageEvent event) { final message = event.message; - if (!narrow.containsMessage(message) || !_messageVisible(message)) { + if (narrow.containsMessage(message) != true || !_messageVisible(message)) { + assert(event.localMessageId == null || outboxMessages.none((message) => + message.localMessageId == int.parse(event.localMessageId!, radix: 10))); return; } - if (!_fetched) { - // TODO mitigate this fetch/event race: save message to add to list later + if (!haveNewest) { + // This message list's [messages] doesn't yet reach the new end + // of the narrow's message history. (Either [fetchInitial] hasn't yet + // completed, or if it has then it was in the middle of history and no + // subsequent [fetchNewer] has reached the end.) + // So this still-newer message doesn't belong. + // Leave it to be found by a subsequent fetch when appropriate. + // TODO mitigate this fetch/event race: save message to add to list later, + // in case the fetch that reaches the end is already ongoing and + // didn't include this message. return; } - // TODO insert in middle instead, when appropriate + + // Remove the outbox messages temporarily. + // We'll add them back after the new message. + _removeOutboxMessageItems(); + // TODO insert in middle of [messages] instead, when appropriate _addMessage(message); + if (event.localMessageId != null) { + final localMessageId = int.parse(event.localMessageId!, radix: 10); + // [outboxMessages] is expected to be short, so removing the corresponding + // outbox message and reprocessing them all in linear time is efficient. + outboxMessages.removeWhere( + (message) => message.localMessageId == localMessageId); + } + _reprocessOutboxMessages(); notifyListeners(); } @@ -676,21 +1185,18 @@ class MessageListView with ChangeNotifier, _MessageSequence { switch (propagateMode) { case PropagateMode.changeAll: case PropagateMode.changeLater: - narrow = newNarrow; - _reset(); - fetchInitial(); + renarrowAndFetch(newNarrow); case PropagateMode.changeOne: } } void messagesMoved({ - required int origStreamId, - required int newStreamId, - required TopicName origTopic, - required TopicName newTopic, + required UpdateMessageMoveData messageMove, required List messageIds, - required PropagateMode propagateMode, }) { + final UpdateMessageMoveData( + :origStreamId, :newStreamId, :origTopic, :newTopic, :propagateMode, + ) = messageMove; switch (narrow) { case DmNarrow(): // DMs can't be moved (nor created by moves), @@ -700,12 +1206,19 @@ class MessageListView with ChangeNotifier, _MessageSequence { case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): - // The messages were and remain in this narrow. - // TODO(#421): … except they may have become muted or not. + // The messages didn't enter or leave this narrow. + // TODO(#1255): … except they may have become muted or not. // We'll handle that at the same time as we handle muting itself changing. // Recipient headers, and downstream of those, may change, though. _messagesMovedInternally(messageIds); + case KeywordSearchNarrow(): + // This might not be quite true, since matches can be determined by + // the topic alone, and topics change. Punt on trying to add/remove + // messages, though, because we aren't equipped to evaluate the match + // without asking the server. + _messagesMovedInternally(messageIds); + case ChannelNarrow(:final streamId): switch ((origStreamId == streamId, newStreamId == streamId)) { case (false, false): return; @@ -751,6 +1264,15 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + /// Notify listeners if the given outbox message is present in this view. + void notifyListenersIfOutboxMessagePresent(int localMessageId) { + final isAnyPresent = + outboxMessages.any((message) => message.localMessageId == localMessageId); + if (isAnyPresent) { + notifyListeners(); + } + } + /// Called when the app is reassembled during debugging, e.g. for hot reload. /// /// This will redo from scratch any computations we can, such as parsing diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart index 9e29808ceb..863867a195 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -19,7 +19,10 @@ sealed class Narrow { /// This does not necessarily mean the message list would show this message /// when navigated to this narrow; in particular it does not address the /// question of whether the stream or topic, or the sending user, is muted. - bool containsMessage(Message message); + /// + /// Null when the client is unable to predict whether the message + /// satisfies the filters of this narrow, e.g. when this is a search narrow. + bool? containsMessage(MessageBase message); /// This narrow, expressed as an [ApiNarrow]. ApiNarrow apiEncode(); @@ -47,7 +50,7 @@ class CombinedFeedNarrow extends Narrow { const CombinedFeedNarrow(); @override - bool containsMessage(Message message) { + bool containsMessage(MessageBase message) { return true; } @@ -71,12 +74,13 @@ class ChannelNarrow extends Narrow { final int streamId; @override - bool containsMessage(Message message) { - return message is StreamMessage && message.streamId == streamId; + bool containsMessage(MessageBase message) { + final conversation = message.conversation; + return conversation is StreamConversation && conversation.streamId == streamId; } @override - ApiNarrow apiEncode() => [ApiNarrowStream(streamId)]; + ApiNarrow apiEncode() => [ApiNarrowChannel(streamId)]; @override String toString() => 'ChannelNarrow($streamId)'; @@ -92,38 +96,53 @@ class ChannelNarrow extends Narrow { } class TopicNarrow extends Narrow implements SendableNarrow { - const TopicNarrow(this.streamId, this.topic); + const TopicNarrow(this.streamId, this.topic, {this.with_}); - factory TopicNarrow.ofMessage(StreamMessage message) { - return TopicNarrow(message.streamId, message.topic); + factory TopicNarrow.ofMessage(MessageBase message) { + return TopicNarrow(message.conversation.streamId, message.conversation.topic); } final int streamId; final TopicName topic; + final int? with_; + + TopicNarrow sansWith() => TopicNarrow(streamId, topic); @override - bool containsMessage(Message message) { - return (message is StreamMessage - && message.streamId == streamId && message.topic == topic); + bool containsMessage(MessageBase message) { + final conversation = message.conversation; + return conversation is StreamConversation + && conversation.streamId == streamId && conversation.topic == topic; } @override - ApiNarrow apiEncode() => [ApiNarrowStream(streamId), ApiNarrowTopic(topic)]; + ApiNarrow apiEncode() => [ + ApiNarrowChannel(streamId), + ApiNarrowTopic(topic), + if (with_ != null) ApiNarrowWith(with_!), + ]; @override StreamDestination get destination => StreamDestination(streamId, topic); @override - String toString() => 'TopicNarrow($streamId, ${topic.displayName})'; + String toString() { + final fields = [ + streamId.toString(), + topic.displayName, + if (with_ != null) 'with: ${with_!}', + ]; + return 'TopicNarrow(${fields.join(', ')})'; + } @override bool operator ==(Object other) { if (other is! TopicNarrow) return false; - return other.streamId == streamId && other.topic == topic; + return other.streamId == streamId && other.topic == topic && other.with_ == with_; } @override - int get hashCode => Object.hash('TopicNarrow', streamId, topic); + int get hashCode => Object.hash('TopicNarrow', streamId, topic, with_); } /// The narrow for a direct-message conversation. @@ -180,9 +199,21 @@ class DmNarrow extends Narrow implements SendableNarrow { ); } - factory DmNarrow.ofMessage(DmMessage message, {required int selfUserId}) { + factory DmNarrow.ofMessage(MessageBase message, { + required int selfUserId, + }) { + return DmNarrow( + // TODO should this really be making a copy of `allRecipientIds`? + allRecipientIds: List.unmodifiable(message.conversation.allRecipientIds), + selfUserId: selfUserId, + ); + } + + factory DmNarrow.ofConversation(DmConversation conversation, { + required int selfUserId, + }) { return DmNarrow( - allRecipientIds: List.unmodifiable(message.allRecipientIds), + allRecipientIds: conversation.allRecipientIds, selfUserId: selfUserId, ); } @@ -221,6 +252,7 @@ class DmNarrow extends Narrow implements SendableNarrow { /// See also: /// * [otherRecipientIds], an alternate way of identifying the conversation. /// * [DmMessage.allRecipientIds], which provides this same format. + /// * [DmConversation.allRecipientIds], which also provides this same format. final List allRecipientIds; /// The user ID of the self-user. @@ -246,11 +278,12 @@ class DmNarrow extends Narrow implements SendableNarrow { late final String _key = otherRecipientIds.join(','); @override - bool containsMessage(Message message) { - if (message is! DmMessage) return false; - if (message.allRecipientIds.length != allRecipientIds.length) return false; + bool containsMessage(MessageBase message) { + final conversation = message.conversation; + if (conversation is! DmConversation) return false; + if (conversation.allRecipientIds.length != allRecipientIds.length) return false; int i = 0; - for (final userId in message.allRecipientIds) { + for (final userId in conversation.allRecipientIds) { if (userId != allRecipientIds[i]) return false; i++; } @@ -290,7 +323,8 @@ class MentionsNarrow extends Narrow { const MentionsNarrow(); @override - bool containsMessage(Message message) { + bool containsMessage(MessageBase message) { + if (message is! Message) return false; return message.flags.any((flag) { switch (flag) { case MessageFlag.mentioned: @@ -329,7 +363,8 @@ class StarredMessagesNarrow extends Narrow { ApiNarrow apiEncode() => [ApiNarrowIs(IsOperand.starred)]; @override - bool containsMessage(Message message) { + bool containsMessage(MessageBase message) { + if (message is! Message) return false; return message.flags.contains(MessageFlag.starred); } @@ -343,3 +378,31 @@ class StarredMessagesNarrow extends Narrow { @override int get hashCode => 'StarredMessagesNarrow'.hashCode; } + +/// A keyword-search narrow. +/// +/// [keyword] must have been trimmed with [String.trim]. +class KeywordSearchNarrow extends Narrow { + KeywordSearchNarrow(this.keyword) + : assert(keyword.trim() == keyword); + + final String keyword; + + @override + bool? containsMessage(MessageBase message) => null; + + @override + ApiNarrow apiEncode() => [ApiNarrowSearch(keyword)]; + + @override + String toString() => 'KeywordSearchNarrow($keyword)'; + + @override + bool operator ==(Object other) { + if (other is! KeywordSearchNarrow) return false; + return other.keyword == keyword; + } + + @override + int get hashCode => Object.hash('KeywordSearchNarrow', keyword); +} diff --git a/lib/model/presence.dart b/lib/model/presence.dart new file mode 100644 index 0000000000..882f12031c --- /dev/null +++ b/lib/model/presence.dart @@ -0,0 +1,207 @@ +import 'dart:async'; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import '../api/model/events.dart'; +import '../api/model/model.dart'; +import '../api/route/users.dart'; +import 'realm.dart'; + +/// The model for tracking which users are online, idle, and offline. +/// +/// Use [presenceStatusForUser]. If that returns null, the user is offline. +/// +/// This substore is its own [ChangeNotifier], +/// so callers need to remember to add a listener (and remove it on dispose). +/// In particular, [PerAccountStoreWidget] doesn't subscribe a widget subtree +/// to updates. +class Presence extends HasRealmStore with ChangeNotifier { + Presence({ + required super.realm, + required Map initial, + }) : _map = initial; + + Map _map; + + AppLifecycleListener? _appLifecycleListener; + + void _handleLifecycleStateChange(AppLifecycleState newState) { + assert(!_disposed); // We remove the listener in [dispose]. + + // Since this handler can cause multiple requests within a + // serverPresencePingInterval period, we pass `pingOnly: true`, for now, because: + // - This makes the request cheap for the server. + // - We don't want to record stale presence data when responses arrive out + // of order. This handler would increase the risk of that by potentially + // sending requests more frequently than serverPresencePingInterval. + // (`pingOnly: true` causes presence data to be omitted in the response.) + // TODO(#1611) Both of these reasons can be easily addressed by passing + // lastUpdateId. Do that, and stop sending `pingOnly: true`. + // (For the latter point, we'd ignore responses with a stale lastUpdateId.) + _maybePingAndRecordResponse(newState, pingOnly: true); + } + + bool _hasStarted = false; + + void start() async { + if (!debugEnable) return; + if (_hasStarted) { + throw StateError('Presence.start should only be called once.'); + } + _hasStarted = true; + + _appLifecycleListener = AppLifecycleListener( + onStateChange: _handleLifecycleStateChange); + + _poll(); + } + + Future _maybePingAndRecordResponse(AppLifecycleState? appLifecycleState, { + required bool pingOnly, + }) async { + if (realmPresenceDisabled) return; + + final UpdatePresenceResult result; + switch (appLifecycleState) { + case null: + case AppLifecycleState.hidden: + case AppLifecycleState.paused: + // No presence update. + return; + case AppLifecycleState.detached: + // > The application is still hosted by a Flutter engine but is + // > detached from any host views. + // TODO see if this actually works as a way to send an "idle" update + // when the user closes the app completely. + result = await updatePresence(connection, + pingOnly: pingOnly, + status: PresenceStatus.idle, + newUserInput: false); + case AppLifecycleState.resumed: + // > […] the default running mode for a running application that has + // > input focus and is visible. + result = await updatePresence(connection, + pingOnly: pingOnly, + status: PresenceStatus.active, + newUserInput: true); + case AppLifecycleState.inactive: + // > At least one view of the application is visible, but none have + // > input focus. The application is otherwise running normally. + // For example, we expect this state when the user is selecting a file + // to upload. + result = await updatePresence(connection, + pingOnly: pingOnly, + status: PresenceStatus.active, + newUserInput: false); + } + if (!pingOnly) { + _handlePresenceResponse(result.presences!); + } + } + + void _poll() async { + assert(!_disposed); + while (true) { + // We put the wait upfront because we already have data when [start] is + // called; it comes from /register. + await Future.delayed(serverPresencePingInterval); + if (_disposed) return; + + await _maybePingAndRecordResponse( + SchedulerBinding.instance.lifecycleState, pingOnly: false); + if (_disposed) return; + } + } + + bool _disposed = false; + + @override + void dispose() { + _appLifecycleListener?.dispose(); + _disposed = true; + super.dispose(); + } + + @visibleForTesting + void debugHandlePresenceResponse(Map presences) { + _handlePresenceResponse(presences); + } + + void _handlePresenceResponse(Map presences) { + _map = presences; + notifyListeners(); + } + + /// The [PresenceStatus] for [userId], or null if the user is offline. + PresenceStatus? presenceStatusForUser(int userId, {required DateTime utcNow}) { + final now = utcNow.millisecondsSinceEpoch ~/ 1000; + final perUserPresence = _map[userId]; + if (perUserPresence == null) return null; + final PerUserPresence(:activeTimestamp, :idleTimestamp) = perUserPresence; + + if (now - activeTimestamp <= serverPresenceOfflineThresholdSeconds) { + return PresenceStatus.active; + } else if (now - idleTimestamp <= serverPresenceOfflineThresholdSeconds) { + // The API doc is kind of confusing, but this seems correct: + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/presence.3A.20.22potentially.20present.22.3F/near/2202431 + // TODO clarify that API doc + return PresenceStatus.idle; + } else { + return null; + } + } + + /// The timestamp when the given user was "last active", if any. + /// + /// This is meaningful only when [presenceStatusForUser] is null. + /// When that method returns active or idle, the user should be displayed + /// with a description like "Active now" or "Idle" rather than + /// one like "Last active $duration ago" that uses this timestamp. + int? userLastActive(int userId) { + // The corresponding implementation on web is complicated; + // but the actual behavior seems to be this simple. + // + // In web, see buddy_data.user_last_seen_time_status; the last-active time + // is used only when the status is offline (vs active or idle). + // The timestamp comes via presence.last_active_date from the data structure + // fed by presence.status_from_raw. + // + // That status_from_raw function sometimes uses idle_timestamp; + // but only when status idle, where the timestamp will be ignored anyway. + // It also consults the equivalent of [User.dateJoined] as a fallback, + // for when processing a user who has "never logged in"... but it's + // not clear that function ever gets called in such a case. + // Those wrinkles aside, it always uses active_timestamp. + + return _map[userId]?.activeTimestamp; + } + + void handlePresenceEvent(PresenceEvent event) { + // TODO(#1618) + } + + /// In debug mode, controls whether presence requests are made. + /// + /// Outside of debug mode, this is always true and the setter has no effect. + static bool get debugEnable { + bool result = true; + assert(() { + result = _debugEnable; + return true; + }()); + return result; + } + static bool _debugEnable = true; + static set debugEnable(bool value) { + assert(() { + _debugEnable = value; + return true; + }()); + } + + @visibleForTesting + static void debugReset() { + debugEnable = true; + } +} diff --git a/lib/model/realm.dart b/lib/model/realm.dart new file mode 100644 index 0000000000..782740405d --- /dev/null +++ b/lib/model/realm.dart @@ -0,0 +1,344 @@ +import 'package:flutter/foundation.dart'; + +import '../api/model/events.dart'; +import '../api/model/initial_snapshot.dart'; +import '../api/model/model.dart'; +import 'store.dart'; +import 'user_group.dart'; + +/// The portion of [PerAccountStore] for realm settings, server settings, +/// and similar data about the whole realm or server. +/// +/// See also: +/// * [RealmStoreImpl] for the implementation of this that does the work. +/// * [HasRealmStore] for an implementation useful for other substores. +mixin RealmStore on PerAccountStoreBase, UserGroupStore { + @protected + UserGroupStore get userGroupStore; + + //|////////////////////////////////////////////////////////////// + // Server settings, explicitly so named. + + Duration get serverPresencePingInterval => Duration(seconds: serverPresencePingIntervalSeconds); + int get serverPresencePingIntervalSeconds; + Duration get serverPresenceOfflineThreshold => Duration(seconds: serverPresenceOfflineThresholdSeconds); + int get serverPresenceOfflineThresholdSeconds; + + Duration get serverTypingStartedExpiryPeriod => Duration(milliseconds: serverTypingStartedExpiryPeriodMilliseconds); + int get serverTypingStartedExpiryPeriodMilliseconds; + Duration get serverTypingStoppedWaitPeriod => Duration(milliseconds: serverTypingStoppedWaitPeriodMilliseconds); + int get serverTypingStoppedWaitPeriodMilliseconds; + Duration get serverTypingStartedWaitPeriod => Duration(milliseconds: serverTypingStartedWaitPeriodMilliseconds); + int get serverTypingStartedWaitPeriodMilliseconds; + + //|////////////////////////////////////////////////////////////// + // Realm settings. + + //|////////////////////////////// + // Realm settings found in realm/update_dict events: + // https://zulip.com/api/get-events#realm-update_dict + // + // In order of appearance in the realm/update_dict event doc. + // TODO(#668): update all these realm settings on events. + + bool get realmAllowMessageEditing; + GroupSettingValue? get realmCanDeleteAnyMessageGroup; // TODO(server-10) + GroupSettingValue? get realmCanDeleteOwnMessageGroup; // TODO(server-10) + bool get realmEnableReadReceipts; + bool get realmMandatoryTopics; + int get maxFileUploadSizeMib; + int? get realmMessageContentDeleteLimitSeconds; + Duration? get realmMessageContentEditLimit => + realmMessageContentEditLimitSeconds == null ? null + : Duration(seconds: realmMessageContentEditLimitSeconds!); + int? get realmMessageContentEditLimitSeconds; + bool get realmPresenceDisabled; + int get realmWaitingPeriodThreshold; + + //|////////////////////////////// + // Realm settings previously found in realm/update_dict events, + // but now deprecated. + + RealmWildcardMentionPolicy get realmWildcardMentionPolicy; // TODO(#662): replaced by can_mention_many_users_group + RealmDeleteOwnMessagePolicy? get realmDeleteOwnMessagePolicy; // TODO(server-10) remove + + //|////////////////////////////// + // Realm settings that lack events. + // (Each of these is probably secretly a server setting.) + + /// The display name to use for empty topics. + /// + /// This should only be accessed when FL >= 334, since topics cannot + /// be empty otherwise. + // TODO(server-10) simplify this + String get realmEmptyTopicDisplayName; + + Map get realmDefaultExternalAccounts; + + //|////////////////////////////// + // Realm settings with their own events. + + List get customProfileFields; + + //|////////////////////////////////////////////////////////////// + // Methods that examine the settings. + + /// Process the given topic to match how it would appear + /// on a message object from the server. + /// + /// This returns the [TopicName] the server would be predicted to include + /// in a message object resulting from sending to the given [TopicName] + /// in a [sendMessage] request. + /// + /// The [TopicName] is required to have no leading or trailing whitespace. + /// + /// For a client that supports empty topics, when FL>=334, the server converts + /// `store.realmEmptyTopicDisplayName` to an empty string; when FL>=370, + /// the server converts "(no topic)" to an empty string as well. + /// + /// See API docs: + /// https://zulip.com/api/send-message#parameter-topic + TopicName processTopicLikeServer(TopicName topic) { + final apiName = topic.apiName; + assert(apiName.trim() == apiName); + // TODO(server-10) simplify this away + if (zulipFeatureLevel < 334) { + // From the API docs: + // > Before Zulip 10.0 (feature level 334), empty string was not a valid + // > topic name for channel messages. + assert(apiName.isNotEmpty); + return topic; + } + + // TODO(server-10) simplify this away + if (zulipFeatureLevel < 370 && apiName == kNoTopicTopic) { + // From the API docs: + // > Before Zulip 10.0 (feature level 370), "(no topic)" was not + // > interpreted as an empty string. + return TopicName(kNoTopicTopic); + } + + if (apiName == kNoTopicTopic || apiName == realmEmptyTopicDisplayName) { + // From the API docs: + // > When "(no topic)" or the value of realm_empty_topic_display_name + // > found in the POST /register response is used for [topic], + // > it is interpreted as an empty string. + return TopicName(''); + } + return topic; + } + + /// Whether the self-user has the given (group-based) permission. + bool selfHasPermissionForGroupSetting(GroupSettingValue value, + GroupSettingType type, String name); +} + +enum GroupSettingType { realm, stream, group } + +mixin ProxyRealmStore on RealmStore { + @protected + RealmStore get realmStore; + + @override + int get serverPresencePingIntervalSeconds => realmStore.serverPresencePingIntervalSeconds; + @override + int get serverPresenceOfflineThresholdSeconds => realmStore.serverPresenceOfflineThresholdSeconds; + @override + int get serverTypingStartedExpiryPeriodMilliseconds => realmStore.serverTypingStartedExpiryPeriodMilliseconds; + @override + int get serverTypingStoppedWaitPeriodMilliseconds => realmStore.serverTypingStoppedWaitPeriodMilliseconds; + @override + int get serverTypingStartedWaitPeriodMilliseconds => realmStore.serverTypingStartedWaitPeriodMilliseconds; + @override + bool get realmAllowMessageEditing => realmStore.realmAllowMessageEditing; + @override + GroupSettingValue? get realmCanDeleteAnyMessageGroup => realmStore.realmCanDeleteAnyMessageGroup; + @override + GroupSettingValue? get realmCanDeleteOwnMessageGroup => realmStore.realmCanDeleteOwnMessageGroup; + @override + bool get realmEnableReadReceipts => realmStore.realmEnableReadReceipts; + @override + bool get realmMandatoryTopics => realmStore.realmMandatoryTopics; + @override + int get maxFileUploadSizeMib => realmStore.maxFileUploadSizeMib; + @override + int? get realmMessageContentDeleteLimitSeconds => realmStore.realmMessageContentDeleteLimitSeconds; + @override + int? get realmMessageContentEditLimitSeconds => realmStore.realmMessageContentEditLimitSeconds; + @override + bool get realmPresenceDisabled => realmStore.realmPresenceDisabled; + @override + int get realmWaitingPeriodThreshold => realmStore.realmWaitingPeriodThreshold; + @override + RealmWildcardMentionPolicy get realmWildcardMentionPolicy => realmStore.realmWildcardMentionPolicy; + @override + RealmDeleteOwnMessagePolicy? get realmDeleteOwnMessagePolicy => realmStore.realmDeleteOwnMessagePolicy; + @override + String get realmEmptyTopicDisplayName => realmStore.realmEmptyTopicDisplayName; + @override + Map get realmDefaultExternalAccounts => realmStore.realmDefaultExternalAccounts; + @override + List get customProfileFields => realmStore.customProfileFields; + @override + bool selfHasPermissionForGroupSetting(GroupSettingValue value, GroupSettingType type, String name) => + realmStore.selfHasPermissionForGroupSetting(value, type, name); +} + +/// A base class for [PerAccountStore] substores that need access to [RealmStore] +/// as well as to [CorePerAccountStore]. +abstract class HasRealmStore extends HasUserGroupStore with RealmStore, ProxyRealmStore { + HasRealmStore({required RealmStore realm}) + : realmStore = realm, super(groups: realm.userGroupStore); + + @protected + @override + final RealmStore realmStore; +} + +/// The implementation of [RealmStore] that does the work. +class RealmStoreImpl extends HasUserGroupStore with RealmStore { + RealmStoreImpl({ + required super.groups, + required InitialSnapshot initialSnapshot, + required User selfUser, + }) : + _selfUserRole = selfUser.role, + serverPresencePingIntervalSeconds = initialSnapshot.serverPresencePingIntervalSeconds, + serverPresenceOfflineThresholdSeconds = initialSnapshot.serverPresenceOfflineThresholdSeconds, + serverTypingStartedExpiryPeriodMilliseconds = initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds, + serverTypingStoppedWaitPeriodMilliseconds = initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds, + serverTypingStartedWaitPeriodMilliseconds = initialSnapshot.serverTypingStartedWaitPeriodMilliseconds, + realmAllowMessageEditing = initialSnapshot.realmAllowMessageEditing, + realmCanDeleteAnyMessageGroup = initialSnapshot.realmCanDeleteAnyMessageGroup, + realmCanDeleteOwnMessageGroup = initialSnapshot.realmCanDeleteOwnMessageGroup, + realmMandatoryTopics = initialSnapshot.realmMandatoryTopics, + maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib, + realmMessageContentDeleteLimitSeconds = initialSnapshot.realmMessageContentDeleteLimitSeconds, + realmMessageContentEditLimitSeconds = initialSnapshot.realmMessageContentEditLimitSeconds, + realmEnableReadReceipts = initialSnapshot.realmEnableReadReceipts, + realmPresenceDisabled = initialSnapshot.realmPresenceDisabled, + realmWaitingPeriodThreshold = initialSnapshot.realmWaitingPeriodThreshold, + realmWildcardMentionPolicy = initialSnapshot.realmWildcardMentionPolicy, + realmDeleteOwnMessagePolicy = initialSnapshot.realmDeleteOwnMessagePolicy, + _realmEmptyTopicDisplayName = initialSnapshot.realmEmptyTopicDisplayName, + realmDefaultExternalAccounts = initialSnapshot.realmDefaultExternalAccounts, + customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields); + + @override + bool selfHasPermissionForGroupSetting(GroupSettingValue value, + GroupSettingType type, String name) { + // Compare web's settings_data.user_has_permission_for_group_setting. + // + // In the whole web app, there's just one caller for that function with + // a user other than the self user: stream_data.can_post_messages_in_stream, + // and only for get_current_user_and_their_bots_with_post_messages_permission, + // with only the self-user's own bots as the arguments. + // That exists for deciding whether to offer the "Generate email address" + // button, and if so then which users to offer in the dropdown; + // it's predicting whether /api/get-stream-email-address would succeed. + if (_selfUserRole == UserRole.guest) { + final config = _groupSettingConfig(type, name); + if (!config.allowEveryoneGroup) return false; + } + return selfInGroupSetting(value); + } + + /// The metadata for how to interpret the given group-based permission setting. + PermissionSettingsItem _groupSettingConfig(GroupSettingType type, String name) { + final supportedSettings = SupportedPermissionSettings.fixture; + + // Compare web's group_permission_settings.get_group_permission_setting_config. + final configGroup = switch (type) { + GroupSettingType.realm => supportedSettings.realm, + GroupSettingType.stream => supportedSettings.stream, + GroupSettingType.group => supportedSettings.group, + }; + final config = configGroup[name]; + return config!; // TODO(log) + } + + /// The [User.role] of the self-user. + /// + /// The main home of this information is [UserStore]: `store.selfUser.role`. + /// We need it here for interpreting some permission settings; + /// so we denormalize it here to avoid a cycle between substores. + UserRole _selfUserRole; + + @override + final int serverPresencePingIntervalSeconds; + @override + final int serverPresenceOfflineThresholdSeconds; + + @override + final int serverTypingStartedExpiryPeriodMilliseconds; + @override + final int serverTypingStoppedWaitPeriodMilliseconds; + @override + final int serverTypingStartedWaitPeriodMilliseconds; + + @override + final bool realmAllowMessageEditing; + @override + final GroupSettingValue? realmCanDeleteAnyMessageGroup; + @override + final GroupSettingValue? realmCanDeleteOwnMessageGroup; + @override + final bool realmEnableReadReceipts; + @override + final bool realmMandatoryTopics; + @override + final int maxFileUploadSizeMib; + @override + final int? realmMessageContentDeleteLimitSeconds; + @override + final int? realmMessageContentEditLimitSeconds; + @override + final bool realmPresenceDisabled; + @override + final int realmWaitingPeriodThreshold; + + @override + final RealmWildcardMentionPolicy realmWildcardMentionPolicy; + @override + final RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy; + + @override + String get realmEmptyTopicDisplayName { + assert(zulipFeatureLevel >= 334); // TODO(server-10) + assert(_realmEmptyTopicDisplayName != null); // TODO(log) + return _realmEmptyTopicDisplayName ?? 'general chat'; + } + final String? _realmEmptyTopicDisplayName; + + @override + final Map realmDefaultExternalAccounts; + + @override + List customProfileFields; + + static List _sortCustomProfileFields(List initialCustomProfileFields) { + // TODO(server): The realm-wide field objects have an `order` property, + // but the actual API appears to be that the fields should be shown in + // the order they appear in the array (`custom_profile_fields` in the + // API; our `realmFields` array here.) See chat thread: + // https://chat.zulip.org/#narrow/stream/378-api-design/topic/custom.20profile.20fields/near/1382982 + // + // We go on to put at the start of the list any fields that are marked for + // displaying in the "profile summary". (Possibly they should be at the + // start of the list in the first place, but make sure just in case.) + final displayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary == true); + final nonDisplayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary != true); + return displayFields.followedBy(nonDisplayFields).toList(); + } + + void handleCustomProfileFieldsEvent(CustomProfileFieldsEvent event) { + customProfileFields = _sortCustomProfileFields(event.fields); + } + + void handleRealmUserUpdateEvent(RealmUserUpdateEvent event) { + // Compare [UserStoreImpl.handleRealmUserEvent]. + if (event.userId == selfUserId) { + if (event.role != null) _selfUserRole = event.role!; + } + } +} diff --git a/lib/model/recent_dm_conversations.dart b/lib/model/recent_dm_conversations.dart index b38610be15..8428ecdf63 100644 --- a/lib/model/recent_dm_conversations.dart +++ b/lib/model/recent_dm_conversations.dart @@ -7,18 +7,19 @@ import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import '../api/model/events.dart'; import 'narrow.dart'; +import 'store.dart'; /// A view-model for the recent-DM-conversations UI. /// /// This maintains the list of recent DM conversations, /// plus additional data in order to efficiently maintain the list. -class RecentDmConversationsView extends ChangeNotifier { +class RecentDmConversationsView extends PerAccountStoreBase with ChangeNotifier { factory RecentDmConversationsView({ + required CorePerAccountStore core, required List initial, - required int selfUserId, }) { final entries = initial.map((conversation) => MapEntry( - DmNarrow.ofRecentDmConversation(conversation, selfUserId: selfUserId), + DmNarrow.ofRecentDmConversation(conversation, selfUserId: core.selfUserId), conversation.maxMessageId, )).toList()..sort((a, b) => -a.value.compareTo(b.value)); @@ -33,18 +34,18 @@ class RecentDmConversationsView extends ChangeNotifier { } return RecentDmConversationsView._( + core: core, map: Map.fromEntries(entries), sorted: QueueList.from(entries.map((e) => e.key)), latestMessagesByRecipient: latestMessagesByRecipient, - selfUserId: selfUserId, ); } RecentDmConversationsView._({ + required super.core, required this.map, required this.sorted, required this.latestMessagesByRecipient, - required this.selfUserId, }); /// The latest message ID in each conversation. @@ -62,8 +63,6 @@ class RecentDmConversationsView extends ChangeNotifier { /// it might have been sent by anyone in its conversation.) final Map latestMessagesByRecipient; - final int selfUserId; - /// Insert the key at the proper place in [sorted]. /// /// Optimized, taking O(1) time, for the case where that place is the start, diff --git a/lib/model/recent_senders.dart b/lib/model/recent_senders.dart index a5c4bca778..f8b046641d 100644 --- a/lib/model/recent_senders.dart +++ b/lib/model/recent_senders.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import '../api/model/events.dart'; import '../api/model/model.dart'; import 'algorithms.dart'; +import 'channel.dart'; /// Tracks the latest messages sent by each user, in each stream and topic. /// @@ -16,7 +17,7 @@ class RecentSenders { // topicSenders[streamId][topic][senderId] = MessageIdTracker @visibleForTesting - final Map>> topicSenders = {}; + final Map>> topicSenders = {}; /// The latest message the given user sent to the given stream, /// or null if no such message is known. @@ -27,6 +28,8 @@ class RecentSenders { /// The latest message the given user sent to the given topic, /// or null if no such message is known. + /// + /// Topics are treated case-insensitively; see [TopicName.isSameAs]. int? latestMessageIdOfSenderInTopic({ required int streamId, required TopicName topic, @@ -53,7 +56,7 @@ class RecentSenders { } for (final entry in messagesByUserInTopic.entries) { final (streamId, topic, senderId) = entry.key; - (((topicSenders[streamId] ??= {})[topic] ??= {}) + (((topicSenders[streamId] ??= makeTopicKeyedMap())[topic] ??= {}) [senderId] ??= MessageIdTracker()).addAll(entry.value); } } @@ -64,7 +67,7 @@ class RecentSenders { final StreamMessage(:streamId, :topic, :senderId, id: int messageId) = message; ((streamSenders[streamId] ??= {}) [senderId] ??= MessageIdTracker()).add(messageId); - (((topicSenders[streamId] ??= {})[topic] ??= {}) + (((topicSenders[streamId] ??= makeTopicKeyedMap())[topic] ??= {}) [senderId] ??= MessageIdTracker()).add(messageId); } diff --git a/lib/model/saved_snippet.dart b/lib/model/saved_snippet.dart new file mode 100644 index 0000000000..59c8347591 --- /dev/null +++ b/lib/model/saved_snippet.dart @@ -0,0 +1,38 @@ +import 'package:collection/collection.dart'; + +import '../api/model/events.dart'; +import '../api/model/model.dart'; +import 'store.dart'; + +mixin SavedSnippetStore { + Map get savedSnippets; +} + +class SavedSnippetStoreImpl extends PerAccountStoreBase with SavedSnippetStore { + SavedSnippetStoreImpl({ + required super.core, + required Iterable savedSnippets, + }) : _savedSnippets = { + for (final savedSnippet in savedSnippets) + savedSnippet.id: savedSnippet, + }; + + @override + late Map savedSnippets = UnmodifiableMapView(_savedSnippets); + final Map _savedSnippets; + + void handleSavedSnippetsEvent(SavedSnippetsEvent event) { + switch (event) { + case SavedSnippetsAddEvent(:final savedSnippet): + _savedSnippets[savedSnippet.id] = savedSnippet; + + case SavedSnippetsUpdateEvent(:final savedSnippet): + assert(_savedSnippets[savedSnippet.id]!.dateCreated + == savedSnippet.dateCreated); // TODO(log) + _savedSnippets[savedSnippet.id] = savedSnippet; + + case SavedSnippetsRemoveEvent(:final savedSnippetId): + _savedSnippets.remove(savedSnippetId); + } + } +} diff --git a/lib/model/schema_versions.g.dart b/lib/model/schema_versions.g.dart new file mode 100644 index 0000000000..194eccd6b5 --- /dev/null +++ b/lib/model/schema_versions.g.dart @@ -0,0 +1,845 @@ +// dart format width=80 +import 'package:drift/internal/versioned_schema.dart' as i0; +import 'package:drift/drift.dart' as i1; +import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import + +// GENERATED BY drift_dev, DO NOT MODIFY. +final class Schema2 extends i0.VersionedSchema { + Schema2({required super.database}) : super(version: 2); + @override + late final List entities = [accounts]; + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +class Shape0 extends i0.VersionedTable { + Shape0({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get realmUrl => + columnsByName['realm_url']! as i1.GeneratedColumn; + i1.GeneratedColumn get userId => + columnsByName['user_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get email => + columnsByName['email']! as i1.GeneratedColumn; + i1.GeneratedColumn get apiKey => + columnsByName['api_key']! as i1.GeneratedColumn; + i1.GeneratedColumn get zulipVersion => + columnsByName['zulip_version']! as i1.GeneratedColumn; + i1.GeneratedColumn get zulipMergeBase => + columnsByName['zulip_merge_base']! as i1.GeneratedColumn; + i1.GeneratedColumn get zulipFeatureLevel => + columnsByName['zulip_feature_level']! as i1.GeneratedColumn; + i1.GeneratedColumn get ackedPushToken => + columnsByName['acked_push_token']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_0(String aliasedName) => + i1.GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: i1.DriftSqlType.int, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); +i1.GeneratedColumn _column_1(String aliasedName) => + i1.GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_2(String aliasedName) => + i1.GeneratedColumn( + 'user_id', + aliasedName, + false, + type: i1.DriftSqlType.int, + ); +i1.GeneratedColumn _column_3(String aliasedName) => + i1.GeneratedColumn( + 'email', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_4(String aliasedName) => + i1.GeneratedColumn( + 'api_key', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_5(String aliasedName) => + i1.GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_6(String aliasedName) => + i1.GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_7(String aliasedName) => + i1.GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: i1.DriftSqlType.int, + ); +i1.GeneratedColumn _column_8(String aliasedName) => + i1.GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); + +final class Schema3 extends i0.VersionedSchema { + Schema3({required super.database}) : super(version: 3); + @override + late final List entities = [ + globalSettings, + accounts, + ]; + late final Shape1 globalSettings = Shape1( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +class Shape1 extends i0.VersionedTable { + Shape1({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get themeSetting => + columnsByName['theme_setting']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_9(String aliasedName) => + i1.GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); + +final class Schema4 extends i0.VersionedSchema { + Schema4({required super.database}) : super(version: 4); + @override + late final List entities = [ + globalSettings, + accounts, + ]; + late final Shape2 globalSettings = Shape2( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +class Shape2 extends i0.VersionedTable { + Shape2({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get themeSetting => + columnsByName['theme_setting']! as i1.GeneratedColumn; + i1.GeneratedColumn get browserPreference => + columnsByName['browser_preference']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_10(String aliasedName) => + i1.GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); + +final class Schema5 extends i0.VersionedSchema { + Schema5({required super.database}) : super(version: 5); + @override + late final List entities = [ + globalSettings, + accounts, + ]; + late final Shape2 globalSettings = Shape2( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +final class Schema6 extends i0.VersionedSchema { + Schema6({required super.database}) : super(version: 6); + @override + late final List entities = [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + late final Shape2 globalSettings = Shape2( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 boolGlobalSettings = Shape3( + source: i0.VersionedTable( + entityName: 'bool_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_12], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +class Shape3 extends i0.VersionedTable { + Shape3({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get value => + columnsByName['value']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_11(String aliasedName) => + i1.GeneratedColumn( + 'name', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_12(String aliasedName) => + i1.GeneratedColumn( + 'value', + aliasedName, + false, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + +final class Schema7 extends i0.VersionedSchema { + Schema7({required super.database}) : super(version: 7); + @override + late final List entities = [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + late final Shape4 globalSettings = Shape4( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10, _column_13], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 boolGlobalSettings = Shape3( + source: i0.VersionedTable( + entityName: 'bool_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_12], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +class Shape4 extends i0.VersionedTable { + Shape4({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get themeSetting => + columnsByName['theme_setting']! as i1.GeneratedColumn; + i1.GeneratedColumn get browserPreference => + columnsByName['browser_preference']! as i1.GeneratedColumn; + i1.GeneratedColumn get visitFirstUnread => + columnsByName['visit_first_unread']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_13(String aliasedName) => + i1.GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); + +final class Schema8 extends i0.VersionedSchema { + Schema8({required super.database}) : super(version: 8); + @override + late final List entities = [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + late final Shape5 globalSettings = Shape5( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10, _column_13, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 boolGlobalSettings = Shape3( + source: i0.VersionedTable( + entityName: 'bool_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_12], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +class Shape5 extends i0.VersionedTable { + Shape5({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get themeSetting => + columnsByName['theme_setting']! as i1.GeneratedColumn; + i1.GeneratedColumn get browserPreference => + columnsByName['browser_preference']! as i1.GeneratedColumn; + i1.GeneratedColumn get visitFirstUnread => + columnsByName['visit_first_unread']! as i1.GeneratedColumn; + i1.GeneratedColumn get markReadOnScroll => + columnsByName['mark_read_on_scroll']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_14(String aliasedName) => + i1.GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); + +final class Schema9 extends i0.VersionedSchema { + Schema9({required super.database}) : super(version: 9); + @override + late final List entities = [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + late final Shape6 globalSettings = Shape6( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10, _column_13, _column_14, _column_15], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 boolGlobalSettings = Shape3( + source: i0.VersionedTable( + entityName: 'bool_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_12], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +class Shape6 extends i0.VersionedTable { + Shape6({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get themeSetting => + columnsByName['theme_setting']! as i1.GeneratedColumn; + i1.GeneratedColumn get browserPreference => + columnsByName['browser_preference']! as i1.GeneratedColumn; + i1.GeneratedColumn get visitFirstUnread => + columnsByName['visit_first_unread']! as i1.GeneratedColumn; + i1.GeneratedColumn get markReadOnScroll => + columnsByName['mark_read_on_scroll']! as i1.GeneratedColumn; + i1.GeneratedColumn get legacyUpgradeState => + columnsByName['legacy_upgrade_state']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_15(String aliasedName) => + i1.GeneratedColumn( + 'legacy_upgrade_state', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); + +final class Schema10 extends i0.VersionedSchema { + Schema10({required super.database}) : super(version: 10); + @override + late final List entities = [ + globalSettings, + boolGlobalSettings, + intGlobalSettings, + accounts, + ]; + late final Shape6 globalSettings = Shape6( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10, _column_13, _column_14, _column_15], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 boolGlobalSettings = Shape3( + source: i0.VersionedTable( + entityName: 'bool_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_12], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape7 intGlobalSettings = Shape7( + source: i0.VersionedTable( + entityName: 'int_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_16], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +class Shape7 extends i0.VersionedTable { + Shape7({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get value => + columnsByName['value']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_16(String aliasedName) => + i1.GeneratedColumn( + 'value', + aliasedName, + false, + type: i1.DriftSqlType.int, + ); + +final class Schema11 extends i0.VersionedSchema { + Schema11({required super.database}) : super(version: 11); + @override + late final List entities = [ + globalSettings, + boolGlobalSettings, + intGlobalSettings, + accounts, + ]; + late final Shape6 globalSettings = Shape6( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10, _column_13, _column_14, _column_15], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 boolGlobalSettings = Shape3( + source: i0.VersionedTable( + entityName: 'bool_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_12], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape7 intGlobalSettings = Shape7( + source: i0.VersionedTable( + entityName: 'int_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_16], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +i0.MigrationStepWithVersion migrationSteps({ + required Future Function(i1.Migrator m, Schema2 schema) from1To2, + required Future Function(i1.Migrator m, Schema3 schema) from2To3, + required Future Function(i1.Migrator m, Schema4 schema) from3To4, + required Future Function(i1.Migrator m, Schema5 schema) from4To5, + required Future Function(i1.Migrator m, Schema6 schema) from5To6, + required Future Function(i1.Migrator m, Schema7 schema) from6To7, + required Future Function(i1.Migrator m, Schema8 schema) from7To8, + required Future Function(i1.Migrator m, Schema9 schema) from8To9, + required Future Function(i1.Migrator m, Schema10 schema) from9To10, + required Future Function(i1.Migrator m, Schema11 schema) from10To11, +}) { + return (currentVersion, database) async { + switch (currentVersion) { + case 1: + final schema = Schema2(database: database); + final migrator = i1.Migrator(database, schema); + await from1To2(migrator, schema); + return 2; + case 2: + final schema = Schema3(database: database); + final migrator = i1.Migrator(database, schema); + await from2To3(migrator, schema); + return 3; + case 3: + final schema = Schema4(database: database); + final migrator = i1.Migrator(database, schema); + await from3To4(migrator, schema); + return 4; + case 4: + final schema = Schema5(database: database); + final migrator = i1.Migrator(database, schema); + await from4To5(migrator, schema); + return 5; + case 5: + final schema = Schema6(database: database); + final migrator = i1.Migrator(database, schema); + await from5To6(migrator, schema); + return 6; + case 6: + final schema = Schema7(database: database); + final migrator = i1.Migrator(database, schema); + await from6To7(migrator, schema); + return 7; + case 7: + final schema = Schema8(database: database); + final migrator = i1.Migrator(database, schema); + await from7To8(migrator, schema); + return 8; + case 8: + final schema = Schema9(database: database); + final migrator = i1.Migrator(database, schema); + await from8To9(migrator, schema); + return 9; + case 9: + final schema = Schema10(database: database); + final migrator = i1.Migrator(database, schema); + await from9To10(migrator, schema); + return 10; + case 10: + final schema = Schema11(database: database); + final migrator = i1.Migrator(database, schema); + await from10To11(migrator, schema); + return 11; + default: + throw ArgumentError.value('Unknown migration from $currentVersion'); + } + }; +} + +i1.OnUpgrade stepByStep({ + required Future Function(i1.Migrator m, Schema2 schema) from1To2, + required Future Function(i1.Migrator m, Schema3 schema) from2To3, + required Future Function(i1.Migrator m, Schema4 schema) from3To4, + required Future Function(i1.Migrator m, Schema5 schema) from4To5, + required Future Function(i1.Migrator m, Schema6 schema) from5To6, + required Future Function(i1.Migrator m, Schema7 schema) from6To7, + required Future Function(i1.Migrator m, Schema8 schema) from7To8, + required Future Function(i1.Migrator m, Schema9 schema) from8To9, + required Future Function(i1.Migrator m, Schema10 schema) from9To10, + required Future Function(i1.Migrator m, Schema11 schema) from10To11, +}) => i0.VersionedSchema.stepByStepHelper( + step: migrationSteps( + from1To2: from1To2, + from2To3: from2To3, + from3To4: from3To4, + from4To5: from4To5, + from5To6: from5To6, + from6To7: from6To7, + from7To8: from7To8, + from8To9: from8To9, + from9To10: from9To10, + from10To11: from10To11, + ), +); diff --git a/lib/model/server_support.dart b/lib/model/server_support.dart new file mode 100644 index 0000000000..f09a1c5adf --- /dev/null +++ b/lib/model/server_support.dart @@ -0,0 +1,71 @@ +import '../api/core.dart'; +import '../api/exception.dart'; +import '../api/model/initial_snapshot.dart'; +import '../api/route/realm.dart'; +import 'database.dart'; + +/// The fields 'zulip_version', 'zulip_merge_base', and 'zulip_feature_level' +/// from a /server_settings or /register response. +class ZulipVersionData { + const ZulipVersionData({ + required this.zulipVersion, + required this.zulipMergeBase, + required this.zulipFeatureLevel, + }); + + factory ZulipVersionData.fromServerSettings(GetServerSettingsResult serverSettings) => + ZulipVersionData( + zulipVersion: serverSettings.zulipVersion, + zulipMergeBase: serverSettings.zulipMergeBase, + zulipFeatureLevel: serverSettings.zulipFeatureLevel); + + factory ZulipVersionData.fromInitialSnapshot(InitialSnapshot initialSnapshot) => + ZulipVersionData( + zulipVersion: initialSnapshot.zulipVersion, + zulipMergeBase: initialSnapshot.zulipMergeBase, + zulipFeatureLevel: initialSnapshot.zulipFeatureLevel); + + /// Make a [ZulipVersionData] from a [MalformedServerResponseException], + /// if the body was readable/valid JSON and contained the data, else null. + /// + /// May be used for the /server_settings or the /register response. + /// + /// If there's a zulip_version but no zulip_feature_level, + /// we infer it's indeed a Zulip server, + /// just an ancient one before feature levels were introduced in Zulip 3.0, + /// and we set 0 for zulipFeatureLevel. + static ZulipVersionData? fromMalformedServerResponseException(MalformedServerResponseException e) { + try { + final data = e.data!; + return ZulipVersionData( + zulipVersion: data['zulip_version'] as String, + zulipMergeBase: data['zulip_merge_base'] as String?, + zulipFeatureLevel: data['zulip_feature_level'] as int? ?? 0); + } catch (inner) { + return null; + } + } + + final String zulipVersion; + + // The `zulip_merge_base` field was added in server-5, feature level 88. + // We leave it nullable on this class because if a user attempts to connect + // to an ancient Zulip server missing this field, we still want to capture + // the rest of the version data for use in the error message. + final String? zulipMergeBase; + + final int zulipFeatureLevel; + + bool matchesAccount(Account account) => + zulipVersion == account.zulipVersion + && zulipMergeBase == account.zulipMergeBase + && zulipFeatureLevel == account.zulipFeatureLevel; + + bool get isUnsupported => zulipFeatureLevel < kMinSupportedZulipFeatureLevel; +} + +class ServerVersionUnsupportedException implements Exception { + final ZulipVersionData data; + + ServerVersionUnsupportedException(this.data); +} diff --git a/lib/model/settings.dart b/lib/model/settings.dart new file mode 100644 index 0000000000..a85a1f6f74 --- /dev/null +++ b/lib/model/settings.dart @@ -0,0 +1,475 @@ +import 'package:flutter/foundation.dart'; + +import '../generated/l10n/zulip_localizations.dart'; +import 'binding.dart'; +import 'database.dart'; +import 'narrow.dart'; +import 'store.dart'; + +/// The user's choice of visual theme for the app. +/// +/// See [zulipThemeData] for how themes are determined. +/// +/// Renaming existing enum values will invalidate the database. +/// Write a migration if such a change is necessary. +enum ThemeSetting { + /// Corresponds to [Brightness.light]. + light, + + /// Corresponds to [Brightness.dark]. + dark; + + static String displayName({ + required ThemeSetting? themeSetting, + required ZulipLocalizations zulipLocalizations, + }) { + return switch (themeSetting) { + null => zulipLocalizations.themeSettingSystem, + ThemeSetting.light => zulipLocalizations.themeSettingLight, + ThemeSetting.dark => zulipLocalizations.themeSettingDark, + }; + } +} + +/// What browser the user has set to use for opening links in messages. +/// +/// Renaming existing enum values will invalidate the database. +/// Write a migration if such a change is necessary. +enum BrowserPreference { + /// Use the in-app browser for HTTP links. + /// + /// For other types of links (e.g. mailto) where a browser won't work, + /// this falls back to [UrlLaunchMode.platformDefault]. + inApp, + + /// Use the user's default browser app. + external, +} + +/// The user's choice of when to open a message list at their first unread, +/// rather than at the newest message. +/// +/// This setting has no effect when navigating to a specific message: +/// in that case the message list opens at that message, +/// regardless of this setting. +enum VisitFirstUnreadSetting { + /// Always go to the first unread, rather than the newest message. + always, + + /// Go to the first unread in conversations, + /// and the newest in interleaved views. + conversations, + + /// Always go to the newest message, rather than the first unread. + never; + + /// The effective value of this setting if the user hasn't set it. + static VisitFirstUnreadSetting _default = conversations; +} + +/// The user's choice of which message-list views should +/// automatically mark messages as read when scrolling through them. +/// +/// This can be overridden by local state: for example, if you've just tapped +/// "Mark as unread from here" the view will stop marking as read automatically, +/// regardless of this setting. +enum MarkReadOnScrollSetting { + /// All views. + always, + + /// Only conversation views. + conversations, + + /// No views. + never; + + /// The effective value of this setting if the user hasn't set it. + static MarkReadOnScrollSetting _default = conversations; +} + +/// The outcome, or in-progress status, of migrating data from the legacy app. +enum LegacyUpgradeState { + /// It's not yet known whether there was data from the legacy app. + unknown, + + /// No legacy data was found. + noLegacy, + + /// Legacy data was found, but not yet migrated into this app's database. + found, + + /// Legacy data was found and migrated. + migrated, + ; + + static LegacyUpgradeState _default = unknown; +} + +/// A general category of account-independent setting the user might set. +/// +/// Different kinds of settings call for different treatment in the UI, +/// and different expected lifecycles as the app evolves. +enum GlobalSettingType { + /// Describes a non-setting which exists to avoid an empty enum. + /// + /// A Dart enum must have at least one value. + /// But in steady state we expect to have no experimental feature flags. + /// To allow [BoolGlobalSetting] to continue to exist in that situation + /// (so that it stands ready to accept a future feature flag), + /// we give it a placeholder value which isn't a real setting. + placeholder, + + /// Describes a pseudo-setting not directly exposed in the UI. + internal, + + /// Describes a setting which enables an in-progress feature of the app. + /// + /// Sometimes when building a complex feature it's useful to merge PRs that + /// make partial progress, and then to have the feature's logic gated behind + /// a setting that serves as a "feature flag". + /// This enables those working on the feature to enable the flag in order to + /// see the current incomplete behavior, while for everyone else it remains + /// disabled and so (barring bugs in the use of the flag itself) has no effect. + /// + /// These settings are primarily meant for people developing Zulip to use, + /// and so appear in an out-of-the-way part of the settings UI. + /// + /// Settings of this kind are costly to the health of the codebase if + /// allowed to accumulate. Most features don't need one, even features that + /// take two or three PRs to implement. See discussion at: + /// https://github.com/zulip/zulip-flutter/issues/1409#issuecomment-2725793787 + /// When a feature flag is introduced, take care to drive the project to + /// completion, either by merge or removal, so that the flag can be retired + /// within a period of a few weeks or months. + experimentalFeatureFlag, + ; +} + +/// A bool-valued, account-independent setting the user might set. +/// +/// These are recorded in the table [BoolGlobalSettings]. +/// To read the value of one of these settings, use [GlobalSettingsStore.getBool]; +/// to set the value, use [GlobalSettingsStore.setBool]. +/// +/// To introduce a new setting, add a value to this enum. +/// Avoid re-using any old names found in the "former settings" list. +/// +/// To remove a setting, comment it out and move to the "former settings" list. +/// Tracking the names of settings that formerly existed is important because +/// they may still appear in users' databases, which means that if we were to +/// accidentally reuse one for an unrelated new setting then users would +/// unwittingly get those values applied to the new setting, +/// which could cause very confusing buggy behavior. +/// +/// (If the list of former settings gets long, we could do a migration to clear +/// them from existing installs, and then drop the list. We don't do that +/// eagerly each time, to avoid creating a new schema version each time we +/// finish an experimental feature.) +enum BoolGlobalSetting { + /// A non-setting to ensure this enum has at least one value. + /// + /// Leave this in place even when there are experimental feature flags too. + /// That way when we remove those, this is already here. + /// (Having one stable value in this enum is also handy for tests.) + placeholderIgnore(GlobalSettingType.placeholder, false), + + /// A pseudo-setting recording whether the user has been shown the + /// welcome dialog for upgrading from the legacy app. + upgradeWelcomeDialogShown(GlobalSettingType.internal, false), + + /// An experimental flag to enable rendering KaTeX even when some + /// errors are encountered. + forceRenderKatex(GlobalSettingType.experimentalFeatureFlag, false), + + // Former settings which might exist in the database, + // whose names should therefore not be reused: + // openFirstUnread // v0.0.30 + // renderKatex // v0.0.29 - v30.0.261 + ; + + const BoolGlobalSetting(this.type, this.default_); + + /// The general category of setting that this setting belongs to. + final GlobalSettingType type; + + /// The value the setting effectively has if the user hasn't chosen a value. + final bool default_; + + static BoolGlobalSetting? byName(String name) => _byName[name]; + + static final Map _byName = { + for (final v in values) + v.name: v, + }; +} + +/// An int-valued, account-independent setting the user might set. +/// +/// These are recorded in the table [IntGlobalSettings]. +/// To read the value of one of these settings, use [GlobalSettingsStore.getInt]; +/// to set the value, use [GlobalSettingsStore.setInt]. +/// +/// To introduce a new setting, add a value to this enum. +/// Avoid re-using any old names found in the "former settings" list. +/// +/// To remove a setting, comment it out and move to the "former settings" list. +/// Tracking the names of settings that formerly existed is important because +/// they may still appear in users' databases, which means that if we were to +/// accidentally reuse one for an unrelated new setting then users would +/// unwittingly get those values applied to the new setting, +/// which could cause very confusing buggy behavior. +/// +/// (If the list of former settings gets long, we could do a migration to clear +/// them from existing installs, and then drop the list. We don't do that +/// eagerly each time, to avoid creating a new schema version each time we +/// finish an experimental feature.) +enum IntGlobalSetting { + /// A non-setting to ensure this enum has at least one value. + /// + /// (This is also handy to use in tests.) + placeholderIgnore, + + /// A pseudo-setting recording the id of the account the user has visited most + /// recently, from the list of all the available accounts on the device. + /// + /// In some cases, this may point to an account that doesn't actually exist on + /// the device, for example, when the last visited account is logged out and + /// another account is not visited during the same running session. For cases + /// like these, it's the responsibility of the code that reads this value to + /// check for the availability of the account that corresponds to this id. + lastVisitedAccountId, + + // Former settings which might exist in the database, + // whose names should therefore not be reused: + // (this list is empty so far) + ; + + static IntGlobalSetting? byName(String name) => _byName[name]; + + static final Map _byName = { + for (final v in values) + v.name: v, + }; +} + +/// Store for the user's account-independent settings. +/// +/// From UI code, use [GlobalStoreWidget.settingsOf] to get hold of +/// an appropriate instance of this class. +class GlobalSettingsStore extends ChangeNotifier { + GlobalSettingsStore({ + required GlobalStoreBackend backend, + required GlobalSettingsData data, + required Map boolData, + required Map intData, + }) : _backend = backend, _data = data, _boolData = boolData, _intData = intData; + + static final List experimentalFeatureFlags = + BoolGlobalSetting.values.where((setting) => + setting.type == GlobalSettingType.experimentalFeatureFlag).toList(); + + final GlobalStoreBackend _backend; + + /// A cache of the [GlobalSettingsData] singleton in the underlying data store. + GlobalSettingsData _data; + + Future _update(GlobalSettingsCompanion data) async { + await _backend.doUpdateGlobalSettings(data); + _data = _data.copyWithCompanion(data); + notifyListeners(); + } + + /// The user's choice of [ThemeSetting]; + /// null means the device-level choice of theme. + /// + /// See also [setThemeSetting]. + ThemeSetting? get themeSetting => _data.themeSetting; + + /// Set [themeSetting], persistently for future runs of the app. + Future setThemeSetting(ThemeSetting? value) async { + await _update(GlobalSettingsCompanion(themeSetting: Value(value))); + } + + /// The user's choice of [BrowserPreference]; + /// null means use our default choice. + /// + /// Consider using [effectiveBrowserPreference] or [getUrlLaunchMode]. + /// + /// See also [setBrowserPreference]. + BrowserPreference? get browserPreference => _data.browserPreference; + + /// Set [browserPreference], persistently for future runs of the app. + Future setBrowserPreference(BrowserPreference? value) async { + await _update(GlobalSettingsCompanion(browserPreference: Value(value))); + } + + /// The value of [BrowserPreference] to use: + /// the user's choice [browserPreference] if any, else our default. + /// + /// See also [getUrlLaunchMode]. + BrowserPreference get effectiveBrowserPreference { + if (browserPreference != null) return browserPreference!; + return switch (defaultTargetPlatform) { + // On iOS we prefer UrlLaunchMode.externalApplication because (for + // HTTP URLs) UrlLaunchMode.platformDefault uses SFSafariViewController, + // which gives an awkward UX as described here: + // https://chat.zulip.org/#narrow/stream/48-mobile/topic/in-app.20browser/near/1169118 + TargetPlatform.iOS => BrowserPreference.external, + + // On Android we prefer an in-app browser. See discussion from 2021: + // https://chat.zulip.org/#narrow/channel/48-mobile/topic/in-app.20browser/near/1169095 + // That's also the `url_launcher` default (at least as of 2025). + _ => BrowserPreference.inApp, + }; + } + + /// The launch mode to use with `url_launcher`, + /// based on the user's choice in [browserPreference]. + UrlLaunchMode getUrlLaunchMode(Uri url) { + switch (effectiveBrowserPreference) { + case BrowserPreference.inApp: + if (!(url.scheme == 'https' || url.scheme == 'http')) { + // For URLs on non-HTTP schemes such as `mailto`, + // `url_launcher.launchUrl` rejects `inAppBrowserView` with an error: + // https://github.com/flutter/packages/blob/9cc6f370/packages/url_launcher/url_launcher/lib/src/url_launcher_uri.dart#L46-L51 + // TODO(upstream/url_launcher): should fall back in this case instead + // (as the `launchUrl` doc already says it may do). + return UrlLaunchMode.platformDefault; + } + return UrlLaunchMode.inAppBrowserView; + + case BrowserPreference.external: + return UrlLaunchMode.externalApplication; + } + } + + /// The user's choice of [VisitFirstUnreadSetting], applying our default. + /// + /// See also [shouldVisitFirstUnread] and [setVisitFirstUnread]. + VisitFirstUnreadSetting get visitFirstUnread { + return _data.visitFirstUnread ?? VisitFirstUnreadSetting._default; + } + + /// Set [visitFirstUnread], persistently for future runs of the app. + Future setVisitFirstUnread(VisitFirstUnreadSetting value) async { + await _update(GlobalSettingsCompanion(visitFirstUnread: Value(value))); + } + + /// The value that [visitFirstUnread] works out to for the given narrow. + bool shouldVisitFirstUnread({required Narrow narrow}) { + return switch (visitFirstUnread) { + VisitFirstUnreadSetting.always => true, + VisitFirstUnreadSetting.never => false, + VisitFirstUnreadSetting.conversations => switch (narrow) { + TopicNarrow() || DmNarrow() + => true, + CombinedFeedNarrow() || ChannelNarrow() + || MentionsNarrow() || StarredMessagesNarrow() + || KeywordSearchNarrow() + => false, + }, + }; + } + + /// The user's choice of [MarkReadOnScrollSetting], applying our default. + /// + /// See also [markReadOnScrollForNarrow] and [setMarkReadOnScroll]. + MarkReadOnScrollSetting get markReadOnScroll { + return _data.markReadOnScroll ?? MarkReadOnScrollSetting._default; + } + + /// Set [markReadOnScroll], persistently for future runs of the app. + Future setMarkReadOnScroll(MarkReadOnScrollSetting value) async { + await _update(GlobalSettingsCompanion(markReadOnScroll: Value(value))); + } + + /// The value that [markReadOnScroll] works out to for the given narrow. + bool markReadOnScrollForNarrow(Narrow narrow) { + return switch (markReadOnScroll) { + MarkReadOnScrollSetting.always => true, + MarkReadOnScrollSetting.never => false, + MarkReadOnScrollSetting.conversations => switch (narrow) { + TopicNarrow() || DmNarrow() + => true, + CombinedFeedNarrow() || ChannelNarrow() + || MentionsNarrow() || StarredMessagesNarrow() + || KeywordSearchNarrow() + => false, + }, + }; + } + + /// The outcome, or in-progress status, of migrating data from the legacy app. + LegacyUpgradeState get legacyUpgradeState { + return _data.legacyUpgradeState ?? LegacyUpgradeState._default; + } + + /// Set [legacyUpgradeState], persistently for future runs of the app. + @visibleForTesting + Future debugSetLegacyUpgradeState(LegacyUpgradeState value) async { + await _update(GlobalSettingsCompanion(legacyUpgradeState: Value(value))); + } + + /// The user's choice of the given bool-valued setting, or our default for it. + /// + /// See also [setBool]. + bool getBool(BoolGlobalSetting setting) { + return _boolData[setting] ?? setting.default_; + } + + /// A cache of the [BoolGlobalSettings] table in the underlying data store. + final Map _boolData; + + /// Set or unset the given bool-valued setting, + /// persistently for future runs of the app. + /// + /// A value of null means the setting will revert to following + /// the app's default. + /// + /// If [value] equals the setting's current value, the database operation + /// and [notifyListeners] are skipped. + /// + /// See also [getBool]. + Future setBool(BoolGlobalSetting setting, bool? value) async { + if (value == _boolData[setting]) return; + + await _backend.doSetBoolGlobalSetting(setting, value); + if (value == null) { + _boolData.remove(setting); + } else { + _boolData[setting] = value; + } + notifyListeners(); + } + + /// The user's choice of the given int-valued setting, or null if not set. + /// + /// See also [setInt]. + int? getInt(IntGlobalSetting setting) { + return _intData[setting]; + } + + /// A cache of the [IntGlobalSettings] table in the underlying data store. + final Map _intData; + + /// Set or unset the given int-valued setting, + /// persistently for future runs of the app. + /// + /// A value of null means the setting will be cleared out. + /// + /// If [value] equals the setting's current value, the database operation + /// and [notifyListeners] are skipped. + /// + /// See also [getInt]. + Future setInt(IntGlobalSetting setting, int? value) async { + if (value == _intData[setting]) return; + + await _backend.doSetIntGlobalSetting(setting, value); + if (value == null) { + _intData.remove(setting); + } else { + _intData[setting] = value; + } + notifyListeners(); + } +} diff --git a/lib/model/store.dart b/lib/model/store.dart index 7603c7f452..a5da6aa3f8 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -14,26 +14,58 @@ import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import '../api/route/events.dart'; -import '../api/route/messages.dart'; import '../api/backoff.dart'; import '../api/route/realm.dart'; import '../log.dart'; import '../notifications/receive.dart'; +import 'actions.dart'; import 'autocomplete.dart'; import 'database.dart'; import 'emoji.dart'; import 'localizations.dart'; import 'message.dart'; -import 'message_list.dart'; +import 'presence.dart'; +import 'realm.dart'; import 'recent_dm_conversations.dart'; import 'recent_senders.dart'; +import 'server_support.dart'; import 'channel.dart'; +import 'saved_snippet.dart'; +import 'settings.dart'; import 'typing_status.dart'; import 'unreads.dart'; +import 'user.dart'; +import 'user_group.dart'; export 'package:drift/drift.dart' show Value; export 'database.dart' show Account, AccountsCompanion, AccountAlreadyExistsException; +/// An underlying data store that can support a [GlobalStore], +/// possibly storing the data to persist between runs of the app. +/// +/// In the real app, the implementation used is [LiveGlobalStoreBackend], +/// which stores data persistently in a database on the user's device. +/// This interface enables tests to use a different implementation. +abstract class GlobalStoreBackend { + /// Update the global settings in the underlying data store. + /// + /// This should only be called from [GlobalSettingsStore]. + Future doUpdateGlobalSettings(GlobalSettingsCompanion data); + + /// Set or unset the given bool-valued setting in the underlying data store. + /// + /// This should only be called from [GlobalSettingsStore]. + Future doSetBoolGlobalSetting(BoolGlobalSetting setting, bool? value); + + /// Set or unset the given int-valued setting in the underlying data store. + /// + /// This should only be called from [GlobalSettingsStore]. + Future doSetIntGlobalSetting(IntGlobalSetting setting, int? value); + + // TODO move here the similar methods for accounts; + // perhaps the rest of the GlobalStore abstract methods, too. +} + /// Store for all the user's data. /// /// From UI code, use [GlobalStoreWidget.of] to get hold of an appropriate @@ -51,13 +83,28 @@ export 'database.dart' show Account, AccountsCompanion, AccountAlreadyExistsExce /// * [LiveGlobalStore], the implementation of this class that /// we use outside of tests. abstract class GlobalStore extends ChangeNotifier { - GlobalStore({required Iterable accounts}) - : _accounts = Map.fromEntries(accounts.map((a) => MapEntry(a.id, a))); + GlobalStore({ + required GlobalStoreBackend backend, + required GlobalSettingsData globalSettings, + required Map boolGlobalSettings, + required Map intGlobalSettings, + required Iterable accounts, + }) + : settings = GlobalSettingsStore(backend: backend, + data: globalSettings, boolData: boolGlobalSettings, intData: intGlobalSettings), + _accounts = Map.fromEntries(accounts.map((a) => MapEntry(a.id, a))); + + /// The store for the user's account-independent settings. + /// + /// When the settings data changes, the [GlobalSettingsStore] will notify + /// its listeners, but the [GlobalStore] will not notify its own listeners. + /// Consider using [GlobalStoreWidget.settingsOf], which automatically + /// subscribes to changes in the [GlobalSettingsStore]. + final GlobalSettingsStore settings; /// A cache of the [Accounts] table in the underlying data store. final Map _accounts; - // TODO settings (those that are per-device rather than per-account) // TODO push token, and other data corresponding to GlobalSessionState /// Construct a new [ApiConnection], real or fake as appropriate. @@ -122,13 +169,17 @@ abstract class GlobalStore extends ChangeNotifier { // It's up to us. Start loading. future = loadPerAccount(accountId); _perAccountStoresLoading[accountId] = future; - store = await future; - _setPerAccount(accountId, store); - unawaited(_perAccountStoresLoading.remove(accountId)); - return store; + try { + store = await future; + _setPerAccount(accountId, store); + return store; + } finally { + unawaited(_perAccountStoresLoading.remove(accountId)); + } } Future _reloadPerAccount(int accountId) async { + assert(_accounts.containsKey(accountId)); assert(_perAccountStores.containsKey(accountId)); assert(!_perAccountStoresLoading.containsKey(accountId)); final store = await loadPerAccount(accountId); @@ -144,22 +195,65 @@ abstract class GlobalStore extends ChangeNotifier { /// Load per-account data for the given account, unconditionally. /// + /// The account for `accountId` must exist. + /// + /// Throws [AccountNotFoundException] if after any async gap + /// the account has been removed. + /// /// This method should be called only by the implementation of [perAccount]. /// Other callers interested in per-account data should use [perAccount] /// and/or [perAccountSync]. Future loadPerAccount(int accountId) async { assert(_accounts.containsKey(accountId)); - final store = await doLoadPerAccount(accountId); - if (!_accounts.containsKey(accountId)) { - // [removeAccount] was called during [doLoadPerAccount]. - store.dispose(); - throw AccountNotFoundException(); + final PerAccountStore store; + try { + store = await doLoadPerAccount(accountId); + } on AccountNotFoundException { + rethrow; + } catch (e) { + final account = getAccount(accountId); + assert(account != null); // doLoadPerAccount would have thrown AccountNotFoundException + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + switch (e) { + case ServerVersionUnsupportedException(): + reportErrorToUserModally( + zulipLocalizations.errorCouldNotConnectTitle, + message: zulipLocalizations.errorServerVersionUnsupportedMessage( + account!.realmUrl.toString(), + e.data.zulipVersion, + kMinSupportedZulipVersion), + learnMoreButtonUrl: kServerSupportDocUrl); + // The important thing is to tear down per-account UI, + // and logOutAccount conveniently handles that already. + // It's not ideal to force the user to reauthenticate when they retry, + // and we can revisit that later if needed. + await logOutAccount(this, accountId); + throw AccountNotFoundException(); + case HttpException(httpStatus: 401): + // The API key is invalid and the store can never be loaded + // unless the user retries manually. + reportErrorToUserModally( + zulipLocalizations.errorCouldNotConnectTitle, + message: zulipLocalizations.errorInvalidApiKeyMessage( + account!.realmUrl.toString())); + await logOutAccount(this, accountId); + throw AccountNotFoundException(); + default: + rethrow; + } } + // doLoadPerAccount would have thrown AccountNotFoundException + assert(_accounts.containsKey(accountId)); return store; } /// Load per-account data for the given account, unconditionally. /// + /// The account for `accountId` must exist. + /// + /// Throws [AccountNotFoundException] if after any async gap + /// the account has been removed. + /// /// This method should be called only by [loadPerAccount]. Future doLoadPerAccount(int accountId); @@ -175,6 +269,18 @@ abstract class GlobalStore extends ChangeNotifier { Account? getAccount(int id) => _accounts[id]; + Account? get lastVisitedAccount { + final id = settings.getInt(IntGlobalSetting.lastVisitedAccountId); + if (id == null) return null; // No account has been visited yet. + + // (Will be null if `id` refers to an account that has been logged out.) + return getAccount(id); + } + + Future setLastVisitedAccount(int accountId) { + return settings.setInt(IntGlobalSetting.lastVisitedAccountId, accountId); + } + /// Add an account to the store, returning its assigned account ID. Future insertAccount(AccountsCompanion data) async { final account = await doInsertAccount(data); @@ -206,10 +312,23 @@ abstract class GlobalStore extends ChangeNotifier { return result; } + /// Update an account with [ZulipVersionData], returning the new version. + /// + /// The account must already exist in the store. + Future updateZulipVersionData(int accountId, ZulipVersionData data) async { + assert(_accounts.containsKey(accountId)); + return updateAccount(accountId, AccountsCompanion( + zulipVersion: Value(data.zulipVersion), + zulipMergeBase: Value(data.zulipMergeBase), + zulipFeatureLevel: Value(data.zulipFeatureLevel))); + } + /// Update an account in the underlying data store. Future doUpdateAccount(int accountId, AccountsCompanion data); /// Remove an account from the store. + /// + /// The account for `accountId` must exist. Future removeAccount(int accountId) async { assert(_accounts.containsKey(accountId)); await doRemoveAccount(accountId); @@ -229,6 +348,102 @@ abstract class GlobalStore extends ChangeNotifier { class AccountNotFoundException implements Exception {} +/// A bundle of items that are useful to [PerAccountStore] and its substores. +/// +/// Each instance of this class is constructed as part of constructing a +/// [PerAccountStore] instance, +/// and is shared by that [PerAccountStore] and its substores. +/// Calling [PerAccountStore.dispose] also disposes the [CorePerAccountStore] +/// (for example, it calls [ApiConnection.dispose] on [connection]). +class CorePerAccountStore { + CorePerAccountStore._({ + required GlobalStore globalStore, + required this.connection, + required this.queueId, + required this.accountId, + required this.selfUserId, + }) : _globalStore = globalStore, + assert(connection.realmUrl == globalStore.getAccount(accountId)!.realmUrl); + + final GlobalStore _globalStore; + final ApiConnection connection; // TODO(#135): update zulipFeatureLevel with events + final String queueId; + final int accountId; + + // This isn't strictly needed as a field; it could be a getter + // that uses `_globalStore.getAccount(accountId)`. + // But we denormalize it here to save a hash-table lookup every time + // the self-user ID is needed, which can be often. + // It never changes on the account; see [GlobalStore.updateAccount]. + final int selfUserId; +} + +/// A base class for [PerAccountStore] and its substores, +/// with getters providing the items in [CorePerAccountStore]. +abstract class PerAccountStoreBase { + PerAccountStoreBase({required this.core}); + + @protected + final CorePerAccountStore core; + + //|////////////////////////////// + // Where data comes from in the first place. + + GlobalStore get _globalStore => core._globalStore; + + ApiConnection get connection => core.connection; + + String get queueId => core.queueId; + + //|////////////////////////////// + // Data attached to the realm or the server. + + /// Always equal to `account.realmUrl` and `connection.realmUrl`. + Uri get realmUrl => connection.realmUrl; + + /// Resolve [reference] as a URL relative to [realmUrl]. + /// + /// This returns null if [reference] fails to parse as a URL. + Uri? tryResolveUrl(String reference) => _tryResolveUrl(realmUrl, reference); + + /// Always equal to `connection.zulipFeatureLevel` + /// and `account.zulipFeatureLevel`. + int get zulipFeatureLevel => connection.zulipFeatureLevel!; + + String get zulipVersion => account.zulipVersion; + + //|////////////////////////////// + // Data attached to the self-account on the realm. + + int get accountId => core.accountId; + + /// The [Account] this store belongs to. + /// + /// Will throw if the account has been removed from the global store, + /// which is possible only if [PerAccountStore.dispose] has been called + /// on this store. + Account get account => _globalStore.getAccount(accountId)!; + + /// The user ID of the "self-user", + /// i.e. the account the person using this app is logged into. + /// + /// This always equals the [Account.userId] on [account]. + /// + /// For the corresponding [User] object, see [UserStore.selfUser]. + int get selfUserId => core.selfUserId; +} + +const _tryResolveUrl = tryResolveUrl; + +/// Like [Uri.resolve], but on failure return null instead of throwing. +Uri? tryResolveUrl(Uri baseUrl, String reference) { + try { + return baseUrl.resolve(reference); + } on FormatException { + return null; + } +} + /// Store for the user's data for a given Zulip account. /// /// This should always have a consistent snapshot of the state on the server, @@ -237,7 +452,15 @@ class AccountNotFoundException implements Exception {} /// This class does not attempt to poll an event queue /// to keep the data up to date. For that behavior, see /// [UpdateMachine]. -class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, MessageStore { +class PerAccountStore extends PerAccountStoreBase with + ChangeNotifier, + UserGroupStore, ProxyUserGroupStore, + RealmStore, ProxyRealmStore, + EmojiStore, ProxyEmojiStore, + SavedSnippetStore, + UserStore, ProxyUserStore, + ChannelStore, ProxyChannelStore, + MessageStore, ProxyMessageStore { /// Construct a store for the user's data, starting from the given snapshot. /// /// The global store must already have been updated with @@ -262,94 +485,97 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess connection ??= globalStore.apiConnectionFromAccount(account); assert(connection.zulipFeatureLevel == account.zulipFeatureLevel); - final realmUrl = account.realmUrl; - final channels = ChannelStoreImpl(initialSnapshot: initialSnapshot); - return PerAccountStore._( + final queueId = initialSnapshot.queueId; + if (queueId == null) { + // The queueId is optional in the type, but should only be missing in the + // case of unauthenticated access to a web-public realm. We authenticated. + throw Exception("bad initial snapshot: missing queueId"); + } + + final core = CorePerAccountStore._( globalStore: globalStore, connection: connection, - realmUrl: realmUrl, - realmWildcardMentionPolicy: initialSnapshot.realmWildcardMentionPolicy, - realmMandatoryTopics: initialSnapshot.realmMandatoryTopics, - realmWaitingPeriodThreshold: initialSnapshot.realmWaitingPeriodThreshold, - maxFileUploadSizeMib: initialSnapshot.maxFileUploadSizeMib, - realmDefaultExternalAccounts: initialSnapshot.realmDefaultExternalAccounts, - customProfileFields: _sortCustomProfileFields(initialSnapshot.customProfileFields), - emailAddressVisibility: initialSnapshot.emailAddressVisibility, - emoji: EmojiStoreImpl( - realmUrl: realmUrl, allRealmEmoji: initialSnapshot.realmEmoji), + queueId: queueId, accountId: accountId, selfUserId: account.userId, + ); + + final userMap = UserStoreImpl.userMapFromInitialSnapshot(initialSnapshot); + final selfUser = userMap[core.selfUserId]; + if (selfUser == null) { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + reportErrorToUserModally( + zulipLocalizations.errorCouldNotConnectTitle, + message: zulipLocalizations.errorMalformedResponseWithCause(200, + // skip-i18n: This would be an unlikely bug (in the server?). We're + // showing the user these details at all only because it would be a + // very nasty bug (so, important to resolve ASAP) if it ever did happen. + 'self-user missing from user list')); + throw Exception("bad initial snapshot: self-user missing from user list"); + } + + final groups = UserGroupStoreImpl(core: core, + groups: initialSnapshot.realmUserGroups); + final realm = RealmStoreImpl(groups: groups, initialSnapshot: initialSnapshot, + selfUser: selfUser); + final users = UserStoreImpl(realm: realm, initialSnapshot: initialSnapshot, + userMap: userMap); + final channels = ChannelStoreImpl(users: users, + initialSnapshot: initialSnapshot); + return PerAccountStore._( + core: core, + groups: groups, + realm: realm, + emoji: EmojiStoreImpl(core: core, + allRealmEmoji: initialSnapshot.realmEmoji), userSettings: initialSnapshot.userSettings, - typingNotifier: TypingNotifier( - connection: connection, - typingStoppedWaitPeriod: Duration( - milliseconds: initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds), - typingStartedWaitPeriod: Duration( - milliseconds: initialSnapshot.serverTypingStartedWaitPeriodMilliseconds), - ), - users: Map.fromEntries( - initialSnapshot.realmUsers - .followedBy(initialSnapshot.realmNonActiveUsers) - .followedBy(initialSnapshot.crossRealmBots) - .map((user) => MapEntry(user.userId, user))), - typingStatus: TypingStatus( - selfUserId: account.userId, - typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds), - ), + savedSnippets: SavedSnippetStoreImpl(core: core, + savedSnippets: initialSnapshot.savedSnippets ?? []), + typingNotifier: TypingNotifier(realm: realm), + users: users, + typingStatus: TypingStatus(realm: realm), + presence: Presence(realm: realm, + initial: initialSnapshot.presences), channels: channels, - messages: MessageStoreImpl(), - unreads: Unreads( - initial: initialSnapshot.unreadMsgs, - selfUserId: account.userId, - channelStore: channels, - ), - recentDmConversationsView: RecentDmConversationsView( - initial: initialSnapshot.recentPrivateConversations, selfUserId: account.userId), + messages: MessageStoreImpl(channels: channels), + unreads: Unreads(core: core, channelStore: channels, + initial: initialSnapshot.unreadMsgs), + recentDmConversationsView: RecentDmConversationsView(core: core, + initial: initialSnapshot.recentPrivateConversations), recentSenders: RecentSenders(), ); } PerAccountStore._({ - required GlobalStore globalStore, - required this.connection, - required this.realmUrl, - required this.realmWildcardMentionPolicy, - required this.realmMandatoryTopics, - required this.realmWaitingPeriodThreshold, - required this.maxFileUploadSizeMib, - required this.realmDefaultExternalAccounts, - required this.customProfileFields, - required this.emailAddressVisibility, + required super.core, + required UserGroupStoreImpl groups, + required RealmStoreImpl realm, required EmojiStoreImpl emoji, - required this.accountId, - required this.selfUserId, required this.userSettings, + required SavedSnippetStoreImpl savedSnippets, required this.typingNotifier, - required this.users, + required UserStoreImpl users, required this.typingStatus, + required this.presence, required ChannelStoreImpl channels, required MessageStoreImpl messages, required this.unreads, required this.recentDmConversationsView, required this.recentSenders, - }) : assert(selfUserId == globalStore.getAccount(accountId)!.userId), - assert(realmUrl == globalStore.getAccount(accountId)!.realmUrl), - assert(realmUrl == connection.realmUrl), - assert(emoji.realmUrl == realmUrl), - _globalStore = globalStore, + }) : _groups = groups, + _realm = realm, _emoji = emoji, + _savedSnippets = savedSnippets, + _users = users, _channels = channels, _messages = messages; - //////////////////////////////////////////////////////////////// + //|////////////////////////////////////////////////////////////// // Data. - //////////////////////////////// + //|////////////////////////////// // Where data comes from in the first place. - final GlobalStore _globalStore; - final ApiConnection connection; // TODO(#135): update zulipFeatureLevel with events - UpdateMachine? get updateMachine => _updateMachine; UpdateMachine? _updateMachine; set updateMachine(UpdateMachine? value) { @@ -358,173 +584,100 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess _updateMachine = value; } - bool get isLoading => _isLoading; - bool _isLoading = false; + bool get isRecoveringEventStream => _isRecoveringEventStream; + bool _isRecoveringEventStream = false; @visibleForTesting - set isLoading(bool value) { - if (_isLoading == value) return; - _isLoading = value; + set isRecoveringEventStream(bool value) { + if (_isRecoveringEventStream == value) return; + _isRecoveringEventStream = value; notifyListeners(); } - //////////////////////////////// + //|////////////////////////////// // Data attached to the realm or the server. - /// Always equal to `account.realmUrl` and `connection.realmUrl`. - final Uri realmUrl; - - /// Resolve [reference] as a URL relative to [realmUrl]. - /// - /// This returns null if [reference] fails to parse as a URL. - 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. - final Map realmDefaultExternalAccounts; - List customProfileFields; - /// For docs, please see [InitialSnapshot.emailAddressVisibility]. - final EmailAddressVisibility? emailAddressVisibility; // TODO(#668): update this realm setting - - //////////////////////////////// - // The realm's repertoire of available emoji. - + // (User groups come before even realm settings, + // because they'll be used for interpreting many realm settings.) + @protected @override - EmojiDisplay emojiDisplayFor({ - required ReactionType emojiType, - required String emojiCode, - required String emojiName - }) { - return _emoji.emojiDisplayFor( - emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName); - } + UserGroupStore get userGroupStore => _groups; + final UserGroupStoreImpl _groups; + @protected @override - Map>? get debugServerEmojiData => _emoji.debugServerEmojiData; + RealmStore get realmStore => _realm; + final RealmStoreImpl _realm; - @override void setServerEmojiData(ServerEmojiData data) { _emoji.setServerEmojiData(data); notifyListeners(); } + @protected @override - Iterable allEmojiCandidates() => _emoji.allEmojiCandidates(); - - EmojiStoreImpl _emoji; + EmojiStore get emojiStore => _emoji; + final EmojiStoreImpl _emoji; - //////////////////////////////// + //|////////////////////////////// // Data attached to the self-account on the realm. - final int accountId; + final UserSettings userSettings; - /// The [Account] this store belongs to. - /// - /// Will throw if called after [dispose] has been called. - Account get account => _globalStore.getAccount(accountId)!; - - /// Always equal to `account.userId`. - final int selfUserId; - - final UserSettings? userSettings; // TODO(server-5) + @override + Map get savedSnippets => _savedSnippets.savedSnippets; + final SavedSnippetStoreImpl _savedSnippets; final TypingNotifier typingNotifier; - //////////////////////////////// + //|////////////////////////////// // Users and data about them. - final Map users; + @protected + @override + UserStore get userStore => _users; + final UserStoreImpl _users; final TypingStatus typingStatus; - /// Whether [user] has passed the realm's waiting period to be a full member. - /// - /// See: - /// https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member - /// - /// To determine if a user is a full member, callers must also check that the - /// user's role is at least [UserRole.member]. - bool hasPassedWaitingPeriod(User user, {required DateTime byDate}) { - // [User.dateJoined] is in UTC. For logged-in users, the format is: - // YYYY-MM-DDTHH:mm+00:00, which includes the timezone offset for UTC. - // For logged-out spectators, the format is: YYYY-MM-DD, which doesn't - // include the timezone offset. In the later case, [DateTime.parse] will - // interpret it as the client's local timezone, which could lead to - // incorrect results; but that's acceptable for now because the app - // doesn't support viewing as a spectator. - // - // See the related discussion: - // https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/provide.20an.20explicit.20format.20for.20.60realm_user.2Edate_joined.60/near/1980194 - final dateJoined = DateTime.parse(user.dateJoined); - return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; - } + final Presence presence; - //////////////////////////////// + //|////////////////////////////// // Streams, topics, and stuff about them. + @protected @override - Map get streams => _channels.streams; - @override - Map get streamsByName => _channels.streamsByName; - @override - Map get subscriptions => _channels.subscriptions; - @override - UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) => - _channels.topicVisibilityPolicy(streamId, topic); - @override - Map> get debugTopicVisibility => - _channels.debugTopicVisibility; - + ChannelStore get channelStore => _channels; final ChannelStoreImpl _channels; - bool hasPostingPermission({ - required ZulipStream inChannel, - required User user, - required DateTime byDate, - }) { - final role = user.role; - // We let the users with [unknown] role to send the message, then the server - // will decide to accept it or not based on its actual role. - if (role == UserRole.unknown) return true; - - switch (inChannel.channelPostPolicy) { - case ChannelPostPolicy.any: return true; - case ChannelPostPolicy.fullMembers: { - if (!role.isAtLeast(UserRole.member)) return false; - return role == UserRole.member - ? hasPassedWaitingPeriod(user, byDate: byDate) - : true; - } - case ChannelPostPolicy.moderators: return role.isAtLeast(UserRole.moderator); - case ChannelPostPolicy.administrators: return role.isAtLeast(UserRole.administrator); - case ChannelPostPolicy.unknown: return true; - } - } - - //////////////////////////////// + //|////////////////////////////// // Messages, and summaries of messages. - @override - Map get messages => _messages.messages; - @override - void registerMessageList(MessageListView view) => - _messages.registerMessageList(view); - @override - void unregisterMessageList(MessageListView view) => - _messages.unregisterMessageList(view); - @override + /// Reconcile a batch of just-fetched messages with the store, + /// mutating the list. + /// + /// This is called after a [getMessages] request to report the result + /// to the store. + /// + /// The list's length will not change, but some entries may be replaced + /// by a different [Message] object with the same [Message.id], + /// and the store will also be updated. + /// When this method returns, all [Message] objects in the list + /// will be present in the map `this.messages`. + /// + /// The list entries may be mutated to remove + /// [Message.matchContent] and [Message.matchTopic] + /// (since these are appropriate for search views but not the central store). + /// The values of those fields should therefore be captured, + /// as needed for search, before this is called. void reconcileMessages(List messages) { _messages.reconcileMessages(messages); // TODO(#649) notify [unreads] of the just-fetched messages // TODO(#650) notify [recentDmConversationsView] of the just-fetched messages } + @protected @override - Set get debugMessageListViews => _messages.debugMessageListViews; - + MessageStore get messageStore => _messages; final MessageStoreImpl _messages; final Unreads unreads; @@ -533,13 +686,13 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess final RecentSenders recentSenders; - //////////////////////////////// + //|////////////////////////////// // Other digests of data. final AutocompleteViewManager autocompleteViewManager = AutocompleteViewManager(); // End of data. - //////////////////////////////////////////////////////////////// + //|////////////////////////////////////////////////////////////// /// Called when the app is reassembled during debugging, e.g. for hot reload. /// @@ -558,6 +711,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess recentDmConversationsView.dispose(); unreads.dispose(); _messages.dispose(); + presence.dispose(); typingStatus.dispose(); typingNotifier.dispose(); updateMachine?.dispose(); @@ -590,62 +744,50 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess } switch (event.property!) { case UserSettingName.twentyFourHourTime: - userSettings?.twentyFourHourTime = event.value as bool; + userSettings.twentyFourHourTime = event.value as TwentyFourHourTimeMode; case UserSettingName.displayEmojiReactionUsers: - userSettings?.displayEmojiReactionUsers = event.value as bool; + userSettings.displayEmojiReactionUsers = event.value as bool; case UserSettingName.emojiset: - userSettings?.emojiset = event.value as Emojiset; + userSettings.emojiset = event.value as Emojiset; + case UserSettingName.presenceEnabled: + userSettings.presenceEnabled = event.value as bool; } notifyListeners(); case CustomProfileFieldsEvent(): assert(debugLog("server event: custom_profile_fields")); - customProfileFields = _sortCustomProfileFields(event.fields); + _realm.handleCustomProfileFieldsEvent(event); + notifyListeners(); + + case UserGroupEvent(): + assert(debugLog("server event: user_group/${event.op}")); + _groups.handleUserGroupEvent(event); notifyListeners(); case RealmUserAddEvent(): assert(debugLog("server event: realm_user/add")); - users[event.person.userId] = event.person; + _users.handleRealmUserEvent(event); notifyListeners(); case RealmUserRemoveEvent(): assert(debugLog("server event: realm_user/remove")); - users.remove(event.userId); + _users.handleRealmUserEvent(event); autocompleteViewManager.handleRealmUserRemoveEvent(event); notifyListeners(); case RealmUserUpdateEvent(): assert(debugLog("server event: realm_user/update")); - final user = users[event.userId]; - if (user == null) { - return; // TODO log - } - if (event.fullName != null) user.fullName = event.fullName!; - if (event.avatarUrl != null) user.avatarUrl = event.avatarUrl!; - if (event.avatarVersion != null) user.avatarVersion = event.avatarVersion!; - if (event.timezone != null) user.timezone = event.timezone!; - if (event.botOwnerId != null) user.botOwnerId = event.botOwnerId!; - if (event.role != null) user.role = event.role!; - if (event.isBillingAdmin != null) user.isBillingAdmin = event.isBillingAdmin!; - if (event.deliveryEmail != null) user.deliveryEmail = event.deliveryEmail!.value; - if (event.newEmail != null) user.email = event.newEmail!; - if (event.isActive != null) user.isActive = event.isActive!; - if (event.customProfileField != null) { - final profileData = (user.profileData ??= {}); - final update = event.customProfileField!; - if (update.value != null) { - profileData[update.id] = ProfileFieldUserData(value: update.value!, renderedValue: update.renderedValue); - } else { - profileData.remove(update.id); - } - if (profileData.isEmpty) { - // null is equivalent to `{}` for efficiency; see [User._readProfileData]. - user.profileData = null; - } - } + _groups.handleRealmUserUpdateEvent(event); + _realm.handleRealmUserUpdateEvent(event); + _users.handleRealmUserEvent(event); autocompleteViewManager.handleRealmUserUpdateEvent(event); notifyListeners(); + case SavedSnippetsEvent(): + assert(debugLog('server event: saved_snippets/${event.op}')); + _savedSnippets.handleSavedSnippetsEvent(event); + notifyListeners(); + case ChannelEvent(): assert(debugLog("server event: stream/${event.op}")); _channels.handleChannelEvent(event); @@ -656,6 +798,11 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess _channels.handleSubscriptionEvent(event); notifyListeners(); + case UserStatusEvent(): + assert(debugLog("server event: user_status")); + _users.handleUserStatusEvent(event); + notifyListeners(); + case UserTopicEvent(): assert(debugLog("server event: user_topic")); _messages.handleUserTopicEvent(event); @@ -665,6 +812,10 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess case MessageEvent(): assert(debugLog("server event: message ${jsonEncode(event.message.toJson())}")); + // Assert against malformed events that might be created in test code. + assert(event.message.matchContent == null); + assert(event.message.matchTopic == null); + _messages.handleMessageEvent(event); unreads.handleMessageEvent(event); recentDmConversationsView.handleMessageEvent(event); @@ -700,55 +851,69 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess assert(debugLog("server event: typing/${event.op} ${event.messageType}")); typingStatus.handleTypingEvent(event); + case PresenceEvent(): + assert(debugLog("server event: presence ${event.userId}")); + // TODO(#1618) handle + break; + case ReactionEvent(): assert(debugLog("server event: reaction/${event.op}")); _messages.handleReactionEvent(event); + case MutedUsersEvent(): + assert(debugLog("server event: muted_users")); + _messages.handleMutedUsersEvent(event); + // Update _users last, so other handlers can compare to the old value. + _users.handleMutedUsersEvent(event); + notifyListeners(); + case UnexpectedEvent(): assert(debugLog("server event: ${jsonEncode(event.toJson())}")); // TODO log better } } - Future sendMessage({required MessageDestination destination, required String content}) { - assert(!_disposed); + @override + String toString() => '${objectRuntimeType(this, 'PerAccountStore')}#${shortHash(this)}'; +} - // TODO implement outbox; see design at - // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/.23M3881.20Sending.20outbox.20messages.20is.20fraught.20with.20issues/near/1405739 - return _apiSendMessage(connection, - destination: destination, - content: content, - readBySender: true, - ); - } +/// A [GlobalStoreBackend] that uses a live, persistent local database. +/// +/// Used as part of a [LiveGlobalStore]. +/// The underlying data store is an [AppDatabase] corresponding to a +/// SQLite database file in the app's persistent storage on the device. +class LiveGlobalStoreBackend implements GlobalStoreBackend { + LiveGlobalStoreBackend._({required AppDatabase db}) : _db = db; - static List _sortCustomProfileFields(List initialCustomProfileFields) { - // TODO(server): The realm-wide field objects have an `order` property, - // but the actual API appears to be that the fields should be shown in - // the order they appear in the array (`custom_profile_fields` in the - // API; our `realmFields` array here.) See chat thread: - // https://chat.zulip.org/#narrow/stream/378-api-design/topic/custom.20profile.20fields/near/1382982 - // - // We go on to put at the start of the list any fields that are marked for - // displaying in the "profile summary". (Possibly they should be at the - // start of the list in the first place, but make sure just in case.) - final displayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary == true); - final nonDisplayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary != true); - return displayFields.followedBy(nonDisplayFields).toList(); - } + final AppDatabase _db; @override - String toString() => '${objectRuntimeType(this, 'PerAccountStore')}#${shortHash(this)}'; -} + Future doUpdateGlobalSettings(GlobalSettingsCompanion data) async { + final rowsAffected = await _db.update(_db.globalSettings).write(data); + assert(rowsAffected == 1); + } -const _apiSendMessage = sendMessage; // Bit ugly; for alternatives, see: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20PerAccountStore.20methods/near/1545809 -const _tryResolveUrl = tryResolveUrl; + @override + Future doSetBoolGlobalSetting(BoolGlobalSetting setting, bool? value) async { + if (value == null) { + final query = _db.delete(_db.boolGlobalSettings) + ..where((r) => r.name.equals(setting.name)); + await query.go(); + } else { + await _db.into(_db.boolGlobalSettings).insertOnConflictUpdate( + BoolGlobalSettingRow(name: setting.name, value: value)); + } + } -/// Like [Uri.resolve], but on failure return null instead of throwing. -Uri? tryResolveUrl(Uri baseUrl, String reference) { - try { - return baseUrl.resolve(reference); - } on FormatException { - return null; + @override + Future doSetIntGlobalSetting(IntGlobalSetting setting, int? value) async { + if (value == null) { + await (_db.delete(_db.intGlobalSettings) + ..where((r) => r.name.equals(setting.name)) + ).go(); + } else { + await _db.into(_db.intGlobalSettings).insertOnConflictUpdate( + IntGlobalSettingRow(name: setting.name, value: value)); + } } } @@ -761,9 +926,13 @@ Uri? tryResolveUrl(Uri baseUrl, String reference) { /// and will have an associated [UpdateMachine]. class LiveGlobalStore extends GlobalStore { LiveGlobalStore._({ - required AppDatabase db, + required LiveGlobalStoreBackend backend, + required super.globalSettings, + required super.boolGlobalSettings, + required super.intGlobalSettings, required super.accounts, - }) : _db = db; + }) : _backend = backend, + super(backend: backend); @override ApiConnection apiConnection({ @@ -777,9 +946,38 @@ class LiveGlobalStore extends GlobalStore { // We keep the API simple and synchronous for the bulk of the app's code // by doing this loading up front before constructing a [GlobalStore]. static Future load() async { + // Loading this data takes roughly 80-100ms (measured on a Pixel 8). + // That's only a small increment on the time spent loading server data, + // so we don't worry about optimizing it further. + // In a future where we keep server data locally between runs (#477) -- + // which will also mean having much more data to load from the database -- + // we'd invest in this area more. For example we'd try doing these + // in parallel, or deferring some to be concurrent with loading server data. + final stopwatch = Stopwatch()..start(); final db = AppDatabase(NativeDatabase.createInBackground(await _dbFile())); + final t1 = stopwatch.elapsed; + final globalSettings = await db.getGlobalSettings(); + final t2 = stopwatch.elapsed; + final boolGlobalSettings = await db.getBoolGlobalSettings(); + final t3 = stopwatch.elapsed; + final intGlobalSettings = await db.getIntGlobalSettings(); + final t4 = stopwatch.elapsed; final accounts = await db.select(db.accounts).get(); - return LiveGlobalStore._(db: db, accounts: accounts); + final t5 = stopwatch.elapsed; + if (kProfileMode) { + String format(Duration d) => + "${(d.inMicroseconds / 1000.0).toStringAsFixed(1)}ms"; + profilePrint("db load time ${format(t5)} total: ${format(t1)} init, " + "${format(t2 - t1)} settings, ${format(t3 - t2)} bool-settings, " + "${format(t4 - t3)} int-settings, ${format(t5 - t4)} accounts"); + } + + return LiveGlobalStore._( + backend: LiveGlobalStoreBackend._(db: db), + globalSettings: globalSettings, + boolGlobalSettings: boolGlobalSettings, + intGlobalSettings: intGlobalSettings, + accounts: accounts); } /// The file path to use for the app database. @@ -787,7 +985,7 @@ class LiveGlobalStore extends GlobalStore { // What directory should we use? // path_provider's getApplicationSupportDirectory: // on Android, -> Flutter's PathUtils.getFilesDir -> https://developer.android.com/reference/android/content/Context#getFilesDir() - // -> empirically /data/data/com.zulip.flutter/files/ + // -> empirically /data/data/com.zulipmobile/files/ // on iOS, -> "Library/Application Support" via https://developer.apple.com/documentation/foundation/nssearchpathdirectory/nsapplicationsupportdirectory // on Linux, -> "${XDG_DATA_HOME:-~/.local/share}/com.zulip.flutter/" // All seem reasonable. @@ -801,7 +999,13 @@ class LiveGlobalStore extends GlobalStore { return File(p.join(dir.path, 'zulip.db')); } - final AppDatabase _db; + final LiveGlobalStoreBackend _backend; + + // The methods that use this should probably all move to [GlobalStoreBackend] + // and [LiveGlobalStoreBackend] anyway (see comment on the former); + // so let the latter be the canonical home of the [AppDatabase]. + // This getter just simplifies the transition. + AppDatabase get _db => _backend._db; @override Future doLoadPerAccount(int accountId) async { @@ -847,36 +1051,56 @@ class UpdateMachine { UpdateMachine.fromInitialSnapshot({ required this.store, required InitialSnapshot initialSnapshot, - }) : queueId = initialSnapshot.queueId ?? (() { - // The queueId is optional in the type, but should only be missing in the - // case of unauthenticated access to a web-public realm. We authenticated. - throw Exception("bad initial snapshot: missing queueId"); - })(), - lastEventId = initialSnapshot.lastEventId { + }) : lastEventId = initialSnapshot.lastEventId { store.updateMachine = this; } - /// Load the user's data from the server, and start an event queue going. + /// Load data for the given account from the server, + /// and start an event queue going. + /// + /// The account for `accountId` must exist. + /// + /// Throws [AccountNotFoundException] if after any async gap + /// the account has been removed. /// /// In the future this might load an old snapshot from local storage first. static Future load(GlobalStore globalStore, int accountId) async { - Account account = globalStore.getAccount(accountId)!; - final connection = globalStore.apiConnectionFromAccount(account); + final connection = globalStore.apiConnectionFromAccount( + globalStore.getAccount(accountId)!); + + void stopAndThrowIfNoAccount() { + final account = globalStore.getAccount(accountId); + if (account == null) { + assert(debugLog('Account logged out during UpdateMachine.load')); + connection.close(); + throw AccountNotFoundException(); + } + } final stopwatch = Stopwatch()..start(); - final initialSnapshot = await _registerQueueWithRetry(connection); - final t = (stopwatch..stop()).elapsed; - assert(debugLog("initial fetch time: ${t.inMilliseconds}ms")); - - if (initialSnapshot.zulipVersion != account.zulipVersion - || initialSnapshot.zulipMergeBase != account.zulipMergeBase - || initialSnapshot.zulipFeatureLevel != account.zulipFeatureLevel) { - account = await globalStore.updateAccount(accountId, AccountsCompanion( - zulipVersion: Value(initialSnapshot.zulipVersion), - zulipMergeBase: Value(initialSnapshot.zulipMergeBase), - zulipFeatureLevel: Value(initialSnapshot.zulipFeatureLevel), - )); - connection.zulipFeatureLevel = initialSnapshot.zulipFeatureLevel; + InitialSnapshot? initialSnapshot; + try { + initialSnapshot = await _registerQueueWithRetry(connection, + stopAndThrowIfNoAccount: stopAndThrowIfNoAccount); + } on ServerVersionUnsupportedException catch (e) { + // `!` is OK because _registerQueueWithRetry would have thrown a + // not-ServerVersionUnsupportedException if no account + final account = globalStore.getAccount(accountId)!; + if (!e.data.matchesAccount(account)) { + await globalStore.updateZulipVersionData(accountId, e.data); + } + connection.close(); + rethrow; + } + if (kProfileMode) { + profilePrint("initial fetch time: ${stopwatch.elapsed.inMilliseconds}ms"); + } + + final zulipVersionData = ZulipVersionData.fromInitialSnapshot(initialSnapshot); + // `!` is OK because _registerQueueWithRetry would have thrown if no account + if (!zulipVersionData.matchesAccount(globalStore.getAccount(accountId)!)) { + await globalStore.updateZulipVersionData(accountId, zulipVersionData); + connection.zulipFeatureLevel = zulipVersionData.zulipFeatureLevel; } final store = PerAccountStore.fromInitialSnapshot( @@ -897,31 +1121,59 @@ class UpdateMachine { // TODO do registerNotificationToken before registerQueue: // https://github.com/zulip/zulip-flutter/pull/325#discussion_r1365982807 unawaited(updateMachine.registerNotificationToken()); + store.presence.start(); return updateMachine; } final PerAccountStore store; - final String queueId; int lastEventId; bool _disposed = false; + /// Make the register-queue request, with retries. + /// + /// After each async gap, calls [stopAndThrowIfNoAccount]. static Future _registerQueueWithRetry( - ApiConnection connection) async { + ApiConnection connection, { + required void Function() stopAndThrowIfNoAccount, + }) async { BackoffMachine? backoffMachine; while (true) { + InitialSnapshot? result; try { - return await registerQueue(connection); + result = await registerQueue(connection); } catch (e, s) { - assert(debugLog('Error fetching initial snapshot: $e')); - // Print stack trace in its own log entry; log entries are truncated - // at 1 kiB (at least on Android), and stack can be longer than that. - assert(debugLog('Stack:\n$s')); + stopAndThrowIfNoAccount(); + // TODO(#890): tell user if initial-fetch errors persist, or look non-transient + final ZulipVersionData? zulipVersionData; + switch (e) { + case MalformedServerResponseException() + when (zulipVersionData = ZulipVersionData.fromMalformedServerResponseException(e)) + ?.isUnsupported == true: + throw ServerVersionUnsupportedException(zulipVersionData!); + case HttpException(httpStatus: 401): + // We cannot recover from this error through retrying. + // Leave it to [GlobalStore.loadPerAccount]. + rethrow; + default: + assert(debugLog('Error fetching initial snapshot: $e')); + // Print stack trace in its own log entry; log entries are truncated + // at 1 kiB (at least on Android), and stack can be longer than that. + assert(debugLog('Stack:\n$s')); + } assert(debugLog('Backing off, then will retry…')); - // TODO tell user if initial-fetch errors persist, or look non-transient await (backoffMachine ??= BackoffMachine()).wait(); + stopAndThrowIfNoAccount(); assert(debugLog('… Backoff wait complete, retrying initial fetch.')); } + if (result != null) { + stopAndThrowIfNoAccount(); + final zulipVersionData = ZulipVersionData.fromInitialSnapshot(result); + if (zulipVersionData.isUnsupported) { + throw ServerVersionUnsupportedException(zulipVersionData); + } + return result; + } } } @@ -1023,7 +1275,13 @@ class UpdateMachine { final GetEventsResult result; try { result = await getEvents(store.connection, - queueId: queueId, lastEventId: lastEventId); + queueId: store.queueId, + lastEventId: lastEventId, + // If the UI shows we're busy getting event-polling to work again, + // ask the server to tell us immediately that it's working again, + // rather than waiting for an event, which could take up to a minute + // in the case of a heartbeat event. See #979. + dontBlock: store.isRecoveringEventStream ? true : null); if (_disposed) return; } catch (e, stackTrace) { if (_disposed) return; @@ -1090,14 +1348,14 @@ class UpdateMachine { // if we stayed at the max backoff interval; partway toward what would // happen if we weren't backing off at all. // - // But at least for [getEvents] requests, as here, it should be OK, - // because this is a long-poll. That means a typical successful request - // takes a long time to come back; in fact longer than our max backoff - // duration (which is 10 seconds). So if we're getting a mix of successes - // and failures, the successes themselves should space out the requests. + // Successful retries won't actually space out the requests, because retries + // are done with the `dont_block` param, asking the server to respond + // immediately instead of waiting through the long-poll period. + // (See comments on that code for why this behavior is helpful.) + // If server logs show pressure from too many requests, we can investigate. _pollBackoffMachine = null; - store.isLoading = false; + store.isRecoveringEventStream = false; _accumulatedTransientFailureCount = 0; reportErrorToUserBriefly(null); } @@ -1116,7 +1374,7 @@ class UpdateMachine { /// * [_handlePollError], which handles errors from the rest of [poll] /// and errors this method rethrows. Future _handlePollRequestError(Object error, StackTrace stackTrace) async { - store.isLoading = true; + store.isRecoveringEventStream = true; if (error is! ApiRequestException) { // Some unexpected error, outside even making the HTTP request. @@ -1174,9 +1432,10 @@ class UpdateMachine { // or an unexpected exception representing a bug in our code or the server. // Either way, the show must go on. So reload server data from scratch. - store.isLoading = true; + store.isRecoveringEventStream = true; bool isUnexpected; + // TODO(#1054): handle auth failure switch (error) { case ZulipApiException(code: 'BAD_EVENT_QUEUE_ID'): assert(debugLog('Lost event queue for $store. Replacing…')); @@ -1218,8 +1477,14 @@ class UpdateMachine { if (_disposed) return; } - await store._globalStore._reloadPerAccount(store.accountId); - assert(_disposed); + try { + await store._globalStore._reloadPerAccount(store.accountId); + } on AccountNotFoundException { + assert(debugLog('… Event queue not replaced; account was logged out.')); + return; + } finally { + assert(_disposed); + } assert(debugLog('… Event queue replaced.')); } @@ -1233,10 +1498,10 @@ class UpdateMachine { } void _reportToUserErrorConnectingToServer(Object error) { - final localizations = GlobalLocalizations.zulipLocalizations; + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; reportErrorToUserBriefly( - localizations.errorConnectingToServerShort, - details: localizations.errorConnectingToServerDetails( + zulipLocalizations.errorConnectingToServerShort, + details: zulipLocalizations.errorConnectingToServerDetails( store.realmUrl.toString(), error.toString())); } diff --git a/lib/model/typing_status.dart b/lib/model/typing_status.dart index 2956564c20..fdb63339bc 100644 --- a/lib/model/typing_status.dart +++ b/lib/model/typing_status.dart @@ -2,28 +2,22 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import '../api/core.dart'; import '../api/model/events.dart'; import '../api/route/typing.dart'; import 'binding.dart'; import 'narrow.dart'; +import 'realm.dart'; /// The model for tracking the typing status organized by narrows. /// /// Listeners are notified when a typist is added or removed from any narrow. -class TypingStatus extends ChangeNotifier { - TypingStatus({ - required this.selfUserId, - required this.typingStartedExpiryPeriod, - }); - - final int selfUserId; - final Duration typingStartedExpiryPeriod; +class TypingStatus extends HasRealmStore with ChangeNotifier { + TypingStatus({required super.realm}); Iterable get debugActiveNarrows => _timerMapsByNarrow.keys; Iterable typistIdsInNarrow(SendableNarrow narrow) => - _timerMapsByNarrow[narrow]?.keys ?? []; + _timerMapsByNarrow[narrow]?.keys ?? const []; // Using SendableNarrow as the key covers the narrows // where typing notices are supported (topics and DMs). @@ -48,7 +42,7 @@ class TypingStatus extends ChangeNotifier { final typistTimer = narrowTimerMap[typistUserId]; final isNewTypist = typistTimer == null; typistTimer?.cancel(); - narrowTimerMap[typistUserId] = Timer(typingStartedExpiryPeriod, () { + narrowTimerMap[typistUserId] = Timer(serverTypingStartedExpiryPeriod, () { if (_removeTypist(narrow, typistUserId)) { notifyListeners(); } @@ -93,16 +87,8 @@ class TypingStatus extends ChangeNotifier { /// See also: /// * https://github.com/zulip/zulip/blob/52a9846cdf4abfbe937a94559690d508e95f4065/web/shared/src/typing_status.ts /// * https://zulip.readthedocs.io/en/latest/subsystems/typing-indicators.html -class TypingNotifier { - TypingNotifier({ - required this.connection, - required this.typingStoppedWaitPeriod, - required this.typingStartedWaitPeriod, - }); - - final ApiConnection connection; - final Duration typingStoppedWaitPeriod; - final Duration typingStartedWaitPeriod; +class TypingNotifier extends HasRealmStore { + TypingNotifier({required super.realm}); SendableNarrow? _currentDestination; @@ -139,7 +125,7 @@ class TypingNotifier { if (destination == _currentDestination) { // Nothing has really changed, except we may need // to send a ping to the server and extend out our idle time. - if (_sinceLastPing!.elapsed > typingStartedWaitPeriod) { + if (_sinceLastPing!.elapsed > serverTypingStartedWaitPeriod) { _actuallyPingServer(); } _startOrExtendIdleTimer(); @@ -181,7 +167,7 @@ class TypingNotifier { void _startOrExtendIdleTimer() { _idleTimer?.cancel(); - _idleTimer = Timer(typingStoppedWaitPeriod, _stopLastNotification); + _idleTimer = Timer(serverTypingStoppedWaitPeriod, _stopLastNotification); } void _actuallyPingServer() { diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart index 1fcea3f83c..616feb18ef 100644 --- a/lib/model/unreads.dart +++ b/lib/model/unreads.dart @@ -10,6 +10,7 @@ import '../log.dart'; import 'algorithms.dart'; import 'narrow.dart'; import 'channel.dart'; +import 'store.dart'; /// The view-model for unread messages. /// @@ -34,59 +35,90 @@ import 'channel.dart'; // sync to those unreads, because the user has shown an interest in them. // TODO When loading a message list with stream messages, check all the stream // messages and refresh [mentions] (see [mentions] dartdoc). -class Unreads extends ChangeNotifier { +class Unreads extends PerAccountStoreBase with ChangeNotifier { factory Unreads({ - required UnreadMessagesSnapshot initial, - required int selfUserId, + required CorePerAccountStore core, required ChannelStore channelStore, + required UnreadMessagesSnapshot initial, }) { - final streams = >>{}; + final locatorMap = {}; + final streams = >>{}; final dms = >{}; final mentions = Set.of(initial.mentions); for (final unreadChannelSnapshot in initial.channels) { final streamId = unreadChannelSnapshot.streamId; final topic = unreadChannelSnapshot.topic; - (streams[streamId] ??= {})[topic] = QueueList.from(unreadChannelSnapshot.unreadMessageIds); + final topics = (streams[streamId] ??= makeTopicKeyedMap()); + topics.update(topic, + // Older servers differentiate topics case-sensitively, but shouldn't: + // https://github.com/zulip/zulip/pull/31869 + // Our topic-keyed map is case-insensitive. When we've seen this + // topic before, modulo case, aggregate instead of clobbering. + // TODO(server-10) simplify away + (value) => setUnion(value, unreadChannelSnapshot.unreadMessageIds), + ifAbsent: () => QueueList.from(unreadChannelSnapshot.unreadMessageIds)); + final narrow = TopicNarrow(streamId, topic); + for (final messageId in unreadChannelSnapshot.unreadMessageIds) { + locatorMap[messageId] = narrow; + } } for (final unreadDmSnapshot in initial.dms) { final otherUserId = unreadDmSnapshot.otherUserId; - final narrow = DmNarrow.withUser(otherUserId, selfUserId: selfUserId); + final narrow = DmNarrow.withUser(otherUserId, selfUserId: core.selfUserId); dms[narrow] = QueueList.from(unreadDmSnapshot.unreadMessageIds); + for (final messageId in dms[narrow]!) { + locatorMap[messageId] = narrow; + } } for (final unreadHuddleSnapshot in initial.huddles) { - final narrow = DmNarrow.ofUnreadHuddleSnapshot(unreadHuddleSnapshot, selfUserId: selfUserId); + final narrow = DmNarrow.ofUnreadHuddleSnapshot(unreadHuddleSnapshot, + selfUserId: core.selfUserId); dms[narrow] = QueueList.from(unreadHuddleSnapshot.unreadMessageIds); + for (final messageId in dms[narrow]!) { + locatorMap[messageId] = narrow; + } } return Unreads._( + core: core, channelStore: channelStore, + locatorMap: locatorMap, streams: streams, dms: dms, mentions: mentions, oldUnreadsMissing: initial.oldUnreadsMissing, - selfUserId: selfUserId, ); } Unreads._({ + required super.core, required this.channelStore, + required this.locatorMap, required this.streams, required this.dms, required this.mentions, required this.oldUnreadsMissing, - required this.selfUserId, }); final ChannelStore channelStore; + /// All unread messages, as: message ID → narrow ([TopicNarrow] or [DmNarrow]). + /// + /// Enables efficient [isUnread] and efficient lookups in [streams] and [dms]. + @visibleForTesting + final Map locatorMap; + // TODO excluded for now; would need to handle nuances around muting etc. // int count; /// Unread stream messages, as: stream ID → topic → message IDs (sorted). - final Map>> streams; + /// + /// The topic-keyed map is case-insensitive and case-preserving; + /// it comes from [makeTopicKeyedMap]. + final Map>> streams; /// Unread DM messages, as: DM narrow → message IDs (sorted). final Map> dms; @@ -125,8 +157,6 @@ class Unreads extends ChangeNotifier { /// Is set to false when the user clears out all unreads. bool oldUnreadsMissing; - final int selfUserId; - // TODO(#370): maintain this count incrementally, rather than recomputing from scratch int countInCombinedFeedNarrow() { int c = 0; @@ -197,6 +227,9 @@ class Unreads extends ChangeNotifier { // TODO: Implement unreads handling. int countInStarredMessagesNarrow() => 0; + // TODO: Implement unreads handling? + int countInKeywordSearchNarrow() => 0; + int countInNarrow(Narrow narrow) { switch (narrow) { case CombinedFeedNarrow(): @@ -211,17 +244,16 @@ class Unreads extends ChangeNotifier { return countInMentionsNarrow(); case StarredMessagesNarrow(): return countInStarredMessagesNarrow(); + case KeywordSearchNarrow(): + return countInKeywordSearchNarrow(); } } /// The unread state for [messageId], or null if unknown. /// /// May be unknown only if [oldUnreadsMissing]. - /// - /// This is inefficient; it iterates through [dms] and [channels]. - // TODO implement efficiently bool? isUnread(int messageId) { - final isPresent = _slowIsPresentInDms(messageId) || _slowIsPresentInStreams(messageId); + final isPresent = locatorMap.containsKey(messageId); if (oldUnreadsMissing && !isPresent) return null; return isPresent; } @@ -234,9 +266,12 @@ class Unreads extends ChangeNotifier { switch (message) { case StreamMessage(): + final narrow = TopicNarrow.ofMessage(message); + locatorMap[event.message.id] = narrow; _addLastInStreamTopic(message.id, message.streamId, message.topic); case DmMessage(): final narrow = DmNarrow.ofMessage(message, selfUserId: selfUserId); + locatorMap[event.message.id] = narrow; _addLastInDm(message.id, narrow); } if ( @@ -259,10 +294,8 @@ class Unreads extends ChangeNotifier { (f) => f == MessageFlag.mentioned || f == MessageFlag.wildcardMentioned, ); - // We assume this event can't signal a change in a message's 'read' flag. - // TODO can it actually though, when it's about messages being moved into an - // unsubscribed stream? - // https://chat.zulip.org/#narrow/stream/378-api-design/topic/mark-as-read.20events.20with.20message.20moves.3F/near/1639957 + // We expect the event's 'read' flag to be boring, + // matching the message's local unread state. final bool isRead = event.flags.contains(MessageFlag.read); assert(() { final isUnreadLocally = isUnread(messageId); @@ -272,6 +305,17 @@ class Unreads extends ChangeNotifier { // We were going to check something but can't; shrug. if (isUnreadLocally == null) return true; + final newChannelId = event.moveData?.newStreamId; + if (newChannelId != null && !channelStore.subscriptions.containsKey(newChannelId)) { + // When unread messages are moved to an unsubscribed channel, the server + // marks them as read without sending a mark-as-read event. Clients are + // asked to special-case this by marking them as read, which we do in + // _handleMessageMove. That contract is clear enough and doesn't involve + // this event's 'read' flag, so don't bother logging about the flag; + // its behavior seems like an implementation detail that could change. + return true; + } + if (isUnreadLocally != isUnreadInEvent) { // If this happens, then either: // - the server and client have been out of sync about the message's @@ -296,23 +340,60 @@ class Unreads extends ChangeNotifier { madeAnyUpdate |= mentions.add(messageId); } - // TODO(#901) handle moved messages + madeAnyUpdate |= _handleMessageMove(event); if (madeAnyUpdate) { notifyListeners(); } } + bool _handleMessageMove(UpdateMessageEvent event) { + if (event.moveData == null) { + // No moved messages. + return false; + } + final UpdateMessageMoveData( + :origStreamId, :newStreamId, :origTopic, :newTopic) = event.moveData!; + + final messageToMoveIds = _popAllInStreamTopic( + event.messageIds.toSet(), origStreamId, origTopic)?..sort(); + + if (messageToMoveIds == null || messageToMoveIds.isEmpty) return false; + assert(event.messageIds.toSet().containsAll(messageToMoveIds)); + + if (!channelStore.subscriptions.containsKey(newStreamId)) { + // Unreads moved to an unsubscribed channel; just drop them. + // See also: + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/mark-as-read.20events.20with.20message.20moves.3F/near/2101926 + for (final messageId in messageToMoveIds) { + locatorMap.remove(messageId); + } + return true; + } + + final narrow = TopicNarrow(newStreamId, newTopic); + for (final messageId in messageToMoveIds) { + locatorMap[messageId] = narrow; + } + _addAllInStreamTopic(messageToMoveIds, newStreamId, newTopic); + + return true; + } + void handleDeleteMessageEvent(DeleteMessageEvent event) { mentions.removeAll(event.messageIds); - final messageIdsSet = Set.of(event.messageIds); switch (event.messageType) { case MessageType.stream: + // All the messages are in [event.streamId] and [event.topic], + // so we can be more efficient than _removeAllInStreamsAndDms. final streamId = event.streamId!; final topic = event.topic!; - _removeAllInStreamTopic(messageIdsSet, streamId, topic); + _removeAllInStreamTopic(Set.of(event.messageIds), streamId, topic); case MessageType.direct: - _slowRemoveAllInDms(messageIdsSet); + _removeAllInStreamsAndDms(event.messageIds, expectOnlyDms: true); + } + for (final messageId in event.messageIds) { + locatorMap.remove(messageId); } // TODO skip notifyListeners if unchanged? @@ -354,18 +435,21 @@ class Unreads extends ChangeNotifier { switch (event) { case UpdateMessageFlagsAddEvent(): if (event.all) { + locatorMap.clear(); streams.clear(); dms.clear(); mentions.clear(); oldUnreadsMissing = false; } else { - final messageIdsSet = Set.of(event.messages); - mentions.removeAll(messageIdsSet); - _slowRemoveAllInStreams(messageIdsSet); - _slowRemoveAllInDms(messageIdsSet); + final messageIds = event.messages; + mentions.removeAll(messageIds); + _removeAllInStreamsAndDms(messageIds); + for (final messageId in messageIds) { + locatorMap.remove(messageId); + } } case UpdateMessageFlagsRemoveEvent(): - final newlyUnreadInStreams = >>{}; + final newlyUnreadInStreams = >>{}; final newlyUnreadInDms = >{}; for (final messageId in event.messages) { final detail = event.messageDetails![messageId]; @@ -380,12 +464,15 @@ class Unreads extends ChangeNotifier { } switch (detail.type) { case MessageType.stream: - final topics = (newlyUnreadInStreams[detail.streamId!] ??= {}); - final messageIds = (topics[detail.topic!] ??= QueueList()); + final UpdateMessageFlagsMessageDetail(:streamId, :topic) = detail; + locatorMap[messageId] = TopicNarrow(streamId!, topic!); + final topics = (newlyUnreadInStreams[streamId] ??= makeTopicKeyedMap()); + final messageIds = (topics[topic] ??= QueueList()); messageIds.add(messageId); case MessageType.direct: final narrow = DmNarrow.ofUpdateMessageFlagsMessageDetail(selfUserId: selfUserId, detail); + locatorMap[messageId] = narrow; (newlyUnreadInDms[narrow] ??= QueueList()) .add(messageId); } @@ -406,22 +493,20 @@ class Unreads extends ChangeNotifier { notifyListeners(); } - /// To be called on success of a mark-all-as-read task in the modern protocol. + /// To be called on success of a mark-all-as-read task. /// /// When the user successfully marks all messages as read, /// there can't possibly be ancient unreads we don't know about. /// So this updates [oldUnreadsMissing] to false and calls [notifyListeners]. /// - /// When we use POST /messages/flags/narrow (FL 155+) for mark-all-as-read, - /// we don't expect to get a mark-as-read event with `all: true`, + /// We don't expect to get a mark-as-read event with `all: true`, /// even on completion of the last batch of unreads. - /// If we did get an event with `all: true` (as we do in the legacy mark-all- + /// If we did get an event with `all: true` (as we did in a legacy mark-all- /// as-read protocol), this would be handled naturally, in /// [handleUpdateMessageFlagsEvent]. /// /// Discussion: /// - // TODO(server-6) Delete mentions of legacy protocol. void handleAllMessagesReadSuccess() { oldUnreadsMissing = false; @@ -440,22 +525,16 @@ class Unreads extends ChangeNotifier { notifyListeners(); } - // TODO use efficient lookups - bool _slowIsPresentInStreams(int messageId) { - return streams.values.any( - (topics) => topics.values.any( - (messageIds) => messageIds.contains(messageId), - ), - ); - } - void _addLastInStreamTopic(int messageId, int streamId, TopicName topic) { - ((streams[streamId] ??= {})[topic] ??= QueueList()).addLast(messageId); + ((streams[streamId] ??= makeTopicKeyedMap())[topic] ??= QueueList()) + .addLast(messageId); } // [messageIds] must be sorted ascending and without duplicates. void _addAllInStreamTopic(QueueList messageIds, int streamId, TopicName topic) { - final topics = streams[streamId] ??= {}; + assert(messageIds.isNotEmpty); + assert(isSortedWithoutDuplicates(messageIds)); + final topics = streams[streamId] ??= makeTopicKeyedMap(); topics.update(topic, ifAbsent: () => messageIds, // setUnion dedupes existing and incoming unread IDs, @@ -465,26 +544,32 @@ class Unreads extends ChangeNotifier { ); } - // TODO use efficient model lookups - void _slowRemoveAllInStreams(Set idsToRemove) { - final newlyEmptyStreams = []; - for (final MapEntry(key: streamId, value: topics) in streams.entries) { - final newlyEmptyTopics = []; - for (final MapEntry(key: topic, value: messageIds) in topics.entries) { - messageIds.removeWhere((id) => idsToRemove.contains(id)); - if (messageIds.isEmpty) { - newlyEmptyTopics.add(topic); - } - } - for (final topic in newlyEmptyTopics) { - topics.remove(topic); - } - if (topics.isEmpty) { - newlyEmptyStreams.add(streamId); - } + /// Remove [idsToRemove] from [streams] and [dms]. + void _removeAllInStreamsAndDms(Iterable idsToRemove, {bool expectOnlyDms = false}) { + final idsPresentByNarrow = >{}; + for (final id in idsToRemove) { + final narrow = locatorMap[id]; + if (narrow == null) continue; + (idsPresentByNarrow[narrow] ??= {}).add(id); } - for (final streamId in newlyEmptyStreams) { - streams.remove(streamId); + + for (final MapEntry(key: narrow, value: ids) in idsPresentByNarrow.entries) { + switch (narrow) { + case TopicNarrow(): + if (expectOnlyDms) { + // TODO(log)? + } + _removeAllInStreamTopic(ids, narrow.streamId, narrow.topic); + case DmNarrow(): + final messageIds = dms[narrow]; + if (messageIds == null) return; + + // ([QueueList] doesn't have a `removeAll`) + messageIds.removeWhere((id) => ids.contains(id)); + if (messageIds.isEmpty) { + dms.remove(narrow); + } + } } } @@ -504,9 +589,47 @@ class Unreads extends ChangeNotifier { } } - // TODO use efficient model lookups - bool _slowIsPresentInDms(int messageId) { - return dms.values.any((ids) => ids.contains(messageId)); + /// Remove unread stream messages contained in `incomingMessageIds`, with + /// the matching `streamId` and `topic`. + /// + /// Returns the removed message IDs, or `null` if no messages are affected. + /// + /// Use [_removeAllInStreamTopic] if the removed message IDs are not needed. + // Part of this is adapted from [ListBase.removeWhere]. + QueueList? _popAllInStreamTopic(Set incomingMessageIds, int streamId, TopicName topic) { + final topics = streams[streamId]; + if (topics == null) return null; + final messageIds = topics[topic]; + if (messageIds == null) return null; + + final retainedMessageIds = messageIds.whereNot( + (id) => incomingMessageIds.contains(id)).toList(); + + if (retainedMessageIds.isEmpty) { + // This is an optimization for the case when all messages in the + // conversation are removed, which avoids making a copy of `messageIds` + // unnecessarily. + topics.remove(topic); + if (topics.isEmpty) { + streams.remove(streamId); + } + return messageIds; + } + + QueueList? poppedMessageIds; + if (retainedMessageIds.length != messageIds.length) { + poppedMessageIds = QueueList.from( + messageIds.where((id) => incomingMessageIds.contains(id))); + messageIds.setRange(0, retainedMessageIds.length, retainedMessageIds); + messageIds.length = retainedMessageIds.length; + } + if (messageIds.isEmpty) { + topics.remove(topic); + if (topics.isEmpty) { + streams.remove(streamId); + } + } + return poppedMessageIds; } void _addLastInDm(int messageId, DmNarrow narrow) { @@ -523,18 +646,4 @@ class Unreads extends ChangeNotifier { (existing) => setUnion(existing, messageIds), ); } - - // TODO use efficient model lookups - void _slowRemoveAllInDms(Set idsToRemove) { - final newlyEmptyDms = []; - for (final MapEntry(key: dmNarrow, value: messageIds) in dms.entries) { - messageIds.removeWhere((id) => idsToRemove.contains(id)); - if (messageIds.isEmpty) { - newlyEmptyDms.add(dmNarrow); - } - } - for (final dmNarrow in newlyEmptyDms) { - dms.remove(dmNarrow); - } - } } diff --git a/lib/model/user.dart b/lib/model/user.dart new file mode 100644 index 0000000000..4983cf48ca --- /dev/null +++ b/lib/model/user.dart @@ -0,0 +1,313 @@ +import 'package:flutter/foundation.dart'; + +import '../api/model/events.dart'; +import '../api/model/initial_snapshot.dart'; +import '../api/model/model.dart'; +import 'algorithms.dart'; +import 'localizations.dart'; +import 'narrow.dart'; +import 'realm.dart'; +import 'store.dart'; + +/// The portion of [PerAccountStore] describing the users in the realm. +mixin UserStore on PerAccountStoreBase, RealmStore { + @protected + RealmStore get realmStore; + + /// The user with the given ID, if that user is known. + /// + /// There may be other users that are perfectly real but are + /// not known to the app, for multiple reasons: + /// + /// * The self-user may not have permission to see all the users in the + /// realm, for example because the self-user is a guest. + /// + /// * Because of the fetch/event race, any data that the client fetched + /// outside of the event system may reflect an earlier or later time + /// than this data, which is maintained by the event system. + /// This includes messages fetched for a message list, and notifications. + /// Those may therefore refer to users for which we have yet to see the + /// [RealmUserAddEvent], or have already handled a [RealmUserRemoveEvent]. + /// + /// Code that looks up a user here should therefore always handle + /// the possibility that the user is not found (except + /// where there is a specific reason to know the user should be found). + /// Consider using [userDisplayName]. + User? getUser(int userId); + + /// All known users in the realm, including deactivated users. + /// + /// Before presenting these users in the UI, consider whether to exclude + /// users who are deactivated (see [User.isActive]) or muted ([isUserMuted]). + /// + /// This may have a large number of elements, like tens of thousands. + /// Consider [getUser] or other alternatives to iterating through this. + /// + /// There may be perfectly real users which are not known + /// and so are not found here. For details, see [getUser]. + Iterable get allUsers; + + /// The [User] object for the "self-user", + /// i.e. the account the person using this app is logged into. + /// + /// When only the user ID is needed, see [selfUserId]. + User get selfUser => getUser(selfUserId)!; + + /// The name to show the given user as in the UI, even for unknown users. + /// + /// If the user is muted and [replaceIfMuted] is true (the default), + /// this is [ZulipLocalizations.mutedUser]. + /// + /// Otherwise this is the user's [User.fullName] if the user is known, + /// or (if unknown) [ZulipLocalizations.unknownUserName]. + /// + /// When a [Message] is available which the user sent, + /// use [senderDisplayName] instead for a better-informed fallback. + String userDisplayName(int userId, {bool replaceIfMuted = true}) { + if (replaceIfMuted && isUserMuted(userId)) { + return GlobalLocalizations.zulipLocalizations.mutedUser; + } + return getUser(userId)?.fullName + ?? GlobalLocalizations.zulipLocalizations.unknownUserName; + } + + /// The name to show for the given message's sender in the UI. + /// + /// If the sender is muted and [replaceIfMuted] is true (the default), + /// this is [ZulipLocalizations.mutedUser]. + /// + /// Otherwise, if the user is known (see [getUser]), + /// this is their current [User.fullName]. + /// If unknown, this uses the fallback value conveniently provided on the + /// [Message] object itself, namely [Message.senderFullName]. + /// + /// For a user who isn't the sender of some known message, + /// see [userDisplayName]. + String senderDisplayName(Message message, {bool replaceIfMuted = true}) { + final senderId = message.senderId; + if (replaceIfMuted && isUserMuted(senderId)) { + return GlobalLocalizations.zulipLocalizations.mutedUser; + } + return getUser(senderId)?.fullName ?? message.senderFullName; + } + + /// Whether [user] has passed the realm's waiting period to be a full member. + /// + /// See: + /// https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member + /// + /// To determine if a user is a full member, callers must also check that the + /// user's role is at least [UserRole.member]. + bool hasPassedWaitingPeriod(User user, {required DateTime byDate}) { + // [User.dateJoined] is in UTC. For logged-in users, the format is: + // YYYY-MM-DDTHH:mm+00:00, which includes the timezone offset for UTC. + // For logged-out spectators, the format is: YYYY-MM-DD, which doesn't + // include the timezone offset. In the later case, [DateTime.parse] will + // interpret it as the client's local timezone, which could lead to + // incorrect results; but that's acceptable for now because the app + // doesn't support viewing as a spectator. + // + // See the related discussion: + // https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/provide.20an.20explicit.20format.20for.20.60realm_user.2Edate_joined.60/near/1980194 + final dateJoined = DateTime.parse(user.dateJoined); + return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; + } + + /// Whether the user with [userId] is muted by the self-user. + /// + /// Looks for [userId] in a private [Set], + /// or in [event.mutedUsers] instead if event is non-null. + bool isUserMuted(int userId, {MutedUsersEvent? event}); + + /// Whether the self-user has muted everyone in [narrow]. + /// + /// Returns false for the self-DM. + /// + /// Calls [isUserMuted] for each participant, passing along [event]. + bool shouldMuteDmConversation(DmNarrow narrow, {MutedUsersEvent? event}) { + if (narrow.otherRecipientIds.isEmpty) return false; + return narrow.otherRecipientIds.every( + (userId) => isUserMuted(userId, event: event)); + } + + /// Whether the given event might change the result of [shouldMuteDmConversation] + /// for its list of muted users, compared to the current state. + MutedUsersVisibilityEffect mightChangeShouldMuteDmConversation(MutedUsersEvent event); + + /// The status of the user with the given ID. + /// + /// If no status is set for the user, returns [UserStatus.zero]. + UserStatus getUserStatus(int userId); +} + +/// Whether and how a given [MutedUsersEvent] may affect the results +/// that [UserStore.shouldMuteDmConversation] would give for some messages. +enum MutedUsersVisibilityEffect { + /// The event will have no effect on the visibility results. + none, + + /// The event may change some visibility results from true to false. + muted, + + /// The event may change some visibility results from false to true. + unmuted, + + /// The event may change some visibility results from false to true, + /// and some from true to false. + mixed; +} + +mixin ProxyUserStore on UserStore { + @protected + UserStore get userStore; + + @override + User? getUser(int userId) => userStore.getUser(userId); + + @override + Iterable get allUsers => userStore.allUsers; + + @override + bool isUserMuted(int userId, {MutedUsersEvent? event}) => + userStore.isUserMuted(userId, event: event); + + @override + MutedUsersVisibilityEffect mightChangeShouldMuteDmConversation(MutedUsersEvent event) => + userStore.mightChangeShouldMuteDmConversation(event); + + @override + UserStatus getUserStatus(int userId) => userStore.getUserStatus(userId); +} + +/// A base class for [PerAccountStore] substores that need access to [UserStore] +/// as well as to its prerequisites [CorePerAccountStore] and [RealmStore]. +abstract class HasUserStore extends HasRealmStore with UserStore, ProxyUserStore { + HasUserStore({required UserStore users}) + : userStore = users, super(realm: users.realmStore); + + @protected + @override + final UserStore userStore; +} + +/// The implementation of [UserStore] that does the work. +/// +/// Generally the only code that should need this class is [PerAccountStore] +/// itself. Other code accesses this functionality through [PerAccountStore], +/// or through the mixin [UserStore] which describes its interface. +class UserStoreImpl extends HasRealmStore with UserStore { + /// Construct an implementation of [UserStore] that does the work itself. + /// + /// The `userMap` parameter should be the result of + /// [UserStoreImpl.userMapFromInitialSnapshot] applied to `initialSnapshot`. + UserStoreImpl({ + required super.realm, + required InitialSnapshot initialSnapshot, + required Map userMap, + }) : _users = userMap, + _mutedUsers = Set.from(initialSnapshot.mutedUsers.map((item) => item.id)), + _userStatuses = initialSnapshot.userStatuses.map((userId, change) => + MapEntry(userId, change.apply(UserStatus.zero))) { + // Verify that [selfUser] will work. + assert(_users.containsKey(selfUserId)); + } + + static Map userMapFromInitialSnapshot(InitialSnapshot initialSnapshot) { + return Map.fromEntries( + initialSnapshot.realmUsers + .followedBy(initialSnapshot.realmNonActiveUsers) + .followedBy(initialSnapshot.crossRealmBots) + .map((user) => MapEntry(user.userId, user))); + } + + final Map _users; + + @override + User? getUser(int userId) => _users[userId]; + + @override + Iterable get allUsers => _users.values; + + final Set _mutedUsers; + + @override + bool isUserMuted(int userId, {MutedUsersEvent? event}) { + return (event?.mutedUsers.map((item) => item.id) ?? _mutedUsers).contains(userId); + } + + @override + MutedUsersVisibilityEffect mightChangeShouldMuteDmConversation(MutedUsersEvent event) { + final sortedOld = _mutedUsers.toList()..sort(); + final sortedNew = event.mutedUsers.map((u) => u.id).toList()..sort(); + assert(isSortedWithoutDuplicates(sortedOld)); + assert(isSortedWithoutDuplicates(sortedNew)); + final union = setUnion(sortedOld, sortedNew); + + final willMuteSome = sortedOld.length < union.length; + final willUnmuteSome = sortedNew.length < union.length; + + switch ((willUnmuteSome, willMuteSome)) { + case (true, false): + return MutedUsersVisibilityEffect.unmuted; + case (false, true): + return MutedUsersVisibilityEffect.muted; + case (true, true): + return MutedUsersVisibilityEffect.mixed; + case (false, false): // TODO(log)? + return MutedUsersVisibilityEffect.none; + } + } + + final Map _userStatuses; + + @override + UserStatus getUserStatus(int userId) => _userStatuses[userId] ?? UserStatus.zero; + + void handleRealmUserEvent(RealmUserEvent event) { + switch (event) { + case RealmUserAddEvent(): + _users[event.person.userId] = event.person; + + case RealmUserRemoveEvent(): + _users.remove(event.userId); + + case RealmUserUpdateEvent(): + final user = _users[event.userId]; + if (user == null) { + return; // TODO log + } + if (event.fullName != null) user.fullName = event.fullName!; + if (event.avatarUrl != null) user.avatarUrl = event.avatarUrl!; + if (event.avatarVersion != null) user.avatarVersion = event.avatarVersion!; + if (event.timezone != null) user.timezone = event.timezone!; + if (event.botOwnerId != null) user.botOwnerId = event.botOwnerId!; + if (event.role != null) user.role = event.role!; + if (event.deliveryEmail != null) user.deliveryEmail = event.deliveryEmail!.value; + if (event.newEmail != null) user.email = event.newEmail!; + if (event.isActive != null) user.isActive = event.isActive!; + if (event.customProfileField != null) { + final profileData = (user.profileData ??= {}); + final update = event.customProfileField!; + if (update.value != null) { + profileData[update.id] = ProfileFieldUserData(value: update.value!, renderedValue: update.renderedValue); + } else { + profileData.remove(update.id); + } + if (profileData.isEmpty) { + // null is equivalent to `{}` for efficiency; see [User._readProfileData]. + user.profileData = null; + } + } + } + } + + void handleUserStatusEvent(UserStatusEvent event) { + _userStatuses[event.userId] = + event.change.apply(getUserStatus(event.userId)); + } + + void handleMutedUsersEvent(MutedUsersEvent event) { + _mutedUsers.clear(); + _mutedUsers.addAll(event.mutedUsers.map((item) => item.id)); + } +} diff --git a/lib/model/user_group.dart b/lib/model/user_group.dart new file mode 100644 index 0000000000..4fb52b6d49 --- /dev/null +++ b/lib/model/user_group.dart @@ -0,0 +1,147 @@ +import 'package:flutter/foundation.dart'; + +import '../api/model/events.dart'; +import '../api/model/model.dart'; +import 'store.dart'; + +/// The portion of [PerAccountStore] describing user groups. +mixin UserGroupStore on PerAccountStoreBase { + /// The user group with the given ID, if any. + UserGroup? getGroup(int userGroupId); + + /// All non-deactivated user groups in the realm. + /// + /// For when deactivated groups are desired too, see [allGroups]. + Iterable get activeGroups; + + /// All user groups in the realm, even those deactivated. + /// + /// Consider using [activeGroups] instead. + Iterable get allGroups; + + /// Whether the self-user is a (transitive) member of the given group, + /// a group-setting value. + bool selfInGroupSetting(GroupSettingValue value); +} + +mixin ProxyUserGroupStore on UserGroupStore { + @protected + UserGroupStore get userGroupStore; + + @override + UserGroup? getGroup(int userGroupId) => userGroupStore.getGroup(userGroupId); + @override + Iterable get activeGroups => userGroupStore.activeGroups; + @override + Iterable get allGroups => userGroupStore.allGroups; + @override + bool selfInGroupSetting(GroupSettingValue value) + => userGroupStore.selfInGroupSetting(value); +} + +abstract class HasUserGroupStore extends PerAccountStoreBase with UserGroupStore, ProxyUserGroupStore { + HasUserGroupStore({required UserGroupStore groups}) + : userGroupStore = groups, super(core: groups.core); + + @protected + @override + final UserGroupStore userGroupStore; +} + +/// The implementation of [UserGroupStore] that does the work. +class UserGroupStoreImpl extends PerAccountStoreBase with UserGroupStore { + UserGroupStoreImpl({required super.core, required List groups}) + : _groups = { + for (final group in groups) + group.id: group, + }; + + @override + UserGroup? getGroup(int userGroupId) { + return _groups[userGroupId]; + } + + @override + Iterable get activeGroups { + return _groups.values.where((group) => !group.deactivated); + } + + @override + Iterable get allGroups { + return _groups.values; + } + + @override + bool selfInGroupSetting(GroupSettingValue value) { + return switch (value) { + GroupSettingValueNamed() => + _selfInGroup(value.groupId), + GroupSettingValueNameless() => + value.directMembers.contains(selfUserId) + || value.directSubgroups.any(_selfInGroup), + }; + } + + bool _selfInGroup(int groupId) { + final group = _groups[groupId]; + if (group == null) return false; // TODO(log); should know all groups + // TODO(perf), TODO(#814): memoize which groups the self-user is in, + // to save doing this depth-first search on each permission check + return group.members.contains(selfUserId) + || group.directSubgroupIds.any(_selfInGroup); + } + + final Map _groups; + + UserGroup? _expectGroup(int groupId) { + final group = _groups[groupId]; + // TODO(log) if group not found + return group; + } + + void handleUserGroupEvent(UserGroupEvent event) { + switch (event) { + case UserGroupAddEvent(): + _groups[event.group.id] = event.group; + + case UserGroupRemoveEvent(): + _groups.remove(event.groupId); + + case UserGroupUpdateEvent(): + final group = _expectGroup(event.groupId); + if (group == null) return; + final data = event.data; + if (data.name != null) group.name = data.name!; + if (data.description != null) group.description = data.description!; + if (data.deactivated != null) group.deactivated = data.deactivated!; + + case UserGroupAddMembersEvent(): + final group = _expectGroup(event.groupId); + if (group == null) return; + group.members.addAll(event.userIds); + + case UserGroupRemoveMembersEvent(): + final group = _expectGroup(event.groupId); + if (group == null) return; + group.members.removeAll(event.userIds); + + case UserGroupAddSubgroupsEvent(): + final group = _expectGroup(event.groupId); + if (group == null) return; + group.directSubgroupIds.addAll(event.directSubgroupIds); + + case UserGroupRemoveSubgroupsEvent(): + final group = _expectGroup(event.groupId); + if (group == null) return; + group.directSubgroupIds.removeAll(event.directSubgroupIds); + } + } + + void handleRealmUserUpdateEvent(RealmUserUpdateEvent event) { + if (event.isActive == false) { + for (final group in _groups.values) { + group.members.remove(event.userId); + } + } + } +} diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 1b85ccaebc..7a66b1d19f 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -1,26 +1,20 @@ import 'dart:async'; import 'dart:io'; -import 'package:http/http.dart' as http; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart' hide Notification; +import 'package:http/http.dart' as http; import '../api/model/model.dart'; import '../api/notifications.dart'; -import '../generated/l10n/zulip_localizations.dart'; import '../host/android_notifications.dart'; import '../log.dart'; import '../model/binding.dart'; import '../model/localizations.dart'; import '../model/narrow.dart'; -import '../widgets/app.dart'; import '../widgets/color.dart'; -import '../widgets/dialog.dart'; -import '../widgets/message_list.dart'; -import '../widgets/page.dart'; -import '../widgets/store.dart'; import '../widgets/theme.dart'; +import 'open.dart'; AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNotificationHost; @@ -43,11 +37,12 @@ enum NotificationSound { class NotificationChannelManager { /// The channel ID we use for our one notification channel, which we use for /// all notifications. - // TODO(launch) check this doesn't match zulip-mobile's current or previous - // channel IDs - // Previous values: 'messages-1' + // Previous values from Zulip Flutter Beta: + // 'messages-1' + // Previous values from Zulip Mobile: + // 'default', 'messages-1', (alpha-only: 'messages-2'), 'messages-3' @visibleForTesting - static const kChannelId = 'messages-2'; + static const kChannelId = 'messages-4'; @visibleForTesting static const kDefaultNotificationSound = NotificationSound.chime3; @@ -64,14 +59,14 @@ class NotificationChannelManager { /// For example, for a resource `@raw/chime3`, where `raw` would be the /// resource type and `chime3` would be the resource name it generates the /// following URL: - /// `android.resource://com.zulip.flutter/raw/chime3` + /// `android.resource://com.zulipmobile/raw/chime3` /// /// Based on: https://stackoverflow.com/a/38340580 - static Uri _resourceUrlFromName({ + static Future _resourceUrlFromName({ required String resourceTypeName, required String resourceEntryName, - }) { - const packageName = 'com.zulip.flutter'; // TODO(#407) + }) async { + final packageInfo = await ZulipBinding.instance.packageInfo; // URL scheme for Android resource url. // See: https://developer.android.com/reference/android/content/ContentResolver#SCHEME_ANDROID_RESOURCE @@ -79,9 +74,9 @@ class NotificationChannelManager { return Uri( scheme: schemeAndroidResource, - host: packageName, + host: packageInfo!.packageName, pathSegments: [resourceTypeName, resourceEntryName], - ); + ).toString(); } /// Prepare our notification sounds; return a URL for our default sound. @@ -92,9 +87,9 @@ class NotificationChannelManager { /// Returns a URL for our default notification sound: either in shared storage /// if we successfully copied it there, or else as our internal resource file. static Future _ensureInitNotificationSounds() async { - String defaultSoundUrl = _resourceUrlFromName( + String defaultSoundUrl = await _resourceUrlFromName( resourceTypeName: 'raw', - resourceEntryName: kDefaultNotificationSound.resourceName).toString(); + resourceEntryName: kDefaultNotificationSound.resourceName); final shouldUseResourceFile = switch (await ZulipBinding.instance.deviceInfo) { // Before Android 10 Q, we don't attempt to put the sounds in shared media storage. @@ -217,10 +212,12 @@ class NotificationChannelManager { /// Service for managing the notifications shown to the user. class NotificationDisplayManager { static Future init() async { + assert(defaultTargetPlatform == TargetPlatform.android); await NotificationChannelManager.ensureChannel(); } static void onFcmMessage(FcmMessage data, Map dataJson) { + assert(defaultTargetPlatform == TargetPlatform.android); switch (data) { case MessageFcmMessage(): _onMessageFcmMessage(data, dataJson); case RemoveFcmMessage(): _onRemoveFcmMessage(data); @@ -231,9 +228,21 @@ class NotificationDisplayManager { static Future _onMessageFcmMessage(MessageFcmMessage data, Map dataJson) async { assert(debugLog('notif message content: ${data.content}')); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - final groupKey = _groupKey(data); + final groupKey = _groupKey(data.realmUrl, data.userId); final conversationKey = _conversationKey(data, groupKey); + final globalStore = await ZulipBinding.instance.getGlobalStore(); + final account = globalStore.accounts.firstWhereOrNull((account) => + account.realmUrl.origin == data.realmUrl.origin && account.userId == data.userId); + + // Skip showing notifications for a logged-out account. This can occur if + // the unregisterToken request failed previously. It would be annoying + // to the user if notifications keep showing up after they've logged out. + // (Also alarming: it suggests the logout didn't fully work.) + if (account == null) { + return; + } + final oldMessagingStyle = await _androidHost .getActiveNotificationMessagingStyleByTag(conversationKey); @@ -288,7 +297,7 @@ class NotificationDisplayManager { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); await _androidHost.notify( id: kNotificationId, @@ -365,7 +374,7 @@ class NotificationDisplayManager { // There may be a lot of messages mentioned here, across a lot of // conversations. But they'll all be for one account, so they'll // fall under one notification group. - final groupKey = _groupKey(data); + final groupKey = _groupKey(data.realmUrl, data.userId); // Find any conversations we can cancel the notification for. // The API doesn't lend itself to removing individual messages as @@ -421,6 +430,20 @@ class NotificationDisplayManager { } } + static Future removeNotificationsForAccount(Uri realmUrl, int userId) async { + assert(defaultTargetPlatform == TargetPlatform.android); + + final groupKey = _groupKey(realmUrl, userId); + final activeNotifications = await _androidHost.getActiveNotifications( + desiredExtras: []); + for (final statusBarNotification in activeNotifications) { + if (statusBarNotification.notification.group == groupKey) { + await _androidHost.cancel( + tag: statusBarNotification.tag, id: statusBarNotification.id); + } + } + } + /// The constant numeric "ID" we use for all non-test notifications, /// along with unique tags. /// @@ -445,46 +468,14 @@ class NotificationDisplayManager { return '$groupKey|$conversation'; } - static String _groupKey(FcmMessageWithIdentity data) { + static String _groupKey(Uri realmUrl, int userId) { // The realm URL can't contain a `|`, because `|` is not a URL code point: // https://url.spec.whatwg.org/#url-code-points - return "${data.realmUrl}|${data.userId}"; + return "$realmUrl|$userId"; } static String _personKey(Uri realmUrl, int userId) => "$realmUrl|$userId"; - /// Navigates to the [MessageListPage] of the specific conversation - /// given the `zulip://notification/…` Android intent data URL, - /// generated with [NotificationOpenPayload.buildUrl] while creating - /// the notification. - static Future navigateForNotification(Uri url) async { - assert(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; - } - - // 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)))); - } - static Future _fetchBitmap(Uri url) async { try { // TODO timeout to prevent waiting indefinitely @@ -498,86 +489,3 @@ class NotificationDisplayManager { return null; } } - -/// The information contained in 'zulip://notification/…' internal -/// Android intent data URL, used for notification-open flow. -class NotificationOpenPayload { - final Uri realmUrl; - final int userId; - final Narrow narrow; - - NotificationOpenPayload({ - required this.realmUrl, - required this.userId, - required this.narrow, - }); - - factory NotificationOpenPayload.parseUrl(Uri url) { - if (url case Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': var realmUrlStr, - 'user_id': var userIdStr, - 'narrow_type': var narrowType, - // In case of narrowType == 'topic': - // 'channel_id' and 'topic' handled below. - - // In case of narrowType == 'dm': - // 'all_recipient_ids' handled below. - }, - )) { - final realmUrl = Uri.parse(realmUrlStr); - final userId = int.parse(userIdStr, radix: 10); - - final Narrow narrow; - switch (narrowType) { - case 'topic': - final channelIdStr = url.queryParameters['channel_id']!; - final channelId = int.parse(channelIdStr, radix: 10); - final topicStr = url.queryParameters['topic']!; - narrow = TopicNarrow(channelId, TopicName(topicStr)); - case 'dm': - final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!; - final allRecipientIds = allRecipientIdsStr.split(',') - .map((idStr) => int.parse(idStr, radix: 10)) - .toList(growable: false); - narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId); - default: - throw const FormatException(); - } - - return NotificationOpenPayload( - realmUrl: realmUrl, - userId: userId, - narrow: narrow, - ); - } else { - // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 - throw const FormatException(); - } - } - - Uri buildUrl() { - return Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': realmUrl.toString(), - 'user_id': userId.toString(), - ...(switch (narrow) { - TopicNarrow(streamId: var channelId, :var topic) => { - 'narrow_type': 'topic', - 'channel_id': channelId.toString(), - 'topic': topic.apiName, - }, - DmNarrow(:var allRecipientIds) => { - 'narrow_type': 'dm', - 'all_recipient_ids': allRecipientIds.join(','), - }, - _ => throw UnsupportedError('Found an unexpected Narrow of type ${narrow.runtimeType}.'), - }) - }, - ); - } -} diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart new file mode 100644 index 0000000000..1152148d48 --- /dev/null +++ b/lib/notifications/open.dart @@ -0,0 +1,343 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../api/model/model.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../host/notifications.dart'; +import '../log.dart'; +import '../model/binding.dart'; +import '../model/narrow.dart'; +import '../widgets/app.dart'; +import '../widgets/dialog.dart'; +import '../widgets/message_list.dart'; +import '../widgets/page.dart'; +import '../widgets/store.dart'; + +NotificationPigeonApi get _notifPigeonApi => ZulipBinding.instance.notificationPigeonApi; + +/// Responds to the user opening a notification. +class NotificationOpenService { + static NotificationOpenService get instance => (_instance ??= NotificationOpenService._()); + static NotificationOpenService? _instance; + + NotificationOpenService._(); + + /// Reset the state of the [NotificationNavigationService], for testing. + static void debugReset() { + _instance = null; + } + + NotificationDataFromLaunch? _notifDataFromLaunch; + + /// A [Future] that completes to signal that the initialization of + /// [NotificationNavigationService] has completed + /// (with either success or failure). + /// + /// Null if [start] hasn't been called. + Future? get initialized => _initializedSignal?.future; + + Completer? _initializedSignal; + + Future start() async { + assert(_initializedSignal == null); + _initializedSignal = Completer(); + try { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + _notifDataFromLaunch = await _notifPigeonApi.getNotificationDataFromLaunch(); + _notifPigeonApi.notificationTapEventsStream() + .listen(_navigateForNotification); + + case TargetPlatform.android: + // Do nothing; we do notification routing differently on Android. + // TODO migrate Android to use the new Pigeon API. + break; + + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + // Do nothing; we don't offer notifications on these platforms. + break; + } + } finally { + _initializedSignal!.complete(); + } + } + + /// Provides the route to open if the app was launched through a tap on + /// a notification. + /// + /// Returns null if app launch wasn't triggered by a notification, or if + /// an error occurs while determining the route for the notification. + /// In the latter case an error dialog is also shown. + /// + /// The context argument should be a descendant of the app's main [Navigator]. + AccountRoute? routeForNotificationFromLaunch({required BuildContext context}) { + assert(defaultTargetPlatform == TargetPlatform.iOS); + final data = _notifDataFromLaunch; + if (data == null) return null; + assert(debugLog('opened notif: ${jsonEncode(data.payload)}')); + + final notifNavData = _tryParseIosApnsPayload(context, data.payload); + if (notifNavData == null) return null; // TODO(log) + + return routeForNotification(context: context, data: notifNavData); + } + + /// Provides the route to open by parsing the notification payload. + /// + /// Returns null and shows an error dialog if the associated account is not + /// found in the global store. + /// + /// The context argument should be a descendant of the app's main [Navigator]. + static AccountRoute? routeForNotification({ + required BuildContext context, + required NotificationOpenPayload data, + }) { + final globalStore = GlobalStoreWidget.of(context); + + final account = globalStore.accounts.firstWhereOrNull( + (account) => account.realmUrl.origin == data.realmUrl.origin + && account.userId == data.userId); + if (account == null) { // TODO(log) + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle, + message: zulipLocalizations.errorNotificationOpenAccountNotFound); + return null; + } + + return MessageListPage.buildRoute( + accountId: account.id, + // TODO(#1565): Open at specific message, not just conversation + narrow: data.narrow); + } + + /// Navigates to the [MessageListPage] of the specific conversation + /// for the provided payload that was attached while creating the + /// notification. + static Future _navigateForNotification(NotificationTapEvent event) async { + assert(defaultTargetPlatform == TargetPlatform.iOS); + assert(debugLog('opened notif: ${jsonEncode(event.payload)}')); + + NavigatorState navigator = await ZulipApp.navigator; + final context = navigator.context; + assert(context.mounted); + if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that + + final notifNavData = _tryParseIosApnsPayload(context, event.payload); + if (notifNavData == null) return; // TODO(log) + final route = routeForNotification(context: context, data: notifNavData); + if (route == null) return; // TODO(log) + + // TODO(nav): Better interact with existing nav stack on notif open + unawaited(navigator.push(route)); + } + + /// Navigates to the [MessageListPage] of the specific conversation + /// given the `zulip://notification/…` Android intent data URL, + /// generated with [NotificationOpenPayload.buildAndroidNotificationUrl] + /// while creating the notification. + static Future navigateForAndroidNotificationUrl(Uri url) async { + assert(defaultTargetPlatform == TargetPlatform.android); + assert(debugLog('opened notif: url: $url')); + + NavigatorState navigator = await ZulipApp.navigator; + final context = navigator.context; + assert(context.mounted); + if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that + + assert(url.scheme == 'zulip' && url.host == 'notification'); + final data = tryParseAndroidNotificationUrl(context: context, url: url); + if (data == null) return; // TODO(log) + final route = routeForNotification(context: context, data: data); + if (route == null) return; // TODO(log) + + // TODO(nav): Better interact with existing nav stack on notif open + unawaited(navigator.push(route)); + } + + static NotificationOpenPayload? _tryParseIosApnsPayload( + BuildContext context, + Map payload, + ) { + try { + return NotificationOpenPayload.parseIosApnsPayload(payload); + } on FormatException catch (e, st) { + assert(debugLog('$e\n$st')); + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle); + return null; + } + } + + static NotificationOpenPayload? tryParseAndroidNotificationUrl({ + required BuildContext context, + required Uri url, + }) { + try { + return NotificationOpenPayload.parseAndroidNotificationUrl(url); + } on FormatException catch (e, st) { + assert(debugLog('$e\n$st')); + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle); + return null; + } + } +} + +/// The data from a notification that describes what to do +/// when the user opens the notification. +class NotificationOpenPayload { + final Uri realmUrl; + final int userId; + final Narrow narrow; + + NotificationOpenPayload({ + required this.realmUrl, + required this.userId, + required this.narrow, + }); + + /// Parses the iOS APNs payload and retrieves the information + /// required for navigation. + factory NotificationOpenPayload.parseIosApnsPayload(Map payload) { + if (payload case { + 'zulip': { + 'user_id': final int userId, + 'sender_id': final int senderId, + } && final zulipData, + }) { + final eventType = zulipData['event']; + if (eventType != null && eventType != 'message') { + // On Android, we also receive "remove" notification messages, tagged + // with an `event` field with value 'remove'. As of Zulip Server 10, + // however, these are not yet sent to iOS devices, and we don't have a + // way to handle them even if they were. + // + // The messages we currently do receive, and can handle, are analogous + // to Android notification messages of event type 'message'. On the + // assumption that some future version of the Zulip server will send + // explicit event types in APNs messages, accept messages with that + // `event` value, but no other. + throw const FormatException(); + } + + final realmUrl = switch (zulipData) { + {'realm_url': final String value} => value, + {'realm_uri': final String value} => value, + _ => throw const FormatException(), + }; + + final narrow = switch (zulipData) { + { + 'recipient_type': 'stream', + 'stream_id': final int streamId, + 'topic': final String topic, + } => + TopicNarrow(streamId, TopicName(topic)), + + {'recipient_type': 'private', 'pm_users': final String pmUsers} => + DmNarrow( + allRecipientIds: pmUsers + .split(',') + .map((e) => int.parse(e, radix: 10)) + .toList(growable: false) + ..sort(), + selfUserId: userId), + + {'recipient_type': 'private'} => + DmNarrow.withUser(senderId, selfUserId: userId), + + _ => throw const FormatException(), + }; + + return NotificationOpenPayload( + realmUrl: Uri.parse(realmUrl), + userId: userId, + narrow: narrow); + } else { + // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 + throw const FormatException(); + } + } + + /// Parses the internal Android notification url, that was created using + /// [buildAndroidNotificationUrl], and retrieves the information required + /// for navigation. + factory NotificationOpenPayload.parseAndroidNotificationUrl(Uri url) { + if (url case Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': var realmUrlStr, + 'user_id': var userIdStr, + 'narrow_type': var narrowType, + // In case of narrowType == 'topic': + // 'channel_id' and 'topic' handled below. + + // In case of narrowType == 'dm': + // 'all_recipient_ids' handled below. + }, + )) { + final realmUrl = Uri.parse(realmUrlStr); + final userId = int.parse(userIdStr, radix: 10); + + final Narrow narrow; + switch (narrowType) { + case 'topic': + final channelIdStr = url.queryParameters['channel_id']!; + final channelId = int.parse(channelIdStr, radix: 10); + final topicStr = url.queryParameters['topic']!; + narrow = TopicNarrow(channelId, TopicName(topicStr)); + case 'dm': + final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!; + final allRecipientIds = allRecipientIdsStr.split(',') + .map((idStr) => int.parse(idStr, radix: 10)) + .toList(growable: false); + narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId); + default: + throw const FormatException(); + } + + return NotificationOpenPayload( + realmUrl: realmUrl, + userId: userId, + narrow: narrow, + ); + } else { + // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 + throw const FormatException(); + } + } + + Uri buildAndroidNotificationUrl() { + return Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': realmUrl.toString(), + 'user_id': userId.toString(), + ...(switch (narrow) { + TopicNarrow(streamId: var channelId, :var topic) => { + 'narrow_type': 'topic', + 'channel_id': channelId.toString(), + 'topic': topic.apiName, + }, + DmNarrow(:var allRecipientIds) => { + 'narrow_type': 'dm', + 'all_recipient_ids': allRecipientIds.join(','), + }, + _ => throw UnsupportedError('Found an unexpected Narrow of type ${narrow.runtimeType}.'), + }) + }, + ); + } +} diff --git a/lib/notifications/receive.dart b/lib/notifications/receive.dart index 738bb98a2e..212b0f5f0d 100644 --- a/lib/notifications/receive.dart +++ b/lib/notifications/receive.dart @@ -8,6 +8,7 @@ import '../firebase_options.dart'; import '../log.dart'; import '../model/binding.dart'; import 'display.dart'; +import 'open.dart'; @pragma('vm:entry-point') class NotificationService { @@ -24,6 +25,7 @@ class NotificationService { instance.token.dispose(); _instance = null; assert(debugBackgroundIsolateIsLive = true); + NotificationOpenService.debugReset(); } /// Whether a background isolate should initialize [LiveZulipBinding]. @@ -77,6 +79,8 @@ class NotificationService { await _getFcmToken(); case TargetPlatform.iOS: // TODO(#324): defer requesting notif permission + await NotificationOpenService.instance.start(); + await ZulipBinding.instance.firebaseInitializeApp( options: kFirebaseOptionsIos); @@ -149,8 +153,10 @@ class NotificationService { await addFcmToken(connection, token: token); case TargetPlatform.iOS: - const appBundleId = 'com.zulip.flutter'; // TODO(#407) find actual value live - await addApnsToken(connection, token: token, appid: appBundleId); + final packageInfo = await ZulipBinding.instance.packageInfo; + await addApnsToken(connection, + token: token, + appid: packageInfo!.packageName); case TargetPlatform.linux: case TargetPlatform.macOS: 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 3eff182bca..ce1be46000 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -11,29 +12,39 @@ import '../api/model/model.dart'; import '../api/route/channels.dart'; import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/binding.dart'; +import '../model/content.dart'; import '../model/emoji.dart'; import '../model/internal_link.dart'; import '../model/narrow.dart'; import 'actions.dart'; -import 'clipboard.dart'; +import 'button.dart'; import 'color.dart'; import 'compose_box.dart'; +import 'content.dart'; import 'dialog.dart'; import 'emoji.dart'; import 'emoji_reaction.dart'; import 'icons.dart'; import 'inset_shadow.dart'; import 'message_list.dart'; +import 'page.dart'; +import 'read_receipts.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'topic_list.dart'; void _showActionSheet( - BuildContext context, { - required List optionButtons, + BuildContext pageContext, { + Widget? header, + required List> buttonSections, }) { + // Could omit this if we need _showActionSheet outside a per-account context. + final accountId = PerAccountStoreWidget.accountIdOf(pageContext); + showModalBottomSheet( - context: context, + context: pageContext, // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect // on my iPhone 13 Pro but is marked as "much slower": // https://api.flutter.dev/flutter/dart-ui/Clip.html @@ -41,27 +52,273 @@ void _showActionSheet( useSafeArea: true, isScrollControlled: true, builder: (BuildContext _) { - return SafeArea( - minimum: const EdgeInsets.only(bottom: 16), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - // TODO(#217): show message text - Flexible(child: InsetShadowBox( - top: 8, bottom: 8, - color: DesignVariables.of(context).bgContextMenu, - child: SingleChildScrollView( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: ClipRRect( - borderRadius: BorderRadius.circular(7), - child: Column(spacing: 1, - children: optionButtons))))), - const ActionSheetCancelButton(), - ]))); + final designVariables = DesignVariables.of(pageContext); + return PerAccountStoreWidget( + accountId: accountId, + child: Semantics( + role: SemanticsRole.menu, + child: SafeArea( + minimum: const EdgeInsets.only(bottom: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (header != null) + Flexible( + // TODO(upstream) Enforce a flex ratio (e.g. 1:3) + // only when the header height plus the buttons' height + // exceeds available space. Otherwise let one or the other + // grow to fill available space even if it breaks the ratio. + // Needs support for separate properties like `flex-grow` + // and `flex-shrink`. + flex: 1, + child: InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgContextMenu, + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(vertical: 8), + child: header))) + else + SizedBox(height: 8), + Flexible( + flex: 3, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgContextMenu, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: buttonSections.map((buttons) => + MenuButtonsShape(buttons: buttons)).toList())))), + const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.cancel), + ]))), + ])))); + }); +} + +typedef WidgetBuilderFromTextStyle = Widget Function(TextStyle); + +/// A header for a bottom sheet with an optional title and multiline message. +/// +/// A title, message, or both must be provided. +/// +/// Provide a title by passing [title] or [buildTitle] (not both). +/// Provide a message by passing [message] or [buildMessage] (not both). +/// The "build" params support richer content, such as [TextWithLink], +/// and the callback is passed a [TextStyle] which is the base style. +/// +/// Assumes 8px padding below the top of the bottom sheet. +/// +/// Figma; just message no title: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3481-26993&m=dev +/// +/// Figma; title and message: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6326-96125&m=dev +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=11367-20898&m=dev +/// The latter example (read receipts) has more horizontal and bottom padding; +/// that looks like an accident that we don't need to follow. +/// It also colors the message text more opaquely…that difference might be +/// intentional, but Vlad's time is limited and I prefer consistency. +class BottomSheetHeader extends StatelessWidget { + const BottomSheetHeader({ + super.key, + this.title, + this.buildTitle, + this.message, + this.buildMessage, + }) : assert(message == null || buildMessage == null), + assert(title == null || buildTitle == null), + assert((message != null || buildMessage != null) + || (title != null || buildTitle != null)); + + final String? title; + final Widget Function(TextStyle)? buildTitle; + final String? message; + final Widget Function(TextStyle)? buildMessage; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + final baseTitleStyle = TextStyle( + fontSize: 20, + height: 20 / 20, + color: designVariables.title, + ).merge(weightVariableTextStyle(context, wght: 600)); + + final effectiveTitle = switch ((buildTitle, title)) { + (final build?, null) => build(baseTitleStyle), + (null, final data?) => Text(style: baseTitleStyle, data), + _ => null, + }; + + final baseMessageStyle = TextStyle( + color: designVariables.labelTime, + fontSize: 17, + height: 22 / 17); + + final effectiveMessage = switch ((buildMessage, message)) { + (final build?, null) => build(baseMessageStyle), + (null, final data?) => Text(style: baseMessageStyle, data), + _ => null, + }; + + return Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [?effectiveTitle, ?effectiveMessage])); + } +} + +/// A placeholder for when a bottom sheet has no content to show. +/// +/// Pass [message] for a "no-content-here" message, +/// or pass true for [loading] if the content hasn't finished loading yet, +/// but don't pass both. +/// +/// Show this below a [BottomSheetHeader] if present. +/// +/// See also: +/// * [PageBodyEmptyContentPlaceholder], for a similar element to use in +/// pages on the home screen. +// TODO(design) we don't yet have a design for this; +// it was ad-hoc and modeled on [PageBodyEmptyContentPlaceholder]. +class BottomSheetEmptyContentPlaceholder extends StatelessWidget { + const BottomSheetEmptyContentPlaceholder({ + super.key, + this.message, + this.loading = false, + }) : assert((message != null) ^ loading); + + final String? message; + final bool loading; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + final child = loading + ? CircularProgressIndicator() + : Text( + textAlign: TextAlign.center, + style: TextStyle( + color: designVariables.labelSearchPrompt, + fontSize: 17, + height: 23 / 17, + ).merge(weightVariableTextStyle(context, wght: 500)), + message!); + + return Padding( + padding: EdgeInsets.fromLTRB(24, 48, 24, 16), + child: Align( + alignment: Alignment.topCenter, + child: child)); + } +} + +/// A bottom sheet that resizes, scrolls, and dismisses in response to dragging. +/// +/// [header] is assumed to occupy the full width its parent allows. +/// (This is important for the clipping/shadow effect when [contentSliver] +/// scrolls under the header.) +/// +/// The sheet's initial height and minimum height before dismissing +/// are set proportionally to the screen's height. +/// The screen's height is read from the parent's max-height constraint, +/// so the caller should not introduce widgets that interfere with that. +/// (Non-layout wrapper widgets such as [InheritedWidget]s are OK.) +/// +/// The sheet's dismissal works like this: +/// - A "Close" button is offered. +/// - A drag-down or fling on the header or the [contentSliver] +/// causes those areas to shrink past a threshold at which the sheet +/// decides to dismiss. +/// - The [enableDrag] param of upstream's [showModalBottomSheet] +/// only seems to affect gesture handling on the Close button and its padding +/// (which are not part of the resizable/scrollable area): +/// - When true, the Close button responds to a downward fling by +/// sliding the sheet downward and dismissing it +/// (i.e. not by the usual behavior where the header- and-content height +/// shrinks past a threshold, causing dismissal). +/// - When false, the Close button doesn't respond to a downward fling. +class DraggableScrollableModalBottomSheet extends StatelessWidget { + const DraggableScrollableModalBottomSheet({ + super.key, + required this.header, + required this.contentSliver, + }); + + final Widget header; + final Widget contentSliver; + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + expand: false, + builder: (context, controller) { + final backgroundColor = Theme.of(context).bottomSheetTheme.backgroundColor!; + + // The "inset shadow" effect in Figma is a bit awkwardly + // implemented here, and there might be a better factoring: + // 1. This effect leans on the abstraction that [contentSliver] + // is simply a scrollable area in its own viewport. + // We'd normally just wrap that viewport in [InsetShadowBox]. + // 2. Really, though, the scrollable includes the header, + // pinned to the viewport top. We do this to support resizing + // (and dismiss-on-min-height) on gestures in the header, too, + // uniformly with the content. + // 3. So for the top shadow, we tack a shadow gradient onto the header, + // exploiting the header's pinning behavior to keep it fixed. + // 3. For the bottom, I haven't found a nice sliver-based implementation + // that supports pinning a shadow overlay at the viewport bottom. + // So for the bottom we use [InsetShadowBox] around the viewport, + // with just `bottom:` and no `top:`. + + final headerWithShadow = Column( + mainAxisSize: MainAxisSize.min, + children: [ + ColoredBox( + color: backgroundColor, + child: header), + SizedBox(height: 8, width: double.infinity, + child: DecoratedBox(decoration: fadeToTransparencyDecoration( + FadeToTransparencyDirection.down, backgroundColor))), + ]); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: InsetShadowBox( + bottom: 8, + color: backgroundColor, + child: CustomScrollView( + // The iOS default "bouncing" effect would look uncoordinated + // in the common case where overscroll co-occurs with + // shrinking the sheet past the threshold where it dismisses. + physics: ClampingScrollPhysics(), + controller: controller, + slivers: [ + PinnedHeaderSliver(child: headerWithShadow), + SliverPadding( + padding: EdgeInsets.only(bottom: 8), + sliver: contentSliver), + ]))), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.close)) + ]); }); + } } /// A button in an action sheet. @@ -117,31 +374,31 @@ abstract class ActionSheetMenuItemButton extends StatelessWidget { @override Widget build(BuildContext context) { - final designVariables = DesignVariables.of(context); final zulipLocalizations = ZulipLocalizations.of(context); - return MenuItemButton( - trailingIcon: Icon(icon, color: designVariables.contextMenuItemText), - style: MenuItemButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - foregroundColor: designVariables.contextMenuItemText, - splashFactory: NoSplash.splashFactory, - ).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) => - designVariables.contextMenuItemBg.withFadedAlpha( - states.contains(WidgetState.pressed) ? 0.20 : 0.12))), + return ZulipMenuItemButton( + icon: icon, + label: label(zulipLocalizations), onPressed: () => _handlePressed(context), - child: Text(label(zulipLocalizations), - style: const TextStyle(fontSize: 20, height: 24 / 20) - .merge(weightVariableTextStyle(context, wght: 600)), - )); + ); } } -class ActionSheetCancelButton extends StatelessWidget { - const ActionSheetCancelButton({super.key}); +/// A stretched gray "Cancel" / "Close" button for the bottom of a bottom sheet. +class BottomSheetDismissButton extends StatelessWidget { + const BottomSheetDismissButton({super.key, required this.style}); + + final BottomSheetDismissButtonStyle style; @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final label = switch (style) { + BottomSheetDismissButtonStyle.cancel => zulipLocalizations.dialogCancel, + BottomSheetDismissButtonStyle.close => zulipLocalizations.dialogClose, + }; + return TextButton( style: TextButton.styleFrom( minimumSize: const Size.fromHeight(44), @@ -156,26 +413,285 @@ class ActionSheetCancelButton extends StatelessWidget { onPressed: () { Navigator.pop(context); }, - child: Text(ZulipLocalizations.of(context).dialogCancel, + child: Text(label, style: const TextStyle(fontSize: 20, height: 24 / 20) .merge(weightVariableTextStyle(context, wght: 600)))); } } +enum BottomSheetDismissButtonStyle { + /// The "Cancel" label, for action sheets. + cancel, + + /// The "Close" label, for bottom sheets that are read-only or for navigation. + close, +} + +/// Show a sheet of actions you can take on a channel. +/// +/// Needs a [PageRoot] ancestor. +/// May or may not have a [MessageListPage] ancestor; +/// some callers are on that page and some aren't. +void showChannelActionSheet(BuildContext context, { + required int channelId, + bool showTopicListButton = true, +}) { + final pageContext = PageRoot.contextOf(context); + final store = PerAccountStoreWidget.of(pageContext); + final messageListPageState = MessageListPage.maybeAncestorOf(pageContext); + + final messageListPageNarrow = messageListPageState?.narrow; + final isOnChannelFeed = messageListPageNarrow is ChannelNarrow + && messageListPageNarrow.streamId == channelId; + + final unreadCount = store.unreads.countInChannelNarrow(channelId); + final channel = store.streams[channelId]; + final isSubscribed = channel is Subscription; + final buttonSections = [ + if (!isSubscribed + && channel != null && store.selfHasContentAccess(channel)) + [SubscribeButton(pageContext: pageContext, channelId: channelId)], + [ + if (unreadCount > 0) + MarkChannelAsReadButton(pageContext: pageContext, channelId: channelId), + if (showTopicListButton) + TopicListButton(pageContext: pageContext, channelId: channelId), + if (!isOnChannelFeed) + ChannelFeedButton(pageContext: pageContext, channelId: channelId), + CopyChannelLinkButton(channelId: channelId, pageContext: pageContext) + ], + if (isSubscribed) + [UnsubscribeButton(pageContext: pageContext, channelId: channelId)], + ]; + + _showActionSheet(pageContext, buttonSections: buttonSections); +} + +class SubscribeButton extends ActionSheetMenuItemButton { + const SubscribeButton({ + super.key, + required this.channelId, + required super.pageContext, + }); + + final int channelId; + + @override + IconData get icon => ZulipIcons.plus; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionSubscribe; + } + + @override + void onPressed() async { + final store = PerAccountStoreWidget.of(pageContext); + final channel = store.streams[channelId]; + if (channel == null || channel is Subscription) return; // TODO could give feedback + + try { + await subscribeToChannel(store.connection, subscriptions: [channel.name]); + } 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 = ZulipLocalizations.of(pageContext).subscribeFailedTitle; + showErrorDialog(context: pageContext, title: title, message: errorMessage); + } + } +} + +class MarkChannelAsReadButton extends ActionSheetMenuItemButton { + const MarkChannelAsReadButton({ + super.key, + required this.channelId, + required super.pageContext, + }); + + final int channelId; + + @override + IconData get icon => ZulipIcons.message_checked; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionMarkChannelAsRead; + } + + @override + void onPressed() async { + final narrow = ChannelNarrow(channelId); + await ZulipAction.markNarrowAsRead(pageContext, narrow); + } +} + +class TopicListButton extends ActionSheetMenuItemButton { + const TopicListButton({ + super.key, + required this.channelId, + required super.pageContext, + }); + + final int channelId; + + @override + IconData get icon => ZulipIcons.topics; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionListOfTopics; + } + + @override + void onPressed() { + Navigator.push(pageContext, + TopicListPage.buildRoute(context: pageContext, streamId: channelId)); + } +} + +class ChannelFeedButton extends ActionSheetMenuItemButton { + const ChannelFeedButton({ + super.key, + required this.channelId, + required super.pageContext, + }); + + final int channelId; + + @override + IconData get icon => ZulipIcons.message_feed; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionChannelFeed; + } + + @override + void onPressed() { + Navigator.push(pageContext, + MessageListPage.buildRoute(context: pageContext, narrow: ChannelNarrow(channelId))); + } +} + +class CopyChannelLinkButton extends ActionSheetMenuItemButton { + const CopyChannelLinkButton({ + super.key, + required this.channelId, + required super.pageContext, + }); + + final int channelId; + + @override + IconData get icon => ZulipIcons.link; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionCopyChannelLink; + } + + @override + void onPressed() async { + final zulipLocalizations = ZulipLocalizations.of(pageContext); + final store = PerAccountStoreWidget.of(pageContext); + + PlatformActions.copyWithPopup(context: pageContext, + successContent: Text(zulipLocalizations.successChannelLinkCopied), + data: ClipboardData(text: narrowLink(store, ChannelNarrow(channelId)).toString())); + } +} + +class UnsubscribeButton extends ActionSheetMenuItemButton { + const UnsubscribeButton({ + super.key, + required this.channelId, + required super.pageContext, + }); + + final int channelId; + + @override + IconData get icon => ZulipIcons.circle_x; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionUnsubscribe; + } + + @override + void onPressed() async { + final subscription = PerAccountStoreWidget.of(pageContext).subscriptions[channelId]; + if (subscription == null) return; // TODO could give feedback + + // TODO(#1786) check group-based permission to subscribe, then replace + // error message with a new one saying "will not" instead of "might not" + // TODO(future) check if the self-user is a guest and the channel is not web-public + final couldResubscribe = !subscription.inviteOnly; + if (!couldResubscribe) { + // TODO(#1788) warn if org would lose content access (nobody can subscribe) + final zulipLocalizations = ZulipLocalizations.of(pageContext); + + final dialog = showSuggestedActionDialog(context: pageContext, + title: zulipLocalizations.unsubscribeConfirmationDialogTitle(subscription.name), + message: zulipLocalizations.unsubscribeConfirmationDialogMessageMaybeCannotResubscribe, + // TODO(#1032) "destructive" style for action button + actionButtonText: zulipLocalizations.unsubscribeConfirmationDialogConfirmButton); + if (await dialog.result != true) return; + if (!pageContext.mounted) return; + } + + try { + await unsubscribeFromChannel(PerAccountStoreWidget.of(pageContext).connection, + subscriptions: [subscription.name]); + } 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 = ZulipLocalizations.of(pageContext).unsubscribeFailedTitle; + showErrorDialog(context: pageContext, title: title, message: errorMessage); + } + } +} + /// 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 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 = []; // TODO(server-7): simplify this condition away - final supportsUnmutingTopics = store.connection.zulipFeatureLevel! >= 170; + final supportsUnmutingTopics = store.zulipFeatureLevel >= 170; // TODO(server-8): simplify this condition away - final supportsFollowingTopics = store.connection.zulipFeatureLevel! >= 219; + final supportsFollowingTopics = store.zulipFeatureLevel >= 219; final visibilityOptions = []; final visibilityPolicy = store.topicVisibilityPolicy(channelId, topic); @@ -237,20 +753,30 @@ void showTopicActionSheet(BuildContext context, { currentVisibilityPolicy: visibilityPolicy, newVisibilityPolicy: to, narrow: TopicNarrow(channelId, topic), - pageContext: context); + pageContext: pageContext); })); - if (optionButtons.isEmpty) { - // TODO(a11y): This case makes a no-op gesture handler; as a consequence, - // we're presenting some UI (to people who use screen-reader software) as - // though it offers a gesture interaction that it doesn't meaningfully - // offer, which is confusing. The solution here is probably to remove this - // is-empty case by having at least one button that's always present, - // such as "copy link to topic". - return; + // TODO: check for other cases that may disallow this action (e.g.: time + // limit for editing topics). + if (someMessageIdInTopic != null && topic.displayName != null) { + optionButtons.add(ResolveUnresolveButton(pageContext: pageContext, + topic: topic, + someMessageIdInTopic: someMessageIdInTopic)); } - _showActionSheet(context, optionButtons: optionButtons); + final unreadCount = store.unreads.countInTopicNarrow(channelId, topic); + if (unreadCount > 0) { + optionButtons.add(MarkTopicAsReadButton( + channelId: channelId, + topic: topic, + pageContext: context)); + } + + optionButtons.add(CopyTopicLinkButton( + narrow: TopicNarrow(channelId, topic, with_: someMessageIdInTopic), + pageContext: context)); + + _showActionSheet(pageContext, buttonSections: [optionButtons]); } class UserTopicUpdateButton extends ActionSheetMenuItemButton { @@ -372,37 +898,215 @@ 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); + } + } +} + +class MarkTopicAsReadButton extends ActionSheetMenuItemButton { + const MarkTopicAsReadButton({ + super.key, + required this.channelId, + required this.topic, + required super.pageContext, + }); + + final int channelId; + final TopicName topic; + + @override IconData get icon => ZulipIcons.message_checked; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionMarkTopicAsRead; + } + + @override void onPressed() async { + await ZulipAction.markNarrowAsRead(pageContext, TopicNarrow(channelId, topic)); + } +} + +class CopyTopicLinkButton extends ActionSheetMenuItemButton { + const CopyTopicLinkButton({ + super.key, + required this.narrow, + required super.pageContext, + }); + + final TopicNarrow narrow; + + @override IconData get icon => ZulipIcons.link; + + @override + String label(ZulipLocalizations localizations) { + return localizations.actionSheetOptionCopyTopicLink; + } + + @override void onPressed() async { + final zulipLocalizations = ZulipLocalizations.of(pageContext); + final store = PerAccountStoreWidget.of(pageContext); + + PlatformActions.copyWithPopup(context: pageContext, + successContent: Text(zulipLocalizations.successTopicLinkCopied), + data: ClipboardData(text: narrowLink(store, narrow).toString())); + } +} + /// 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); + + final popularEmojiLoaded = store.popularEmojiCandidates().isNotEmpty; + + final reactions = message.reactions; + final hasReactions = reactions != null && reactions.total > 0; + + final readReceiptsEnabled = store.realmEnableReadReceipts; // 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 isComposeBoxOffered = messageListPage.composeBoxController != null; + final messageListPage = MessageListPage.ancestorOf(pageContext); + final isComposeBoxOffered = messageListPage.composeBoxState != null; final isMessageRead = message.flags.contains(MessageFlag.read); - final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155; // TODO(server-6) + final markAsUnreadSupported = store.zulipFeatureLevel >= 155; // TODO(server-6) final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead; + final isSenderMuted = store.isUserMuted(message.senderId); + final optionButtons = [ - ReactionButtons(message: message, pageContext: context), - StarButton(message: message, pageContext: context), + if (popularEmojiLoaded) + ReactionButtons(message: message, pageContext: pageContext), + if (hasReactions) + ViewReactionsButton(message: message, pageContext: pageContext), + if (readReceiptsEnabled) + ViewReadReceiptsButton(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), + if (isSenderMuted) + // The message must have been revealed in order to open this action sheet. + UnrevealMutedMessageButton(message: message, pageContext: pageContext), + CopyMessageTextButton(message: message, pageContext: pageContext), + CopyMessageLinkButton(message: message, pageContext: pageContext), + ShareButton(message: message, pageContext: pageContext), + if (_getShouldShowEditButton(pageContext, message)) + EditButton(message: message, pageContext: pageContext), ]; - _showActionSheet(context, optionButtons: optionButtons); + _showActionSheet(pageContext, + buttonSections: [optionButtons], + header: _MessageActionSheetHeader(message: message)); +} + +class _MessageActionSheetHeader extends StatelessWidget { + const _MessageActionSheetHeader({required this.message}); + + final Message message; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + // TODO this seems to lose the hero animation when opening an image; + // investigate. + // TODO should we close the sheet before opening a narrow link? + // On popping the pushed narrow route, the sheet is still open. + + return Container( + // TODO(#647) use different color for highlighted messages + // TODO(#681) use different color for DM messages + color: designVariables.bgMessageRegular, + padding: EdgeInsets.symmetric(vertical: 4), + child: Column( + spacing: 4, + children: [ + SenderRow(message: message, + timestampStyle: MessageTimestampStyle.full), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + // TODO(#10) offer text selection; the Figma asks for it here: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3483-30210&m=dev + child: MessageContent(message: message, content: parseMessageContent(message))), + ])); + } } abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButton { @@ -415,6 +1119,36 @@ abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButto final Message message; } +bool _getShouldShowEditButton(BuildContext pageContext, Message message) { + final store = PerAccountStoreWidget.of(pageContext); + + final messageListPage = MessageListPage.ancestorOf(pageContext); + final composeBoxState = messageListPage.composeBoxState; + final isComposeBoxOffered = composeBoxState != null; + final composeBoxController = composeBoxState?.controller; + + final editMessageErrorStatus = store.getEditMessageErrorStatus(message.id); + final editMessageInProgress = + // The compose box is in edit-message mode, with Cancel/Save instead of Send. + composeBoxController is EditMessageComposeBoxController + // An edit request is in progress or the error state. + || editMessageErrorStatus != null; + + final now = ZulipBinding.instance.utcNow().millisecondsSinceEpoch ~/ 1000; + final editLimit = store.realmMessageContentEditLimitSeconds; + final outsideEditLimit = + editLimit != null + && editLimit != 0 // TODO(server-6) remove (pre-FL 138, 0 represents no limit) + && now - message.timestamp > editLimit; + + return message.senderId == store.selfUserId + && isComposeBoxOffered + && store.realmAllowMessageEditing + && !outsideEditLimit + && !editMessageInProgress + && message.poll == null; // messages with polls cannot be edited +} + class ReactionButtons extends StatelessWidget { const ReactionButtons({ super.key, @@ -447,14 +1181,22 @@ class ReactionButtons extends StatelessWidget { : zulipLocalizations.errorReactionAddingFailedTitle); } - void _handleTapMore() { + void _handleTapMore() async { // TODO(design): have emoji picker slide in from right and push // action sheet off to the left // Dismiss current action sheet before opening emoji picker sheet. Navigator.of(pageContext).pop(); - showEmojiPickerSheet(pageContext: pageContext, message: message); + final emoji = await showEmojiPickerSheet(pageContext: pageContext); + if (emoji == null || !pageContext.mounted) return; + unawaited(doAddOrRemoveReaction( + context: pageContext, + doRemoveReaction: false, + messageId: message.id, + emoji: emoji, + errorDialogTitle: + ZulipLocalizations.of(pageContext).errorReactionAddingFailedTitle)); } Widget _buildButton({ @@ -483,17 +1225,23 @@ class ReactionButtons extends StatelessWidget { : null, child: UnicodeEmojiWidget( emojiDisplay: emoji.emojiDisplay as UnicodeEmojiDisplay, - notoColorEmojiTextSize: 20.1, size: 24)))); } @override Widget build(BuildContext context) { - assert(EmojiStore.popularEmojiCandidates.every( + final store = PerAccountStoreWidget.of(pageContext); + final popularEmojiCandidates = store.popularEmojiCandidates(); + assert(popularEmojiCandidates.every( (emoji) => emoji.emojiType == ReactionType.unicodeEmoji)); + // (if this is empty, the widget isn't built in the first place) + assert(popularEmojiCandidates.isNotEmpty); + // UI not designed to handle more than 6 popular emoji. + // (We might have fewer if ServerEmojiData is lacking expected data, + // but that looks fine in manual testing, even when there's just one.) + assert(popularEmojiCandidates.length <= 6); final zulipLocalizations = ZulipLocalizations.of(context); - final store = PerAccountStoreWidget.of(pageContext); final designVariables = DesignVariables.of(context); bool hasSelfVote(EmojiCandidate emoji) { @@ -509,7 +1257,7 @@ class ReactionButtons extends StatelessWidget { color: designVariables.contextMenuItemBg.withFadedAlpha(0.12)), child: Row(children: [ Flexible(child: Row(spacing: 1, children: List.unmodifiable( - EmojiStore.popularEmojiCandidates.mapIndexed((index, emoji) => + popularEmojiCandidates.mapIndexed((index, emoji) => _buildButton( context: context, emoji: emoji, @@ -542,6 +1290,36 @@ class ReactionButtons extends StatelessWidget { } } +class ViewReactionsButton extends MessageActionSheetMenuItemButton { + ViewReactionsButton({super.key, required super.message, required super.pageContext}); + + @override IconData get icon => ZulipIcons.see_who_reacted; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionSeeWhoReacted; + } + + @override void onPressed() { + showViewReactionsSheet(pageContext, messageId: message.id); + } +} + +class ViewReadReceiptsButton extends MessageActionSheetMenuItemButton { + ViewReadReceiptsButton({super.key, required super.message, required super.pageContext}); + + @override IconData get icon => ZulipIcons.check_check; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionViewReadReceipts; + } + + @override void onPressed() { + showReadReceiptsSheet(pageContext, messageId: message.id); + } +} + class StarButton extends MessageActionSheetMenuItemButton { StarButton({super.key, required super.message, required super.pageContext}); @@ -587,54 +1365,6 @@ class StarButton extends MessageActionSheetMenuItemButton { } } -/// Fetch and return the raw Markdown content for [messageId], -/// showing an error dialog on failure. -Future fetchRawContentWithFeedback({ - required BuildContext context, - required int messageId, - required String errorDialogTitle, -}) async { - Message? fetchedMessage; - String? errorMessage; - // TODO, supported by reusable code: - // - (?) Retry with backoff on plausibly transient errors. - // - If request(s) take(s) a long time, show snackbar with cancel - // button, like "Still working on quote-and-reply…". - // On final failure or success, auto-dismiss the snackbar. - final zulipLocalizations = ZulipLocalizations.of(context); - try { - fetchedMessage = await getMessageCompat(PerAccountStoreWidget.of(context).connection, - messageId: messageId, - applyMarkdown: false, - ); - if (fetchedMessage == null) { - errorMessage = zulipLocalizations.errorMessageDoesNotSeemToExist; - } - } catch (e) { - switch (e) { - case ZulipApiException(): - errorMessage = e.message; - // TODO specific messages for common errors, like network errors - // (support with reusable code) - default: - errorMessage = zulipLocalizations.errorCouldNotFetchMessageSource; - } - } - - if (!context.mounted) return null; - - if (fetchedMessage == null) { - assert(errorMessage != null); - // TODO(?) give no feedback on error conditions we expect to - // flag centrally in event polling, like invalid auth, - // user/realm deactivated. (Support with reusable code.) - showErrorDialog(context: context, - title: errorDialogTitle, message: errorMessage); - } - - return fetchedMessage?.content; -} - class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { QuoteAndReplyButton({super.key, required super.message, required super.pageContext}); @@ -642,14 +1372,14 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { @override String label(ZulipLocalizations zulipLocalizations) { - return zulipLocalizations.actionSheetOptionQuoteAndReply; + return zulipLocalizations.actionSheetOptionQuoteMessage; } @override void onPressed() async { final zulipLocalizations = ZulipLocalizations.of(pageContext); final message = this.message; - var composeBoxController = findMessageListPage().composeBoxController; + var composeBoxController = findMessageListPage().composeBoxState?.controller; // The compose box doesn't null out its controller; it's either always null // (e.g. in Combined Feed) or always non-null; it can't have been nulled out // after the action sheet opened. @@ -671,7 +1401,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { message: message, ); - final rawContent = await fetchRawContentWithFeedback( + final rawContent = await ZulipAction.fetchRawContentWithFeedback( context: pageContext, messageId: message.id, errorDialogTitle: zulipLocalizations.errorQuotationFailed, @@ -679,7 +1409,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { if (!pageContext.mounted) return; - composeBoxController = findMessageListPage().composeBoxController; + composeBoxController = findMessageListPage().composeBoxState?.controller; // The compose box doesn't null out its controller; it's either always null // (e.g. in Combined Feed) or always non-null; it can't have been nulled out // during the raw-content request. @@ -705,8 +1435,35 @@ class MarkAsUnreadButton extends MessageActionSheetMenuItemButton { } @override void onPressed() async { - final narrow = findMessageListPage().narrow; - unawaited(markNarrowAsUnreadFromMessage(pageContext, message, narrow)); + final messageListPage = findMessageListPage(); + unawaited(ZulipAction.markNarrowAsUnreadFromMessage(pageContext, + message, messageListPage.narrow)); + // TODO should we alert the user about this change somehow? A snackbar? + messageListPage.markReadOnScroll = false; + } +} + +class UnrevealMutedMessageButton extends MessageActionSheetMenuItemButton { + UnrevealMutedMessageButton({ + super.key, + required super.message, + required super.pageContext, + }); + + @override + IconData get icon => ZulipIcons.eye_off; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionHideMutedMessage; + } + + @override + void onPressed() { + // The message should have been revealed in order to reach this action sheet. + assert(MessageListPage.maybeRevealedMutedMessagesOf(pageContext)! + .isMutedMessageRevealed(message.id)); + findMessageListPage().unrevealMutedMessage(message.id); } } @@ -722,12 +1479,13 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton { @override void onPressed() async { // This action doesn't show request progress. - // But hopefully it won't take long at all; and - // fetchRawContentWithFeedback has a TODO for giving feedback if it does. + // But hopefully it won't take long at all, + // and [ZulipAction.fetchRawContentWithFeedback] has a TODO + // for giving feedback if it does. final zulipLocalizations = ZulipLocalizations.of(pageContext); - final rawContent = await fetchRawContentWithFeedback( + final rawContent = await ZulipAction.fetchRawContentWithFeedback( context: pageContext, messageId: message.id, errorDialogTitle: zulipLocalizations.errorCopyingFailed, @@ -737,7 +1495,7 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton { if (!pageContext.mounted) return; - copyWithPopup(context: pageContext, + PlatformActions.copyWithPopup(context: pageContext, successContent: Text(zulipLocalizations.successMessageTextCopied), data: ClipboardData(text: rawContent)); } @@ -746,7 +1504,7 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton { class CopyMessageLinkButton extends MessageActionSheetMenuItemButton { CopyMessageLinkButton({super.key, required super.message, required super.pageContext}); - @override IconData get icon => Icons.link; + @override IconData get icon => ZulipIcons.link; @override String label(ZulipLocalizations zulipLocalizations) { @@ -763,7 +1521,7 @@ class CopyMessageLinkButton extends MessageActionSheetMenuItemButton { nearMessageId: message.id, ); - copyWithPopup(context: pageContext, + PlatformActions.copyWithPopup(context: pageContext, successContent: Text(zulipLocalizations.successMessageLinkCopied), data: ClipboardData(text: messageLink.toString())); } @@ -794,7 +1552,7 @@ class ShareButton extends MessageActionSheetMenuItemButton { final zulipLocalizations = ZulipLocalizations.of(pageContext); - final rawContent = await fetchRawContentWithFeedback( + final rawContent = await ZulipAction.fetchRawContentWithFeedback( context: pageContext, messageId: message.id, errorDialogTitle: zulipLocalizations.errorSharingFailed, @@ -809,7 +1567,8 @@ class ShareButton extends MessageActionSheetMenuItemButton { // https://pub.dev/packages/share_plus#ipad // Perhaps a wart in the API; discussion: // https://github.com/zulip/zulip-flutter/pull/12#discussion_r1130146231 - final result = await Share.share(rawContent); + final result = + await SharePlus.instance.share(ShareParams(text: rawContent)); switch (result.status) { // The plugin isn't very helpful: "The status can not be determined". @@ -824,3 +1583,22 @@ class ShareButton extends MessageActionSheetMenuItemButton { } } } + +class EditButton extends MessageActionSheetMenuItemButton { + EditButton({super.key, required super.message, required super.pageContext}); + + @override + IconData get icon => ZulipIcons.edit; + + @override + String label(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.actionSheetOptionEditMessage; + + @override void onPressed() async { + final composeBoxState = findMessageListPage().composeBoxState; + if (composeBoxState == null) { + throw StateError('Compose box unexpectedly absent when edit-message button pressed'); + } + composeBoxState.startEditInteraction(message.id); + } +} diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart index 86c6e44875..4c725f9878 100644 --- a/lib/widgets/actions.dart +++ b/lib/widgets/actions.dart @@ -1,258 +1,308 @@ -/// Methods that act through the Zulip API and show feedback in the UI. -/// -/// The methods in this file can be thought of as higher-level wrappers for -/// some of the Zulip API endpoint binding methods in `lib/api/route/`. -/// But they don't belong in `lib/api/`, because they also interact with widgets -/// in order to present success or error feedback to the user through the UI. -library; - import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../api/exception.dart'; import '../api/model/model.dart'; import '../api/model/narrow.dart'; import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/binding.dart'; import '../model/narrow.dart'; -import '../model/store.dart'; -import '../notifications/receive.dart'; import 'dialog.dart'; import 'store.dart'; -Future logOutAccount(BuildContext context, int accountId) async { - final globalStore = GlobalStoreWidget.of(context); +/// Methods that act through the Zulip API and show feedback in the UI. +/// +/// The static methods on this class can be thought of as higher-level wrappers +/// for some of the Zulip API endpoint binding methods in `lib/api/route/`. +/// But they don't belong in `lib/api/`, because they also interact with widgets +/// in order to present success or error feedback to the user through the UI. +abstract final class ZulipAction { + /// Mark the given narrow as read, + /// showing feedback to the user on progress or failure. + /// + /// This is mostly a wrapper around [updateMessageFlagsStartingFromAnchor]; + /// for details on the UI feedback, see there. + static Future markNarrowAsRead(BuildContext context, Narrow narrow) async { + final zulipLocalizations = ZulipLocalizations.of(context); - final account = globalStore.getAccount(accountId); - if (account == null) return; // TODO(log) + final didPass = await updateMessageFlagsStartingFromAnchor( + context: context, + // Include `is:unread` in the narrow. That has a database index, so + // this can be an important optimization in narrows with a lot of history. + // The server applies the same optimization within the (deprecated) + // specialized endpoints for marking messages as read; see + // `do_mark_stream_messages_as_read` in `zulip:zerver/actions/message_flags.py`. + apiNarrow: narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)), + // Use [AnchorCode.oldest], because [AnchorCode.firstUnread] + // will be the oldest non-muted unread message, which would + // result in muted unreads older than the first unread not + // being processed. + anchor: AnchorCode.oldest, + // [AnchorCode.oldest] is an anchor ID lower than any valid + // message ID. + includeAnchor: false, + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read, + onCompletedMessage: zulipLocalizations.markAsReadComplete, + progressMessage: zulipLocalizations.markAsReadInProgress, + onFailedTitle: zulipLocalizations.errorMarkAsReadFailedTitle); - // Unawaited, to not block removing the account on this request. - unawaited(unregisterToken(globalStore, accountId)); + if (!didPass || !context.mounted) return; + if (narrow is CombinedFeedNarrow) { + PerAccountStoreWidget.of(context).unreads.handleAllMessagesReadSuccess(); + } + } - await globalStore.removeAccount(accountId); -} + /// Mark the given narrow as unread from the given message onward, + /// showing feedback to the user on progress or failure. + /// + /// This is a wrapper around [updateMessageFlagsStartingFromAnchor]; + /// for details on the UI feedback, see there. + static Future markNarrowAsUnreadFromMessage( + BuildContext context, + Message message, + Narrow narrow, + ) async { + assert(PerAccountStoreWidget.of(context).zulipFeatureLevel >= 155); // TODO(server-6) + final zulipLocalizations = ZulipLocalizations.of(context); + await updateMessageFlagsStartingFromAnchor( + context: context, + apiNarrow: narrow.apiEncode(), + anchor: NumericAnchor(message.id), + includeAnchor: true, + op: UpdateMessageFlagsOp.remove, + flag: MessageFlag.read, + onCompletedMessage: zulipLocalizations.markAsUnreadComplete, + progressMessage: zulipLocalizations.markAsUnreadInProgress, + onFailedTitle: zulipLocalizations.errorMarkAsUnreadFailedTitle); + } -Future unregisterToken(GlobalStore globalStore, int accountId) async { - final account = globalStore.getAccount(accountId); - if (account == null) return; // TODO(log) + /// Add or remove the given flag from the anchor to the end of the narrow, + /// showing feedback to the user on progress or failure. + /// + /// This has the semantics of [updateMessageFlagsForNarrow] + /// (see https://zulip.com/api/update-message-flags-for-narrow) + /// with `numBefore: 0` and infinite `numAfter`. It operates by calling that + /// endpoint with a finite `numAfter` as a batch size, in a loop. + /// + /// If the operation requires more than one batch, the user is shown progress + /// feedback through [SnackBar], using [progressMessage] and [onCompletedMessage]. + /// If the operation fails, the user is shown an error dialog box with title + /// [onFailedTitle]. + /// + /// Returns true just if the operation finished successfully. + static Future updateMessageFlagsStartingFromAnchor({ + required BuildContext context, + required List apiNarrow, + required Anchor anchor, + required bool includeAnchor, + required UpdateMessageFlagsOp op, + required MessageFlag flag, + required String Function(int) onCompletedMessage, + required String progressMessage, + required String onFailedTitle, + }) async { + try { + final store = PerAccountStoreWidget.of(context); + final connection = store.connection; + final scaffoldMessenger = ScaffoldMessenger.of(context); - // TODO(#322) use actual acked push token; until #322, this is just null. - final token = account.ackedPushToken - // Try the current token as a fallback; maybe the server has registered - // it and we just haven't recorded that fact in the client. - ?? NotificationService.instance.token.value; - if (token == null) return; + // Compare web's `mark_all_as_read` in web/src/unread_ops.js + // and zulip-mobile's `markAsUnreadFromMessage` in src/action-sheets/index.js . + int responseCount = 0; + int updatedCount = 0; + while (true) { + final result = await updateMessageFlagsForNarrow(connection, + anchor: anchor, + includeAnchor: includeAnchor, + // There is an upper limit of 5000 messages per batch + // (numBefore + numAfter <= 5000) enforced on the server. + // See `update_message_flags_in_narrow` in zerver/views/message_flags.py . + // zulip-mobile uses `numAfter` of 5000, but web uses 1000 + // for more responsive feedback. See zulip@f0d87fcf6. + numBefore: 0, + numAfter: 1000, + narrow: apiNarrow, + op: op, + flag: flag); + if (!context.mounted) { + scaffoldMessenger.clearSnackBars(); + return false; + } + responseCount++; + updatedCount += result.updatedCount; - final connection = globalStore.apiConnectionFromAccount(account); - try { - await NotificationService.unregisterToken(connection, token: token); - } catch (e) { - // TODO retry? handle failures? - } finally { - connection.close(); - } -} + if (result.foundNewest) { + if (responseCount > 1) { + // We previously showed an in-progress [SnackBar], so say we're done. + // There may be a backlog of [SnackBar]s accumulated in the queue + // so be sure to clear them out here. + scaffoldMessenger + ..clearSnackBars() + ..showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, + content: Text(onCompletedMessage(updatedCount)))); + } + return true; + } -Future markNarrowAsRead(BuildContext context, Narrow narrow) async { - final store = PerAccountStoreWidget.of(context); - final connection = store.connection; - final zulipLocalizations = ZulipLocalizations.of(context); - final useLegacy = connection.zulipFeatureLevel! < 155; // TODO(server-6) - if (useLegacy) { - try { - await _legacyMarkNarrowAsRead(context, narrow); - return; + if (result.lastProcessedId == null) { + final zulipLocalizations = ZulipLocalizations.of(context); + // No messages were in the range of the request. + // This should be impossible given that `foundNewest` was false + // (and that our `numAfter` was positive.) + showErrorDialog(context: context, + title: onFailedTitle, + message: zulipLocalizations.errorInvalidResponse); + return false; + } + anchor = NumericAnchor(result.lastProcessedId!); + includeAnchor = false; + + // The task is taking a while, so tell the user we're working on it. + // TODO: Ideally we'd have a progress widget here that showed up based + // on actual time elapsed -- so it could appear before the first + // batch returns, if that takes a while -- and that then stuck + // around continuously until the task ends. For now we use a + // series of [SnackBar]s, which may feel a bit janky. + // There is complexity in tracking the status of each [SnackBar], + // due to having no way to determine which is currently active, + // or if there is an active one at all. Resetting the [SnackBar] here + // results in the same message popping in and out and the user experience + // is better for now if we allow them to run their timer through + // and clear the backlog later. + scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, + content: Text(progressMessage))); + } } catch (e) { - if (!context.mounted) return; + if (!context.mounted) return false; + final zulipLocalizations = ZulipLocalizations.of(context); + final message = switch (e) { + ZulipApiException() => zulipLocalizations.errorServerMessage(e.message), + _ => e.toString(), // TODO(#741): extract user-facing message better + }; showErrorDialog(context: context, - title: zulipLocalizations.errorMarkAsReadFailedTitle, - message: e.toString()); // TODO(#741): extract user-facing message better - return; + title: onFailedTitle, + message: message); + return false; } } - final didPass = await updateMessageFlagsStartingFromAnchor( - context: context, - // Include `is:unread` in the narrow. That has a database index, so - // this can be an important optimization in narrows with a lot of history. - // The server applies the same optimization within the (deprecated) - // specialized endpoints for marking messages as read; see - // `do_mark_stream_messages_as_read` in `zulip:zerver/actions/message_flags.py`. - apiNarrow: narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)), - // Use [AnchorCode.oldest], because [AnchorCode.firstUnread] - // will be the oldest non-muted unread message, which would - // result in muted unreads older than the first unread not - // being processed. - anchor: AnchorCode.oldest, - // [AnchorCode.oldest] is an anchor ID lower than any valid - // message ID. - includeAnchor: false, - op: UpdateMessageFlagsOp.add, - flag: MessageFlag.read, - onCompletedMessage: zulipLocalizations.markAsReadComplete, - progressMessage: zulipLocalizations.markAsReadInProgress, - onFailedTitle: zulipLocalizations.errorMarkAsReadFailedTitle); + /// Fetch and return the raw Markdown content for [messageId], + /// showing an error dialog on failure. + static Future fetchRawContentWithFeedback({ + required BuildContext context, + required int messageId, + required String errorDialogTitle, + }) async { + Message? fetchedMessage; + String? errorMessage; + // TODO, supported by reusable code: + // - (?) Retry with backoff on plausibly transient errors. + // - If request(s) take(s) a long time, show snackbar with cancel + // button, like "Still working on quote-and-reply…". + // On final failure or success, auto-dismiss the snackbar. + final zulipLocalizations = ZulipLocalizations.of(context); + try { + fetchedMessage = (await getMessage(PerAccountStoreWidget.of(context).connection, + messageId: messageId, + applyMarkdown: false, + allowEmptyTopicName: true, + )).message; + } catch (e) { + switch (e) { + case ZulipApiException(code: 'BAD_REQUEST'): + // Servers use this code when the message doesn't exist, according to + // the example in the doc: https://zulip.com/api/get-message + errorMessage = zulipLocalizations.errorMessageDoesNotSeemToExist; + case ZulipApiException(): + errorMessage = e.message; + // TODO specific messages for common errors, like network errors + // (support with reusable code) + default: + errorMessage = zulipLocalizations.errorCouldNotFetchMessageSource; + } + } + + if (!context.mounted) return null; - if (!didPass || !context.mounted) return; - if (narrow is CombinedFeedNarrow) { - PerAccountStoreWidget.of(context).unreads.handleAllMessagesReadSuccess(); - } -} + if (fetchedMessage == null) { + assert(errorMessage != null); + // TODO(?) give no feedback on error conditions we expect to + // flag centrally in event polling, like invalid auth, + // user/realm deactivated. (Support with reusable code.) + showErrorDialog(context: context, + title: errorDialogTitle, message: errorMessage); + } -Future markNarrowAsUnreadFromMessage( - BuildContext context, - Message message, - Narrow narrow, -) async { - final connection = PerAccountStoreWidget.of(context).connection; - assert(connection.zulipFeatureLevel! >= 155); // TODO(server-6) - final zulipLocalizations = ZulipLocalizations.of(context); - await updateMessageFlagsStartingFromAnchor( - context: context, - apiNarrow: narrow.apiEncode(), - anchor: NumericAnchor(message.id), - includeAnchor: true, - op: UpdateMessageFlagsOp.remove, - flag: MessageFlag.read, - onCompletedMessage: zulipLocalizations.markAsUnreadComplete, - progressMessage: zulipLocalizations.markAsUnreadInProgress, - onFailedTitle: zulipLocalizations.errorMarkAsUnreadFailedTitle); + return fetchedMessage?.content; + } } -/// Add or remove the given flag from the anchor to the end of the narrow, -/// showing feedback to the user on progress or failure. +/// Methods that act through platform APIs and show feedback in the UI. /// -/// This has the semantics of [updateMessageFlagsForNarrow] -/// (see https://zulip.com/api/update-message-flags-for-narrow) -/// with `numBefore: 0` and infinite `numAfter`. It operates by calling that -/// endpoint with a finite `numAfter` as a batch size, in a loop. -/// -/// If the operation requires more than one batch, the user is shown progress -/// feedback through [SnackBar], using [progressMessage] and [onCompletedMessage]. -/// If the operation fails, the user is shown an error dialog box with title -/// [onFailedTitle]. -/// -/// Returns true just if the operation finished successfully. -Future updateMessageFlagsStartingFromAnchor({ - required BuildContext context, - required List apiNarrow, - required Anchor anchor, - required bool includeAnchor, - required UpdateMessageFlagsOp op, - required MessageFlag flag, - required String Function(int) onCompletedMessage, - required String progressMessage, - required String onFailedTitle, -}) async { - try { - final store = PerAccountStoreWidget.of(context); - final connection = store.connection; - final scaffoldMessenger = ScaffoldMessenger.of(context); +/// The static methods on this class can be thought of as higher-level wrappers +/// for some of the platform binding methods in [ZulipBinding]. +/// But they don't belong there, because they also interact with widgets +/// in order to present success or error feedback to the user through the UI. +abstract final class PlatformActions { + /// Copies [data] to the clipboard and shows a popup on success. + /// + /// Must have a [Scaffold] ancestor. + /// + /// On newer Android the popup is defined and shown by the platform. On older + /// Android and on iOS, shows a [Snackbar] with [successContent]. + /// + /// In English, the text in [successContent] should be short, should start with + /// a capital letter, and should have no ending punctuation: "{noun} copied". + static void copyWithPopup({ + required BuildContext context, + required ClipboardData data, + required Widget successContent, + }) async { + await Clipboard.setData(data); + final deviceInfo = await ZulipBinding.instance.deviceInfo; - // Compare web's `mark_all_as_read` in web/src/unread_ops.js - // and zulip-mobile's `markAsUnreadFromMessage` in src/action-sheets/index.js . - int responseCount = 0; - int updatedCount = 0; - while (true) { - final result = await updateMessageFlagsForNarrow(connection, - anchor: anchor, - includeAnchor: includeAnchor, - // There is an upper limit of 5000 messages per batch - // (numBefore + numAfter <= 5000) enforced on the server. - // See `update_message_flags_in_narrow` in zerver/views/message_flags.py . - // zulip-mobile uses `numAfter` of 5000, but web uses 1000 - // for more responsive feedback. See zulip@f0d87fcf6. - numBefore: 0, - numAfter: 1000, - narrow: apiNarrow, - op: op, - flag: flag); - if (!context.mounted) { - scaffoldMessenger.clearSnackBars(); - return false; - } - responseCount++; - updatedCount += result.updatedCount; + if (!context.mounted) return; - if (result.foundNewest) { - if (responseCount > 1) { - // We previously showed an in-progress [SnackBar], so say we're done. - // There may be a backlog of [SnackBar]s accumulated in the queue - // so be sure to clear them out here. - scaffoldMessenger - ..clearSnackBars() - ..showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, - content: Text(onCompletedMessage(updatedCount)))); - } - return true; - } + final shouldShowSnackbar = switch (deviceInfo) { + // Android 13+ shows its own popup on copying to the clipboard, + // so we suppress ours, following the advice at: + // https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications + // TODO(android-sdk-33): Simplify this and dartdoc + AndroidDeviceInfo(:var sdkInt) => sdkInt <= 32, + _ => true, + }; + if (shouldShowSnackbar) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(behavior: SnackBarBehavior.floating, content: successContent)); + } + } - if (result.lastProcessedId == null) { - final zulipLocalizations = ZulipLocalizations.of(context); - // No messages were in the range of the request. - // This should be impossible given that `foundNewest` was false - // (and that our `numAfter` was positive.) - showErrorDialog(context: context, - title: onFailedTitle, - message: zulipLocalizations.errorInvalidResponse); - return false; - } - anchor = NumericAnchor(result.lastProcessedId!); - includeAnchor = false; + /// Opens a URL with [ZulipBinding.launchUrl], with an error dialog on failure. + static Future launchUrl(BuildContext context, Uri url) async { + final globalSettings = GlobalStoreWidget.settingsOf(context); - // The task is taking a while, so tell the user we're working on it. - // TODO: Ideally we'd have a progress widget here that showed up based - // on actual time elapsed -- so it could appear before the first - // batch returns, if that takes a while -- and that then stuck - // around continuously until the task ends. For now we use a - // series of [SnackBar]s, which may feel a bit janky. - // There is complexity in tracking the status of each [SnackBar], - // due to having no way to determine which is currently active, - // or if there is an active one at all. Resetting the [SnackBar] here - // results in the same message popping in and out and the user experience - // is better for now if we allow them to run their timer through - // and clear the backlog later. - scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, - content: Text(progressMessage))); + bool launched = false; + String? errorMessage; + try { + launched = await ZulipBinding.instance.launchUrl(url, + mode: globalSettings.getUrlLaunchMode(url)); + } on PlatformException catch (e) { + errorMessage = e.message; } - } catch (e) { - if (!context.mounted) return false; - showErrorDialog(context: context, - title: onFailedTitle, - message: e.toString()); // TODO(#741): extract user-facing message better - return false; - } -} + if (!launched) { // TODO(log) + if (!context.mounted) return; -Future _legacyMarkNarrowAsRead(BuildContext context, Narrow narrow) async { - final store = PerAccountStoreWidget.of(context); - final connection = store.connection; - switch (narrow) { - case CombinedFeedNarrow(): - await markAllAsRead(connection); - case ChannelNarrow(:final streamId): - await markStreamAsRead(connection, streamId: streamId); - case TopicNarrow(:final streamId, :final topic): - await markTopicAsRead(connection, streamId: streamId, topicName: topic); - case DmNarrow(): - final unreadDms = store.unreads.dms[narrow]; - // Silently ignore this race-condition as the outcome - // (no unreads in this narrow) was the desired end-state - // of pushing the button. - if (unreadDms == null) return; - await updateMessageFlags(connection, - messages: unreadDms, - op: UpdateMessageFlagsOp.add, - flag: MessageFlag.read); - case MentionsNarrow(): - final unreadMentions = store.unreads.mentions.toList(); - if (unreadMentions.isEmpty) return; - await updateMessageFlags(connection, - messages: unreadMentions, - op: UpdateMessageFlagsOp.add, - flag: MessageFlag.read); - case StarredMessagesNarrow(): - // TODO: Implement unreads handling. - return; + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorCouldNotOpenLinkTitle, + message: [ + zulipLocalizations.errorCouldNotOpenLink(url.toString()), + if (errorMessage != null) errorMessage, + ].join("\n\n")); + } } } diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 9525dffdfe..a3aa1053a8 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -6,11 +6,11 @@ import 'package:flutter/scheduler.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../log.dart'; +import '../model/actions.dart'; import '../model/localizations.dart'; import '../model/store.dart'; -import '../notifications/display.dart'; +import '../notifications/open.dart'; import 'about_zulip.dart'; -import 'actions.dart'; import 'dialog.dart'; import 'home.dart'; import 'login.dart'; @@ -85,6 +85,7 @@ class ZulipApp extends StatefulWidget { static void debugReset() { _snackBarCount = 0; reportErrorToUserBriefly = defaultReportErrorToUserBriefly; + reportErrorToUserModally = defaultReportErrorToUserModally; _ready.dispose(); _ready = ValueNotifier(false); } @@ -111,7 +112,7 @@ class ZulipApp extends StatefulWidget { return; } - final localizations = ZulipLocalizations.of(navigatorKey.currentContext!); + final zulipLocalizations = ZulipLocalizations.of(navigatorKey.currentContext!); final newSnackBar = scaffoldMessenger!.showSnackBar( snackBarAnimationStyle: AnimationStyle( duration: const Duration(milliseconds: 200), @@ -119,19 +120,35 @@ class ZulipApp extends StatefulWidget { SnackBar( content: Text(message), action: (details == null) ? null : SnackBarAction( - label: localizations.snackBarDetails, + label: zulipLocalizations.snackBarDetails, onPressed: () => showErrorDialog(context: navigatorKey.currentContext!, - title: localizations.errorDialogTitle, + title: zulipLocalizations.errorDialogTitle, message: details)))); _snackBarCount++; newSnackBar.closed.whenComplete(() => _snackBarCount--); } + /// The callback we normally use as [reportErrorToUserModally]. + static void _reportErrorToUserModally( + String title, { + String? message, + Uri? learnMoreButtonUrl, + }) { + assert(_ready.value); + + showErrorDialog( + context: navigatorKey.currentContext!, + title: title, + message: message, + learnMoreButtonUrl: learnMoreButtonUrl); + } + void _declareReady() { assert(navigatorKey.currentContext != null); _ready.value = true; reportErrorToUserBriefly = _reportErrorToUserBriefly; + reportErrorToUserModally = _reportErrorToUserModally; } @override @@ -139,31 +156,11 @@ class ZulipApp extends StatefulWidget { } class _ZulipAppState extends State with WidgetsBindingObserver { - @override - Future didPushRouteInformation(routeInformation) async { - switch (routeInformation.uri) { - case Uri(scheme: 'zulip', host: 'login') && var url: - await LoginPage.handleWebAuthUrl(url); - return true; - case Uri(scheme: 'zulip', host: 'notification') && var url: - await NotificationDisplayManager.navigateForNotification(url); - return true; - } - 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(); + UpgradeWelcomeDialog.maybeShow(); } @override @@ -172,22 +169,94 @@ class _ZulipAppState extends State with WidgetsBindingObserver { super.dispose(); } + AccountRoute? _initialRouteIos(BuildContext context) { + return NotificationOpenService.instance + .routeForNotificationFromLaunch(context: context); + } + + // TODO migrate Android's notification navigation to use the new Pigeon API. + AccountRoute? _initialRouteAndroid( + BuildContext context, + String initialRoute, + ) { + final initialRouteUrl = Uri.tryParse(initialRoute); + if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { + assert(debugLog('got notif: url: $initialRouteUrl')); + final data = NotificationOpenService.tryParseAndroidNotificationUrl( + context: context, + url: initialRouteUrl); + if (data == null) return null; // TODO(log) + return NotificationOpenService.routeForNotification( + context: context, + data: data); + } + + return null; + } + + List> _handleGenerateInitialRoutes(String initialRoute) { + // The `_ZulipAppState.context` lacks the required ancestors. Instead + // we use the Navigator which should be available when this callback is + // called and its context should have the required ancestors. + final context = ZulipApp.navigatorKey.currentContext!; + + final route = defaultTargetPlatform == TargetPlatform.iOS + ? _initialRouteIos(context) + : _initialRouteAndroid(context, initialRoute); + if (route != null) { + return [ + HomePage.buildRoute(accountId: route.accountId), + route, + ]; + } + + final globalStore = GlobalStoreWidget.of(context); + final lastVisitedAccountId = globalStore.lastVisitedAccount?.id; + + return [ + if (lastVisitedAccountId == null) + // There are no accounts, or the last-visited account was logged out. + MaterialWidgetRoute(page: const ChooseAccountPage()) + else + HomePage.buildRoute(accountId: lastVisitedAccountId), + ]; + } + + @override + Future didPushRouteInformation(routeInformation) async { + switch (routeInformation.uri) { + case Uri(scheme: 'zulip', host: 'login') && var url: + await LoginPage.handleWebAuthUrl(url); + return true; + case Uri(scheme: 'zulip', host: 'notification') && var url: + await NotificationOpenService.navigateForAndroidNotificationUrl(url); + return true; + } + return super.didPushRouteInformation(routeInformation); + } + @override Widget build(BuildContext context) { - final themeData = zulipThemeData(context); return GlobalStoreWidget( + blockingFuture: NotificationOpenService.instance.initialized, 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', + onGenerateTitle: (BuildContext context) { + return ZulipLocalizations.of(context).zulipAppTitle; + }, localizationsDelegates: ZulipLocalizations.localizationsDelegates, supportedLocales: ZulipLocalizations.supportedLocales, - theme: themeData, + // The context has to be taken from the [Builder] because + // [zulipThemeData] requires access to [GlobalStoreWidget] in the tree. + theme: zulipThemeData(context), navigatorKey: ZulipApp.navigatorKey, - navigatorObservers: widget.navigatorObservers ?? const [], + navigatorObservers: [ + if (widget.navigatorObservers != null) + ...widget.navigatorObservers!, + _PreventEmptyStack(), + _UpdateLastVisitedAccount(GlobalStoreWidget.of(context)), + ], builder: (BuildContext context, Widget? child) { if (!ZulipApp.ready.value) { SchedulerBinding.instance.addPostFrameCallback( @@ -206,15 +275,48 @@ class _ZulipAppState extends State with WidgetsBindingObserver { // 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), - ]; - }); - })); + onGenerateInitialRoutes: _handleGenerateInitialRoutes); + })); + } +} + +/// Pushes a route whenever the observed navigator stack becomes empty. +class _PreventEmptyStack extends NavigatorObserver { + void _pushRouteIfEmptyStack() async { + final navigator = await ZulipApp.navigator; + bool isEmptyStack = true; + // TODO: find a better way to inspect the navigator stack + navigator.popUntil((route) { + isEmptyStack = false; + return true; // never actually pops + }); + if (isEmptyStack) { + unawaited(navigator.push( + MaterialWidgetRoute(page: const ChooseAccountPage()))); + } + } + + @override + void didRemove(Route route, Route? previousRoute) async { + _pushRouteIfEmptyStack(); + } + + @override + void didPop(Route route, Route? previousRoute) async { + _pushRouteIfEmptyStack(); + } +} + +class _UpdateLastVisitedAccount extends NavigatorObserver { + _UpdateLastVisitedAccount(this.globalStore); + + final GlobalStore globalStore; + + @override + void didChangeTop(Route topRoute, _) { + if (topRoute case AccountPageRouteMixin(:var accountId)) { + globalStore.setLastVisitedAccount(accountId); + } } } @@ -241,16 +343,17 @@ class ChooseAccountPage extends StatelessWidget { trailing: MenuAnchor( menuChildren: [ MenuItemButton( - onPressed: () { - showSuggestedActionDialog(context: context, + onPressed: () async { + final dialog = showSuggestedActionDialog(context: context, title: zulipLocalizations.logOutConfirmationDialogTitle, message: zulipLocalizations.logOutConfirmationDialogMessage, // TODO(#1032) "destructive" style for action button - actionButtonText: zulipLocalizations.logOutConfirmationDialogConfirmButton, - onActionButtonPress: () { - // TODO error handling if db write fails? - logOutAccount(context, accountId); - }); + actionButtonText: zulipLocalizations.logOutConfirmationDialogConfirmButton); + if (await dialog.result == true) { + if (!context.mounted) return; + // TODO error handling if db write fails? + unawaited(logOutAccount(GlobalStoreWidget.of(context), accountId)); + } }, child: Text(zulipLocalizations.chooseAccountPageLogOutButton)), ], diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart index f548557681..77f77ba7e2 100644 --- a/lib/widgets/app_bar.dart +++ b/lib/widgets/app_bar.dart @@ -79,7 +79,7 @@ class _ZulipAppBarBottom extends StatelessWidget implements PreferredSizeWidget @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); - if (!store.isLoading) return const SizedBox.shrink(); + if (!store.isRecoveringEventStream) return const SizedBox.shrink(); return LinearProgressIndicator(minHeight: 4.0, backgroundColor: backgroundColor); } } diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 40d1f2bf16..59b70a11d6 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/emoji.dart'; import '../model/store.dart'; -import 'content.dart'; import 'emoji.dart'; import 'icons.dart'; import 'store.dart'; @@ -11,6 +10,9 @@ import '../model/autocomplete.dart'; import '../model/compose.dart'; import '../model/narrow.dart'; import 'compose_box.dart'; +import 'text.dart'; +import 'theme.dart'; +import 'user.dart'; abstract class AutocompleteField extends StatefulWidget { const AutocompleteField({ @@ -128,7 +130,7 @@ class _AutocompleteFieldState) - fieldViewBuilder: (context, _, __, ___) => widget.fieldViewBuilder(context), + fieldViewBuilder: (context, _, _, _) => widget.fieldViewBuilder(context), ); } } @@ -176,8 +178,8 @@ class ComposeAutocomplete extends AutocompleteField _MentionAutocompleteItem( + MentionAutocompleteResult() => MentionAutocompleteItem( option: option, narrow: narrow), EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), }; @@ -226,60 +249,110 @@ class ComposeAutocomplete extends AutocompleteField= 247; // TODO(server-9) - final localizations = ZulipLocalizations.of(context); - final description = switch (wildcardOption) { + final isChannelWildcardAvailable = store.zulipFeatureLevel >= 247; // TODO(server-9) + final zulipLocalizations = ZulipLocalizations.of(context); + return switch (wildcardOption) { WildcardMentionOption.all || WildcardMentionOption.everyone => isDmNarrow - ? localizations.wildcardMentionAllDmDescription + ? zulipLocalizations.wildcardMentionAllDmDescription : isChannelWildcardAvailable - ? localizations.wildcardMentionChannelDescription - : localizations.wildcardMentionStreamDescription, - WildcardMentionOption.channel => localizations.wildcardMentionChannelDescription, + ? zulipLocalizations.wildcardMentionChannelDescription + : zulipLocalizations.wildcardMentionStreamDescription, + WildcardMentionOption.channel => zulipLocalizations.wildcardMentionChannelDescription, WildcardMentionOption.stream => isChannelWildcardAvailable - ? localizations.wildcardMentionChannelDescription - : localizations.wildcardMentionStreamDescription, - WildcardMentionOption.topic => localizations.wildcardMentionTopicDescription, + ? zulipLocalizations.wildcardMentionChannelDescription + : zulipLocalizations.wildcardMentionStreamDescription, + WildcardMentionOption.topic => zulipLocalizations.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); + final designVariables = DesignVariables.of(context); + Widget avatar; - Widget label; + String label; + Widget? emoji; + String? sublabel; switch (option) { case UserMentionAutocompleteResult(:var userId): - avatar = Avatar(userId: userId, size: 32, borderRadius: 3); // web uses 21px - label = Text(store.users[userId]!.fullName); + avatar = Avatar(userId: userId, size: 36, borderRadius: 4); + label = store.userDisplayName(userId); + emoji = UserStatusEmoji(userId: userId, size: 18, + padding: const EdgeInsetsDirectional.only(start: 5.0)); + sublabel = store.getUser(userId)?.deliveryEmail; + case UserGroupMentionAutocompleteResult(:final groupId): + final group = store.getGroup(groupId); + avatar = SizedBox.square(dimension: 36, + child: const Icon(ZulipIcons.three_person, size: 24)); + label = group?.name + // Don't crash on theoretical race between async results-filtering + // and losing data for the group. + ?? ''; + emoji = null; + sublabel = group?.description; case WildcardMentionAutocompleteResult(:var wildcardOption): - avatar = const Icon(ZulipIcons.three_person, size: 29); // web uses 19px - label = wildcardLabel(wildcardOption, context: context, store: store); + avatar = SizedBox.square(dimension: 36, + child: const Icon(ZulipIcons.three_person, size: 24)); + label = wildcardOption.canonicalString; + emoji = null; + sublabel = wildcardSublabel(wildcardOption, context: context, store: store); } + final labelWidget = Row(children: [ + Flexible(child: Text(label, + style: TextStyle( + fontSize: 18, + height: 20 / 18, + color: designVariables.contextMenuItemLabel, + ).merge(weightVariableTextStyle(context, + wght: sublabel == null ? 500 : 600)), + overflow: TextOverflow.ellipsis, + maxLines: 1)), + ?emoji, + ]); + + final sublabelWidget = sublabel == null ? null : Text( + sublabel, + style: TextStyle( + fontSize: 14, + height: 16 / 14, + color: designVariables.contextMenuItemMeta), + overflow: TextOverflow.ellipsis, + maxLines: 1); + return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + padding: const EdgeInsetsDirectional.fromSTEB(4, 4, 8, 4), child: Row(children: [ avatar, - const SizedBox(width: 8), - label, + const SizedBox(width: 6), + Expanded(child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [labelWidget, ?sublabelWidget])), ])); } } @@ -289,12 +362,12 @@ class _EmojiAutocompleteItem extends StatelessWidget { final EmojiAutocompleteResult option; - static const _size = 32.0; - static const _notoColorEmojiTextSize = 25.7; + static const _size = 24.0; @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); final candidate = option.candidate; // TODO deduplicate this logic with [EmojiPickerListEntry] @@ -303,9 +376,7 @@ class _EmojiAutocompleteItem extends StatelessWidget { ImageEmojiDisplay() => ImageEmojiWidget(size: _size, emojiDisplay: emojiDisplay), UnicodeEmojiDisplay() => - UnicodeEmojiWidget( - size: _size, notoColorEmojiTextSize: _notoColorEmojiTextSize, - emojiDisplay: emojiDisplay), + UnicodeEmojiWidget(size: _size, emojiDisplay: emojiDisplay), TextEmojiDisplay() => null, // The text is already shown separately. }; @@ -313,15 +384,26 @@ class _EmojiAutocompleteItem extends StatelessWidget { ? candidate.emojiName : [candidate.emojiName, ...candidate.aliases].join(", "); // TODO(#1080) + // TODO(design): emoji autocomplete results + // There's no design in Figma for emoji autocomplete results. + // Instead we adapt the design for the emoji picker to the + // context of autocomplete results as exemplified by _MentionAutocompleteItem. + // That means: emoji size, text size, text line-height, and font weight + // from emoji picker; text color (for contrast with background) and + // outer padding from _MentionAutocompleteItem; padding around emoji glyph + // to bring it to same size as avatar in _MentionAutocompleteItem. return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: Row(children: [ if (glyph != null) ...[ - glyph, - const SizedBox(width: 8), + Padding(padding: const EdgeInsets.all(6), + child: glyph), + const SizedBox(width: 6), ], Expanded( child: Text( + style: TextStyle(fontSize: 17, height: 18 / 17, + color: designVariables.contextMenuItemLabel), maxLines: 2, overflow: TextOverflow.ellipsis, label)), @@ -365,12 +447,21 @@ class TopicAutocomplete extends AutocompleteField(T small, T normal) => + switch (size) { + ZulipWebUiKitButtonSize.small => small, + ZulipWebUiKitButtonSize.normal => normal, + }; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + // With [MaterialTapTargetSize.padded], + // make [TextButton] set 44 instead of 48 for the touch-target height. + final visualDensity = VisualDensity(vertical: -1); + // A value that [TextButton] adds to some of its layout parameters; + // we can cancel out those adjustments by subtracting it. + final densityVerticalAdjustment = visualDensity.baseSizeAdjustment.dy; + + // An upper limit when the text-size setting is large + // - helps prioritize more important content (like message content); #1023 + // - prevents the vertical padding added by [MaterialTapTargetSize.padded] + // from shrinking to zero as the button grows to accommodate a larger label + final textScaler = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5); + + final buttonHeight = _forSize(24, 28); + + final labelColor = _labelColor(designVariables); + + return AnimatedScaleOnTap( + scaleEnd: 0.96, + duration: Duration(milliseconds: 100), + child: TextButton.icon( + // TODO the gap between the icon and label should be 6px, not 8px + icon: icon != null ? Icon(icon) : null, + style: TextButton.styleFrom( + iconSize: 16, + iconColor: labelColor, + padding: EdgeInsets.symmetric( + horizontal: _forSize(6, 10), + vertical: 4 - densityVerticalAdjustment, + ), + foregroundColor: labelColor, + shape: RoundedRectangleBorder( + side: _borderSide(designVariables), + borderRadius: BorderRadius.circular(_forSize(6, 4))), + splashFactory: NoSplash.splashFactory, + + // These three arguments make the button `buttonHeight` tall, + // but with vertical padding to make the touch target 44px tall: + // https://github.com/zulip/zulip-flutter/pull/1432#discussion_r2023907300 + visualDensity: visualDensity, + tapTargetSize: MaterialTapTargetSize.padded, + minimumSize: Size( + kMinInteractiveDimension, + buttonHeight - densityVerticalAdjustment, + ), + ).copyWith(backgroundColor: _backgroundColor(designVariables)), + onPressed: onPressed, + label: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 240), + child: Text(label, + textScaler: textScaler, + maxLines: 1, + style: _labelStyle(context, textScaler: textScaler), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis)))); + } +} + +enum ZulipWebUiKitButtonAttention { + high, + medium, + // low, + + /// An ad hoc value for the "Reveal message" button + /// on a message from a muted sender: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6092-50786&m=dev + minimal, +} + +enum ZulipWebUiKitButtonIntent { + neutral, + // warning, + // danger, + info, + // success, + // brand, +} + +enum ZulipWebUiKitButtonSize { + /// A smaller size than the one in the Zulip Web UI Kit. + /// + /// This was ad hoc for mobile, for the "Reveal message" button + /// on a message from a muted sender: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6092-50786&m=dev + small, + + normal, +} + +/// Apply [Transform.scale] to the child widget when tapped, and reset its scale +/// when released, while animating the transitions. +class AnimatedScaleOnTap extends StatefulWidget { + const AnimatedScaleOnTap({ + super.key, + required this.scaleEnd, + required this.duration, + required this.child, + }); + + /// The terminal scale to animate to. + final double scaleEnd; + + /// The duration over which to animate the scale change. + final Duration duration; + + final Widget child; + + @override + State createState() => _AnimatedScaleOnTapState(); +} + +class _AnimatedScaleOnTapState extends State { + double _scale = 1; + + void _changeScale(double scale) { + setState(() { + _scale = scale; + }); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTapDown: (_) => _changeScale(widget.scaleEnd), + onTapUp: (_) => _changeScale(1), + onTapCancel: () => _changeScale(1), + child: AnimatedScale( + scale: _scale, + duration: widget.duration, + curve: Curves.easeOut, + child: widget.child)); + } +} + +/// The rounded-rectangle shape and 1-pixel spacing for a run of [ZulipMenuItemButton]s. +class MenuButtonsShape extends StatelessWidget { + const MenuButtonsShape({ + super.key, + required this.buttons, + }); + + final List buttons; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Column(spacing: 1, + children: buttons)); + } +} + +/// The "menu button" or "list button" component in Figma. +/// +/// Use [ZulipMenuItemButtonStyle] to choose between components. +/// +/// Must have a [MenuButtonsShape] ancestor. +/// +/// See Figma: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6070-60681&m=dev +class ZulipMenuItemButton extends StatelessWidget { + const ZulipMenuItemButton({ + super.key, + this.style = ZulipMenuItemButtonStyle.menu, + required this.label, + this.subLabel, + this.onPressed, + this.icon, + this.toggle, + }); + + final ZulipMenuItemButtonStyle style; + final String label; + final TextSpan? subLabel; + final VoidCallback? onPressed; + final IconData? icon; + + /// A [Toggle] to go before [icon], or in its place if it's null. + /// + /// See Figma: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6070-60682&m=dev + // TODO(design) Is the toggle option meant only for + // [ZulipMenuItemButtonStyle.menu]? + final Widget? toggle; + + double get itemSpacingAndEndPadding => switch (style) { + ZulipMenuItemButtonStyle.menu => 16, + ZulipMenuItemButtonStyle.list => 12, + }; + + static bool _debugCheckShapeAncestor(BuildContext context) { + final ancestor = context.findAncestorWidgetOfExactType(); + assert(() { + if (ancestor != null) return true; + throw FlutterError.fromParts([ + ErrorSummary('No MenuButtonsShape ancestor found.'), + ErrorDescription('ZulipMenuItemButton widgets require a MenuButtonsShape ancestor.'), + ]); + }()); + return true; + } + + WidgetStateColor _backgroundColor(DesignVariables designVariables) { + switch (style) { + case ZulipMenuItemButtonStyle.menu: + return WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.20), + ~WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.12), + }); + case ZulipMenuItemButtonStyle.list: + return WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.listMenuItemBg.withFadedAlpha(0.7), + ~WidgetState.pressed: designVariables.listMenuItemBg.withFadedAlpha(0.35), + }); + } + } + + Color _labelColor(DesignVariables designVariables) { + return switch (style) { + ZulipMenuItemButtonStyle.menu => designVariables.contextMenuItemText, + ZulipMenuItemButtonStyle.list => designVariables.listMenuItemText, + }; + } + + double _labelWght() { + return switch (style) { + ZulipMenuItemButtonStyle.menu => 600, + ZulipMenuItemButtonStyle.list => 500, + }; + } + + Color _iconColor(DesignVariables designVariables) { + return switch (style) { + ZulipMenuItemButtonStyle.menu => designVariables.contextMenuItemIcon, + ZulipMenuItemButtonStyle.list => designVariables.listMenuItemIcon, + }; + } + + @override + Widget build(BuildContext context) { + _debugCheckShapeAncestor(context); + + final designVariables = DesignVariables.of(context); + + // (see `trailingIcon`) + assert(Theme.of(context).visualDensity == VisualDensity.standard); + + return MenuItemButton( + trailingIcon: (icon != null || toggle != null) + ? Padding( + // This Material widget gives us 12px padding before the icon -- + // or more or less, depending on Theme.of(context).visualDensity, + // hence the `assert` above. + padding: EdgeInsetsDirectional.only(start: itemSpacingAndEndPadding - 12), + + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: itemSpacingAndEndPadding, + children: [ + if (toggle != null) toggle!, + if (icon != null) Icon(icon!, color: _iconColor(designVariables)), + ])) + : null, + style: MenuItemButton.styleFrom( + minimumSize: Size.fromHeight(48), + padding: EdgeInsetsDirectional.only(start: 16, end: itemSpacingAndEndPadding), + foregroundColor: _labelColor(designVariables), + splashFactory: NoSplash.splashFactory, + ).copyWith(backgroundColor: _backgroundColor(designVariables)), + overflowAxis: Axis.vertical, + onPressed: onPressed, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: localizedTextBaseline(context), + children: [ + Flexible(child: Text(label, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 20, height: 24 / 20) + .merge(weightVariableTextStyle(context, wght: _labelWght())))), + if (subLabel != null) + Flexible(child: Text.rich(subLabel!, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + height: 16 / 16, + color: _labelColor(designVariables).withFadedAlpha(0.70), + ).merge(weightVariableTextStyle(context, wght: _labelWght())))), + ], + ))); + } +} + +/// The style of a [ZulipMenuItemButton]. +enum ZulipMenuItemButtonStyle { + /// The purple "menu button" component in Figma, with 16px end padding. + /// + /// See Figma: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3302-20443&m=dev + menu, + + /// The gray "list button" component in Figma, with 12px end padding. + /// + /// See Figma: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5000-52868&m=dev + list, +} + +/// The "toggle" component in Figma. +/// +/// See Figma: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6070-60682&m=dev +class Toggle extends StatelessWidget { + const Toggle({ + super.key, + required this.value, + required this.onChanged, + }); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + // Figma has this (blue/500) in both light and dark mode. + // TODO(#831) + final activeColor = Color(0xff4370f0); + + // Figma has this (grey/400) in both light and dark mode. + // TODO(#831) + final inactiveColor = Color(0xff9194a3); + + // TODO(#1636): + // All of these just need _SwitchConfig to be exposed, + // and there's an upstream issue for that: + // https://github.com/flutter/flutter/issues/131478 + // + // - active thumb radius should be 10px, not 12px + // (_SwitchConfig.thumbRadiusWithIcon) + // - inactive thumb radius should be 7px, not 8px + // (_SwitchConfig.inactiveThumbRadius) + // - track dimensions before trackOutlineWidth should be 24px by 44px, + // not 32px by 52px (_SwitchConfig.trackHeight and trackWidth). + + return Switch( + value: value, + onChanged: onChanged, + padding: EdgeInsets.zero, + splashRadius: 0, + thumbIcon: WidgetStateProperty.fromMap({ + WidgetState.selected: Icon(ZulipIcons.check, size: 16, color: activeColor), + ~WidgetState.selected: null, + }), + + // Figma has white for "on" and "off" in both light and dark mode. + thumbColor: WidgetStatePropertyAll(Colors.white), + + activeTrackColor: activeColor, + inactiveTrackColor: inactiveColor, + trackOutlineColor: WidgetStateColor.fromMap({ + WidgetState.selected: activeColor, + ~WidgetState.selected: inactiveColor, + }), + trackOutlineWidth: WidgetStateProperty.fromMap({ + // The outline is effectively painted with strokeAlignCenter: + // https://api.flutter.dev/flutter/painting/BorderSide/strokeAlignCenter-constant.html + WidgetState.selected: 2 * 2, + ~WidgetState.selected: 1 * 2, + }), + overlayColor: WidgetStatePropertyAll(Colors.transparent), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } +} diff --git a/lib/widgets/clipboard.dart b/lib/widgets/clipboard.dart deleted file mode 100644 index 9977af3f0c..0000000000 --- a/lib/widgets/clipboard.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../model/binding.dart'; - -/// Copies [data] to the clipboard and shows a popup on success. -/// -/// Must have a [Scaffold] ancestor. -/// -/// On newer Android the popup is defined and shown by the platform. On older -/// Android and on iOS, shows a [Snackbar] with [successContent]. -/// -/// In English, the text in [successContent] should be short, should start with -/// a capital letter, and should have no ending punctuation: "{noun} copied". -void copyWithPopup({ - required BuildContext context, - required ClipboardData data, - required Widget successContent, -}) async { - await Clipboard.setData(data); - final deviceInfo = await ZulipBinding.instance.deviceInfo; - - if (!context.mounted) return; - - final shouldShowSnackbar = switch (deviceInfo) { - // Android 13+ shows its own popup on copying to the clipboard, - // so we suppress ours, following the advice at: - // https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications - // TODO(android-sdk-33): Simplify this and dartdoc - AndroidDeviceInfo(:var sdkInt) => sdkInt <= 32, - _ => true, - }; - if (shouldShowSnackbar) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(behavior: SnackBarBehavior.floating, content: successContent)); - } -} diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 9ee87754b8..1644a6a038 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1,9 +1,12 @@ +import 'dart:async'; import 'dart:math'; import 'package:app_settings/app_settings.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:mime/mime.dart'; +import 'package:path/path.dart' as path; import '../api/exception.dart'; import '../api/model/model.dart'; @@ -11,13 +14,17 @@ import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/binding.dart'; import '../model/compose.dart'; +import '../model/message.dart'; import '../model/narrow.dart'; import '../model/store.dart'; +import 'actions.dart'; import 'autocomplete.dart'; +import 'button.dart'; import 'color.dart'; import 'dialog.dart'; import 'icons.dart'; import 'inset_shadow.dart'; +import 'page.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -81,6 +88,8 @@ const double _composeButtonSize = 44; /// /// Subclasses must ensure that [_update] is called in all exposed constructors. abstract class ComposeController extends TextEditingController { + ComposeController({super.text}); + int get maxLengthUnicodeCodePoints; String get textNormalized => _textNormalized; @@ -142,7 +151,7 @@ enum TopicValidationError { } class ComposeTopicController extends ComposeController { - ComposeTopicController({required this.store}) { + ComposeTopicController({super.text, required this.store}) { _update(); } @@ -157,13 +166,38 @@ class ComposeTopicController extends ComposeController { @override String _computeTextNormalized() { String trimmed = text.trim(); - return trimmed.isEmpty ? kNoTopicTopic : trimmed; + // TODO(server-10): simplify + if (store.zulipFeatureLevel < 334) { + return trimmed.isEmpty ? kNoTopicTopic : trimmed; + } + + return trimmed; + } + + /// Whether [textNormalized] would fail a mandatory-topics check + /// (see [mandatory]). + /// + /// The term "Vacuous" draws distinction from [String.isEmpty], in the sense + /// that certain strings are not empty but also indicate the absence of a topic. + /// + /// See also: https://zulip.com/api/send-message#parameter-topic + bool get isTopicVacuous { + if (textNormalized.isEmpty) return true; + + if (textNormalized == kNoTopicTopic) return true; + + // TODO(server-10): simplify + if (store.zulipFeatureLevel >= 334) { + return textNormalized == store.realmEmptyTopicDisplayName; + } + + return false; } @override List _computeValidationErrors() { return [ - if (mandatory && textNormalized == kNoTopicTopic) + if (mandatory && isTopicVacuous) TopicValidationError.mandatoryButEmpty, if ( @@ -175,7 +209,7 @@ class ComposeTopicController extends ComposeController { } void setTopic(TopicName newTopic) { - value = TextEditingValue(text: newTopic.displayName); + value = TextEditingValue(text: newTopic.displayName ?? ''); } } @@ -200,10 +234,13 @@ enum ContentValidationError { } class ComposeContentController extends ComposeController { - ComposeContentController() { + ComposeContentController({super.text, this.requireNotEmpty = true}) { _update(); } + /// Whether to produce [ContentValidationError.empty]. + final bool requireNotEmpty; + // TODO(#1237) use `max_message_length` instead of hardcoded limit @override final maxLengthUnicodeCodePoints = kMaxMessageLengthCodePoints; @@ -315,7 +352,7 @@ class ComposeContentController extends ComposeController final tag = _nextUploadTag; _nextUploadTag += 1; final linkText = zulipLocalizations.composeBoxUploadingFilename(filename); - final placeholder = inlineLink(linkText, null); + final placeholder = inlineLink(linkText, ''); _uploads[tag] = (filename: filename, placeholder: placeholder); notifyListeners(); // _uploads change could affect validationErrors value = value.replaced(insertionIndex(), '$placeholder\n\n'); @@ -324,9 +361,9 @@ class ComposeContentController extends ComposeController /// Tells the controller that a file upload has ended, with success or error. /// - /// To indicate success, pass the URL to be used for the Markdown link. + /// To indicate success, pass the URL string to be used for the Markdown link. /// If `url` is null, failure is assumed. - void registerUploadEnd(int tag, Uri? url) { + void registerUploadEnd(int tag, String? url) { final val = _uploads[tag]; assert(val != null, 'registerUploadEnd called twice for same tag'); final (:filename, :placeholder) = val!; @@ -350,7 +387,7 @@ class ComposeContentController extends ComposeController @override List _computeValidationErrors() { return [ - if (textNormalized.isEmpty) + if (requireNotEmpty && textNormalized.isEmpty) ContentValidationError.empty, if ( @@ -368,24 +405,22 @@ class ComposeContentController extends ComposeController } } -class _ContentInput extends StatefulWidget { - const _ContentInput({ - required this.narrow, +class _TypingNotifier extends StatefulWidget { + const _TypingNotifier({ required this.destination, required this.controller, - required this.hintText, + required this.child, }); - final Narrow narrow; final SendableNarrow destination; final ComposeBoxController controller; - final String hintText; + final Widget child; @override - State<_ContentInput> createState() => _ContentInputState(); + State<_TypingNotifier> createState() => _TypingNotifierState(); } -class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserver { +class _TypingNotifierState extends State<_TypingNotifier> with WidgetsBindingObserver { @override void initState() { super.initState(); @@ -395,7 +430,7 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve } @override - void didUpdateWidget(covariant _ContentInput oldWidget) { + void didUpdateWidget(covariant _TypingNotifier oldWidget) { super.didUpdateWidget(oldWidget); if (widget.controller != oldWidget.controller) { oldWidget.controller.content.removeListener(_contentChanged); @@ -458,6 +493,55 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve } } + @override + Widget build(BuildContext context) => widget.child; +} + +class _ContentInput extends StatelessWidget { + const _ContentInput({ + required this.narrow, + required this.controller, + this.hintText, + this.enabled = true, + }); + + final Narrow narrow; + final ComposeBoxController controller; + final String? hintText; + final bool enabled; + + void _handleContentInserted(BuildContext context, KeyboardInsertedContent content) async { + if (content.data == null || content.data!.isEmpty) { + // As of writing, the engine implementation never leaves `content.data` as + // `null`, but ideally it should be when the data cannot be read for + // errors. + // + // When `content.data` is empty, the data is not literally empty — this + // can also happen when the data can't be read from the input stream + // provided by the Android SDK because of an IO exception. + // + // See Flutter engine implementation that prepares this data: + // https://github.com/flutter/flutter/blob/0ffc4ce00/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java#L497-L548 + // TODO(upstream): improve the API for this + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorContentNotInsertedTitle, + message: zulipLocalizations.errorContentToInsertIsEmpty); + return; + } + + final file = FileToUpload( + content: Stream.fromIterable([content.data!]), + length: content.data!.length, + filename: path.basename(content.uri), + mimeType: content.mimeType); + + await controller.uploadFiles( + context: context, + files: [file], + shouldRequestFocus: true); + } + static double maxHeight(BuildContext context) { final clampingTextScaler = MediaQuery.textScalerOf(context) .clamp(maxScaleFactor: 1.5); @@ -488,9 +572,9 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve final designVariables = DesignVariables.of(context); return ComposeAutocomplete( - narrow: widget.narrow, - controller: widget.controller.content, - focusNode: widget.controller.contentFocusNode, + narrow: narrow, + controller: controller.content, + focusNode: controller.contentFocusNode, fieldViewBuilder: (context) => ConstrainedBox( constraints: BoxConstraints(maxHeight: maxHeight(context)), // This [ClipRect] replaces the [TextField] clipping we disable below. @@ -499,8 +583,11 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve top: _verticalPadding, bottom: _verticalPadding, color: designVariables.composeBoxBg, child: TextField( - controller: widget.controller.content, - focusNode: widget.controller.contentFocusNode, + enabled: enabled, + controller: controller.content, + focusNode: controller.contentFocusNode, + contentInsertionConfiguration: ContentInsertionConfiguration( + onContentInserted: (content) => _handleContentInserted(context, content)), // Let the content show through the `contentPadding` so that // our [InsetShadowBox] can fade it smoothly there. clipBehavior: Clip.none, @@ -526,7 +613,7 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve // that 54px distance while also making the scrolling work like // this and offering two lines of touchable area. contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding), - hintText: widget.hintText, + hintText: hintText, hintStyle: TextStyle( color: designVariables.textInput.withFadedAlpha(0.5)))))))); } @@ -544,19 +631,30 @@ class _StreamContentInput extends StatefulWidget { } class _StreamContentInputState extends State<_StreamContentInput> { - late String _topicTextNormalized; - void _topicChanged() { setState(() { - _topicTextNormalized = widget.controller.topic.textNormalized; + // The relevant state lives on widget.controller.topic itself. + }); + } + + void _contentFocusChanged() { + setState(() { + // The relevant state lives on widget.controller.contentFocusNode itself. + }); + } + + void _topicInteractionStatusChanged() { + setState(() { + // The relevant state lives on widget.controller.topicInteractionStatus itself. }); } @override void initState() { super.initState(); - _topicTextNormalized = widget.controller.topic.textNormalized; widget.controller.topic.addListener(_topicChanged); + widget.controller.contentFocusNode.addListener(_contentFocusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); } @override @@ -566,63 +664,206 @@ class _StreamContentInputState extends State<_StreamContentInput> { oldWidget.controller.topic.removeListener(_topicChanged); widget.controller.topic.addListener(_topicChanged); } + if (widget.controller.contentFocusNode != oldWidget.controller.contentFocusNode) { + oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged); + widget.controller.contentFocusNode.addListener(_contentFocusChanged); + } + if (widget.controller.topicInteractionStatus != oldWidget.controller.topicInteractionStatus) { + oldWidget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); + } } @override void dispose() { widget.controller.topic.removeListener(_topicChanged); + widget.controller.contentFocusNode.removeListener(_contentFocusChanged); + widget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); super.dispose(); } + /// The topic name to show in the hint text, or null to show no topic. + TopicName? _hintTopic() { + if (widget.controller.topic.isTopicVacuous) { + if (widget.controller.topic.mandatory) { + // The chosen topic can't be sent to, so don't show it. + return null; + } + if (widget.controller.topicInteractionStatus.value != + ComposeTopicInteractionStatus.hasChosen) { + // Do not fall back to a vacuous topic unless the user explicitly + // chooses to do so, so that the user is not encouraged to use vacuous + // topic before they have interacted with the inputs at all. + return null; + } + } + + return TopicName(widget.controller.topic.textNormalized); + } + @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); + final streamName = store.streams[widget.narrow.streamId]?.name ?? zulipLocalizations.unknownChannelName; - return _ContentInput( - narrow: widget.narrow, - destination: TopicNarrow(widget.narrow.streamId, TopicName(_topicTextNormalized)), + final hintTopic = _hintTopic(); + final hintDestination = hintTopic == null + // No i18n of this use of "#" and ">" string; those are part of how + // Zulip expresses channels and topics, not any normal English punctuation, + // so don't make sense to translate. See: + // https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585 + ? '#$streamName' + : '#$streamName > ${hintTopic.displayName ?? store.realmEmptyTopicDisplayName}'; + + return _TypingNotifier( + destination: TopicNarrow(widget.narrow.streamId, + TopicName(widget.controller.topic.textNormalized)), controller: widget.controller, - hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, _topicTextNormalized)); + child: _ContentInput( + narrow: widget.narrow, + controller: widget.controller, + hintText: zulipLocalizations.composeBoxChannelContentHint(hintDestination))); } } -class _TopicInput extends StatelessWidget { +class _TopicInput extends StatefulWidget { const _TopicInput({required this.streamId, required this.controller}); final int streamId; final StreamComposeBoxController controller; + @override + State<_TopicInput> createState() => _TopicInputState(); +} + +class _TopicInputState extends State<_TopicInput> { + void _topicOrContentFocusChanged() { + setState(() { + final status = widget.controller.topicInteractionStatus; + if (widget.controller.topicFocusNode.hasFocus) { + // topic input gains focus + status.value = ComposeTopicInteractionStatus.isEditing; + } else if (widget.controller.contentFocusNode.hasFocus) { + // content input gains focus + status.value = ComposeTopicInteractionStatus.hasChosen; + } else { + // neither input has focus, the new value of topicInteractionStatus + // depends on its previous value + if (status.value == ComposeTopicInteractionStatus.isEditing) { + // topic input loses focus + status.value = ComposeTopicInteractionStatus.notEditingNotChosen; + } else { + // content input loses focus; stay in hasChosen + assert(status.value == ComposeTopicInteractionStatus.hasChosen); + } + } + }); + } + + void _topicInteractionStatusChanged() { + setState(() { + // The actual state lives in widget.controller.topicInteractionStatus + }); + } + + @override + void initState() { + super.initState(); + widget.controller.topicFocusNode.addListener(_topicOrContentFocusChanged); + widget.controller.contentFocusNode.addListener(_topicOrContentFocusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); + } + + @override + void didUpdateWidget(covariant _TopicInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.topicFocusNode.removeListener(_topicOrContentFocusChanged); + widget.controller.topicFocusNode.addListener(_topicOrContentFocusChanged); + oldWidget.controller.contentFocusNode.removeListener(_topicOrContentFocusChanged); + widget.controller.contentFocusNode.addListener(_topicOrContentFocusChanged); + oldWidget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); + } + } + + @override + void dispose() { + widget.controller.topicFocusNode.removeListener(_topicOrContentFocusChanged); + widget.controller.contentFocusNode.removeListener(_topicOrContentFocusChanged); + widget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); + super.dispose(); + } + @override Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); - TextStyle topicTextStyle = TextStyle( + final store = PerAccountStoreWidget.of(context); + + final topicTextStyle = TextStyle( fontSize: 20, height: 22 / 20, color: designVariables.textInput.withFadedAlpha(0.9), ).merge(weightVariableTextStyle(context, wght: 600)); + // TODO(server-10) simplify away + final emptyTopicsSupported = store.zulipFeatureLevel >= 334; + + final String hintText; + TextStyle hintStyle = topicTextStyle.copyWith( + color: designVariables.textInput.withFadedAlpha(0.5)); + + if (store.realmMandatoryTopics) { + // Something short and not distracting. + hintText = zulipLocalizations.composeBoxTopicHintText; + } else { + switch (widget.controller.topicInteractionStatus.value) { + case ComposeTopicInteractionStatus.notEditingNotChosen: + // Something short and not distracting. + hintText = zulipLocalizations.composeBoxTopicHintText; + case ComposeTopicInteractionStatus.isEditing: + // The user is actively interacting with the input. Since topics are + // not mandatory, show a long hint text mentioning that they can be + // left empty. + hintText = zulipLocalizations.composeBoxEnterTopicOrSkipHintText( + emptyTopicsSupported + ? store.realmEmptyTopicDisplayName + : kNoTopicTopic); + case ComposeTopicInteractionStatus.hasChosen: + // The topic has likely been chosen. Since topics are not mandatory, + // show the default topic display name as if the user has entered that + // when they left the input empty. + if (emptyTopicsSupported) { + hintText = store.realmEmptyTopicDisplayName; + hintStyle = topicTextStyle.copyWith(fontStyle: FontStyle.italic); + } else { + hintText = kNoTopicTopic; + hintStyle = topicTextStyle; + } + } + } + + final decoration = InputDecoration(hintText: hintText, hintStyle: hintStyle); + return TopicAutocomplete( - streamId: streamId, - controller: controller.topic, - focusNode: controller.topicFocusNode, - contentFocusNode: controller.contentFocusNode, + streamId: widget.streamId, + controller: widget.controller.topic, + focusNode: widget.controller.topicFocusNode, + contentFocusNode: widget.controller.contentFocusNode, fieldViewBuilder: (context) => Container( padding: const EdgeInsets.only(top: 10, bottom: 9), decoration: BoxDecoration(border: Border(bottom: BorderSide( width: 1, color: designVariables.foreground.withFadedAlpha(0.2)))), child: TextField( - controller: controller.topic, - focusNode: controller.topicFocusNode, + controller: widget.controller.topic, + focusNode: widget.controller.topicFocusNode, textInputAction: TextInputAction.next, style: topicTextStyle, - decoration: InputDecoration( - hintText: zulipLocalizations.composeBoxTopicHintText, - hintStyle: topicTextStyle.copyWith( - color: designVariables.textInput.withFadedAlpha(0.5)))))); + decoration: decoration))); } } @@ -643,16 +884,22 @@ class _FixedDestinationContentInput extends StatelessWidget { final streamName = store.streams[streamId]?.name ?? zulipLocalizations.unknownChannelName; return zulipLocalizations.composeBoxChannelContentHint( - streamName, topic.displayName); + // No i18n of this use of "#" and ">" string; those are part of how + // Zulip expresses channels and topics, not any normal English punctuation, + // so don't make sense to translate. See: + // https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585 + '#$streamName > ${topic.displayName ?? store.realmEmptyTopicDisplayName}'); case DmNarrow(otherRecipientIds: []): // The self-1:1 thread. return zulipLocalizations.composeBoxSelfDmContentHint; case DmNarrow(otherRecipientIds: [final otherUserId]): final store = PerAccountStoreWidget.of(context); - final fullName = store.users[otherUserId]?.fullName; - if (fullName == null) return zulipLocalizations.composeBoxGenericContentHint; - return zulipLocalizations.composeBoxDmContentHint(fullName); + final user = store.getUser(otherUserId); + if (user == null) return zulipLocalizations.composeBoxGenericContentHint; + // TODO write a test where the user is muted + return zulipLocalizations.composeBoxDmContentHint( + store.userDisplayName(otherUserId, replaceIfMuted: false)); case DmNarrow(): // A group DM thread. return zulipLocalizations.composeBoxGroupDmContentHint; @@ -661,11 +908,38 @@ class _FixedDestinationContentInput extends StatelessWidget { @override Widget build(BuildContext context) { + return _TypingNotifier( + destination: narrow, + controller: controller, + child: _ContentInput( + narrow: narrow, + controller: controller, + hintText: _hintText(context))); + } +} + +class _EditMessageContentInput extends StatelessWidget { + const _EditMessageContentInput({ + required this.narrow, + required this.controller, + }); + + final Narrow narrow; + final EditMessageComposeBoxController controller; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final awaitingRawContent = ComposeBoxInheritedWidget.of(context) + .awaitingRawMessageContentForEdit; return _ContentInput( narrow: narrow, - destination: narrow, controller: controller, - hintText: _hintText(context)); + enabled: !awaitingRawContent, + hintText: awaitingRawContent + ? zulipLocalizations.preparingEditMessageContentInput + : null, + ); } } @@ -673,8 +947,8 @@ class _FixedDestinationContentInput extends StatelessWidget { /// /// A convenience class to represent data from the generic file picker, /// the media library, and the camera, in a single form. -class _File { - _File({ +class FileToUpload { + FileToUpload({ required this.content, required this.length, required this.filename, @@ -691,14 +965,15 @@ Future _uploadFiles({ required BuildContext context, required ComposeContentController contentController, required FocusNode contentFocusNode, - required Iterable<_File> files, + bool shouldRequestFocus = true, + required Iterable files, }) async { assert(context.mounted); final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); - final List<_File> tooLargeFiles = []; - final List<_File> rightSizeFiles = []; + final List tooLargeFiles = []; + final List rightSizeFiles = []; for (final file in files) { if ((file.length / (1 << 20)) > store.maxFileUploadSizeMib) { tooLargeFiles.add(file); @@ -709,7 +984,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, @@ -720,19 +996,19 @@ Future _uploadFiles({ listMessage)); } - final List<(int, _File)> uploadsInProgress = []; + final List<(int, FileToUpload)> uploadsInProgress = []; for (final file in rightSizeFiles) { final tag = contentController.registerUploadStart(file.filename, zulipLocalizations); uploadsInProgress.add((tag, file)); } - if (!contentFocusNode.hasFocus) { + if (shouldRequestFocus && !contentFocusNode.hasFocus) { contentFocusNode.requestFocus(); } for (final (tag, file) in uploadsInProgress) { - final _File(:content, :length, :filename, :mimeType) = file; - Uri? url; + final FileToUpload(:content, :length, :filename, :mimeType) = file; + String? url; try { final result = await uploadFile(store.connection, content: content, @@ -740,7 +1016,7 @@ Future _uploadFiles({ filename: filename, contentType: mimeType, ); - url = Uri.parse(result.uri); + url = result.url; } catch (e) { if (!context.mounted) return; // TODO(#741): Specifically handle `413 Payload Too Large` @@ -755,9 +1031,10 @@ Future _uploadFiles({ } abstract class _AttachUploadsButton extends StatelessWidget { - const _AttachUploadsButton({required this.controller}); + const _AttachUploadsButton({required this.controller, required this.enabled}); final ComposeBoxController controller; + final bool enabled; IconData get icon; String tooltip(ZulipLocalizations zulipLocalizations); @@ -769,7 +1046,7 @@ abstract class _AttachUploadsButton extends StatelessWidget { /// /// To signal exiting the interaction with no files chosen, /// return an empty [Iterable] after showing user feedback as appropriate. - Future> getFiles(BuildContext context); + Future> getFiles(BuildContext context); void _handlePress(BuildContext context) async { final files = await getFiles(context); @@ -783,11 +1060,10 @@ abstract class _AttachUploadsButton extends StatelessWidget { return; } - await _uploadFiles( + await controller.uploadFiles( context: context, - contentController: controller.content, - contentFocusNode: controller.contentFocusNode, - files: files); + files: files, + shouldRequestFocus: true); } @override @@ -799,11 +1075,11 @@ abstract class _AttachUploadsButton extends StatelessWidget { child: IconButton( icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)), tooltip: tooltip(zulipLocalizations), - onPressed: () => _handlePress(context))); + onPressed: enabled ? () => _handlePress(context) : null)); } } -Future> _getFilePickerFiles(BuildContext context, FileType type) async { +Future> _getFilePickerFiles(BuildContext context, FileType type) async { FilePickerResult? result; try { result = await ZulipBinding.instance @@ -818,13 +1094,13 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) // If the user hasn't checked "Don't ask again", they can always dismiss // our prompt and retry, and the permissions request will reappear, // letting them grant permissions and complete the upload. - showSuggestedActionDialog(context: context, + final dialog = showSuggestedActionDialog(context: context, title: zulipLocalizations.permissionsNeededTitle, message: zulipLocalizations.permissionsDeniedReadExternalStorage, - actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, - onActionButtonPress: () { - AppSettings.openAppSettings(); - }); + actionButtonText: zulipLocalizations.permissionsNeededOpenSettings); + if (await dialog.result == true) { + unawaited(AppSettings.openAppSettings()); + } } else { showErrorDialog(context: context, title: zulipLocalizations.errorDialogTitle, @@ -848,7 +1124,7 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) f.path ?? '', headerBytes: f.bytes?.take(defaultMagicNumbersMaxLength).toList(), ); - return _File( + return FileToUpload( content: f.readStream!, length: f.size, filename: f.name, @@ -858,7 +1134,7 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) } class _AttachFileButton extends _AttachUploadsButton { - const _AttachFileButton({required super.controller}); + const _AttachFileButton({required super.controller, required super.enabled}); @override IconData get icon => ZulipIcons.attach_file; @@ -868,13 +1144,13 @@ class _AttachFileButton extends _AttachUploadsButton { zulipLocalizations.composeBoxAttachFilesTooltip; @override - Future> getFiles(BuildContext context) async { + Future> getFiles(BuildContext context) async { return _getFilePickerFiles(context, FileType.any); } } class _AttachMediaButton extends _AttachUploadsButton { - const _AttachMediaButton({required super.controller}); + const _AttachMediaButton({required super.controller, required super.enabled}); @override IconData get icon => ZulipIcons.image; @@ -884,14 +1160,14 @@ class _AttachMediaButton extends _AttachUploadsButton { zulipLocalizations.composeBoxAttachMediaTooltip; @override - Future> getFiles(BuildContext context) async { + Future> getFiles(BuildContext context) async { // TODO(#114): This doesn't give quite the right UI on Android. return _getFilePickerFiles(context, FileType.media); } } class _AttachFromCameraButton extends _AttachUploadsButton { - const _AttachFromCameraButton({required super.controller}); + const _AttachFromCameraButton({required super.controller, required super.enabled}); @override IconData get icon => ZulipIcons.camera; @@ -901,7 +1177,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton { zulipLocalizations.composeBoxAttachFromCameraTooltip; @override - Future> getFiles(BuildContext context) async { + Future> getFiles(BuildContext context) async { final zulipLocalizations = ZulipLocalizations.of(context); final XFile? result; try { @@ -919,13 +1195,13 @@ class _AttachFromCameraButton extends _AttachUploadsButton { // permission-request alert once, the first time the app wants to // use a protected resource. After that, the only way the user can // grant it is in Settings. - showSuggestedActionDialog(context: context, + final dialog = showSuggestedActionDialog(context: context, title: zulipLocalizations.permissionsNeededTitle, message: zulipLocalizations.permissionsDeniedCameraAccess, - actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, - onActionButtonPress: () { - AppSettings.openAppSettings(); - }); + actionButtonText: zulipLocalizations.permissionsNeededOpenSettings); + if (await dialog.result == true) { + unawaited(AppSettings.openAppSettings()); + } } else { showErrorDialog(context: context, title: zulipLocalizations.errorDialogTitle, @@ -952,7 +1228,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton { } catch (e) { // TODO(log) } - return [_File( + return [FileToUpload( content: result.openRead(), length: length, filename: result.name, @@ -1051,15 +1327,8 @@ class _SendButtonState extends State<_SendButton> { final content = controller.content.textNormalized; controller.content.clear(); - // The following `stoppedComposing` call is currently redundant, - // because clearing input sends a "typing stopped" notice. - // It will be necessary once we resolve #720. - store.typingNotifier.stoppedComposing(); try { - // TODO(#720) clear content input only on success response; - // while waiting, put input(s) and send button into a disabled - // "working on it" state (letting input text be selected for copying). await store.sendMessage(destination: widget.getDestination(), content: content); } on ApiRequestException catch (e) { if (!mounted) return; @@ -1101,17 +1370,17 @@ class _SendButtonState extends State<_SendButton> { class _ComposeBoxContainer extends StatelessWidget { const _ComposeBoxContainer({ required this.body, - this.errorBanner, - }) : assert(body != null || errorBanner != null); + this.banner, + }) : assert(body != null || banner != null); /// The text inputs, compose-button row, and send button. /// /// This widget does not need a [SafeArea] to consume any device insets. /// - /// Can be null, but only if [errorBanner] is non-null. + /// Can be null, but only if [banner] is non-null. final Widget? body; - /// An error bar that goes at the top. + /// A bar that goes at the top. /// /// This may be present on its own or with a [body]. /// If [body] is null this must be present. @@ -1119,7 +1388,7 @@ class _ComposeBoxContainer extends StatelessWidget { /// This widget should use a [SafeArea] to pad the left, right, /// and bottom device insets. /// (A bottom inset may occur if [body] is null.) - final Widget? errorBanner; + final Widget? banner; Widget _paddedBody() { assert(body != null); @@ -1131,15 +1400,15 @@ class _ComposeBoxContainer extends StatelessWidget { Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); - final List children = switch ((errorBanner, body)) { + final List children = switch ((banner, body)) { (Widget(), Widget()) => [ // _paddedBody() already pads the bottom inset, - // so make sure the error banner doesn't double-pad it. + // so make sure the banner doesn't double-pad it. MediaQuery.removePadding(context: context, removeBottom: true, - child: errorBanner!), + child: banner!), _paddedBody(), ], - (Widget(), null) => [errorBanner!], + (Widget(), null) => [banner!], (null, Widget()) => [_paddedBody()], (null, null) => throw UnimplementedError(), // not allowed, see dartdoc }; @@ -1151,7 +1420,6 @@ class _ComposeBoxContainer extends StatelessWidget { border: Border(top: BorderSide(color: designVariables.borderBar)), boxShadow: ComposeBoxTheme.of(context).boxShadow, ), - // TODO(#720) try a Stack for the overlaid linear progress indicator child: Material( color: designVariables.composeBoxBg, child: Column( @@ -1168,7 +1436,8 @@ abstract class _ComposeBoxBody extends StatelessWidget { Widget? buildTopicInput(); Widget buildContentInput(); - Widget buildSendButton(); + bool getComposeButtonsEnabled(BuildContext context); + Widget? buildSendButton(); @override Widget build(BuildContext context) { @@ -1194,13 +1463,15 @@ abstract class _ComposeBoxBody extends StatelessWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(4))))); + final composeButtonsEnabled = getComposeButtonsEnabled(context); final composeButtons = [ - _AttachFileButton(controller: controller), - _AttachMediaButton(controller: controller), - _AttachFromCameraButton(controller: controller), + _AttachFileButton(controller: controller, enabled: composeButtonsEnabled), + _AttachMediaButton(controller: controller, enabled: composeButtonsEnabled), + _AttachFromCameraButton(controller: controller, enabled: composeButtonsEnabled), ]; final topicInput = buildTopicInput(); + final sendButton = buildSendButton(); return Column(children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -1218,7 +1489,7 @@ abstract class _ComposeBoxBody extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row(children: composeButtons), - buildSendButton(), + if (sendButton != null) sendButton, ]))), ]); } @@ -1247,6 +1518,8 @@ class _StreamComposeBoxBody extends _ComposeBoxBody { controller: controller, ); + @override bool getComposeButtonsEnabled(BuildContext context) => true; + @override Widget buildSendButton() => _SendButton( controller: controller, getDestination: () => StreamDestination( @@ -1270,16 +1543,75 @@ class _FixedDestinationComposeBoxBody extends _ComposeBoxBody { controller: controller, ); + @override bool getComposeButtonsEnabled(BuildContext context) => true; + @override Widget buildSendButton() => _SendButton( controller: controller, getDestination: () => narrow.destination, ); } +/// A compose box for editing an already-sent message. +class _EditMessageComposeBoxBody extends _ComposeBoxBody { + _EditMessageComposeBoxBody({required this.narrow, required this.controller}); + + @override + final Narrow narrow; + + @override + final EditMessageComposeBoxController controller; + + @override Widget? buildTopicInput() => null; + + @override Widget buildContentInput() => _EditMessageContentInput( + narrow: narrow, + controller: controller); + + @override bool getComposeButtonsEnabled(BuildContext context) => + !ComposeBoxInheritedWidget.of(context).awaitingRawMessageContentForEdit; + + @override Widget? buildSendButton() => null; +} + sealed class ComposeBoxController { final content = ComposeContentController(); final contentFocusNode = FocusNode(); + /// If no input is focused, requests focus on the appropriate input. + /// + /// This encapsulates choosing the topic or content input + /// when both exist (see [StreamComposeBoxController.requestFocusIfUnfocused]). + void requestFocusIfUnfocused() { + if (contentFocusNode.hasFocus) return; + contentFocusNode.requestFocus(); + } + + /// Uploads the provided files, populating the content input with their links. + /// + /// If any of the files are larger than maximum file size allowed by the + /// server, an error dialog is shown mentioning their names and actual + /// file sizes. + /// + /// While uploading, a placeholder link is inserted in the content input and + /// if [shouldRequestFocus] is true it will be focused. And then after + /// uploading completes successfully the placeholder link will be replaced + /// with an actual link. + /// + /// If there is an error while uploading a file, then an error dialog is + /// shown mentioning the corresponding file name. + Future uploadFiles({ + required BuildContext context, + required Iterable files, + required bool shouldRequestFocus, + }) async { + await _uploadFiles( + context: context, + contentController: content, + contentFocusNode: contentFocusNode, + shouldRequestFocus: shouldRequestFocus, + files: files); + } + @mustCallSuper void dispose() { content.dispose(); @@ -1287,55 +1619,280 @@ sealed class ComposeBoxController { } } +/// Represent how a user has interacted with topic and content inputs. +/// +/// State-transition diagram: +/// +/// ``` +/// (default) +/// Topic input │ Content input +/// lost focus. ▼ gained focus. +/// ┌────────────► notEditingNotChosen ────────────┐ +/// │ │ │ +/// │ Topic input │ │ +/// │ gained focus. │ │ +/// │ ◄─────────────────────────┘ ▼ +/// isEditing ◄───────────────────────────── hasChosen +/// │ Focus moved from ▲ │ ▲ +/// │ content to topic. │ │ │ +/// │ │ │ │ +/// └──────────────────────────────────────┘ └─────┘ +/// Focus moved from Content input loses focus +/// topic to content. without topic input gaining it. +/// ``` +/// +/// This state machine offers the following invariants: +/// - When topic input has focus, the status must be [isEditing]. +/// - When content input has focus, the status must be [hasChosen]. +/// - When neither input has focus, and content input was the last +/// input among the two to be focused, the status must be [hasChosen]. +/// - Otherwise, the status must be [notEditingNotChosen]. +enum ComposeTopicInteractionStatus { + /// The topic has likely not been chosen if left empty, + /// and is not being actively edited. + /// + /// When in this status neither the topic input nor the content input has focus. + notEditingNotChosen, + + /// The topic is being actively edited. + /// + /// When in this status, the topic input must have focus. + isEditing, + + /// The topic has likely been chosen, even if it is left empty. + /// + /// When in this status, the topic input must have no focus; + /// the content input might have focus. + hasChosen, +} + class StreamComposeBoxController extends ComposeBoxController { StreamComposeBoxController({required PerAccountStore store}) : topic = ComposeTopicController(store: store); final ComposeTopicController topic; final topicFocusNode = FocusNode(); + final ValueNotifier topicInteractionStatus = + ValueNotifier(ComposeTopicInteractionStatus.notEditingNotChosen); + + @override void requestFocusIfUnfocused() { + if (topicFocusNode.hasFocus || contentFocusNode.hasFocus) return; + switch (topicInteractionStatus.value) { + case ComposeTopicInteractionStatus.notEditingNotChosen: + topicFocusNode.requestFocus(); + case ComposeTopicInteractionStatus.isEditing: + // (should be impossible given early-return on topicFocusNode.hasFocus) + break; + case ComposeTopicInteractionStatus.hasChosen: + contentFocusNode.requestFocus(); + } + } @override void dispose() { topic.dispose(); topicFocusNode.dispose(); + topicInteractionStatus.dispose(); super.dispose(); } } class FixedDestinationComposeBoxController extends ComposeBoxController {} -class _ErrorBanner extends StatelessWidget { - const _ErrorBanner({required this.label}); +class EditMessageComposeBoxController extends ComposeBoxController { + EditMessageComposeBoxController({ + required this.messageId, + required this.originalRawContent, + required String? initialText, + }) : _content = ComposeContentController( + text: initialText, + // Editing to delete the content is a supported form of + // deletion: https://zulip.com/help/delete-a-message#delete-message-content + requireNotEmpty: false); + + factory EditMessageComposeBoxController.empty(int messageId) => + EditMessageComposeBoxController(messageId: messageId, + originalRawContent: null, initialText: null); + + @override ComposeContentController get content => _content; + final ComposeContentController _content; + + final int messageId; + String? originalRawContent; +} + +/// A banner to display over or instead of interactive compose-box content. +/// +/// Must have a [PageRoot] ancestor. +abstract class _Banner extends StatelessWidget { + const _Banner(); - final String label; + String getLabel(ZulipLocalizations zulipLocalizations); + Color getLabelColor(DesignVariables designVariables); + Color getBackgroundColor(DesignVariables designVariables); + + /// A trailing element, with vertical but not horizontal outer padding + /// for spacing/positioning. + /// + /// An interactive element's touchable area should have height at least 44px, + /// with some of that as "slop" vertical outer padding above and below + /// what gets painted: + /// https://github.com/zulip/zulip-flutter/pull/1432#discussion_r2023907300 + /// + /// To control the element's distance from the end edge, override [padEnd]. + /// + /// The passed [BuildContext] will be the result of [PageRoot.contextOf], + /// so it's expected to remain mounted until the whole page disappears, + /// which may be long after the banner disappears. + Widget? buildTrailing(BuildContext pageContext); + + /// Whether to apply `end: 8` in [SafeArea.minimum]. + /// + /// Subclasses can use `false` when the [buildTrailing] element + /// is meant to abut the edge of the screen + /// in the common case that there are no horizontal device insets. + bool get padEnd => true; @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); final labelTextStyle = TextStyle( fontSize: 17, height: 22 / 17, - color: designVariables.btnLabelAttMediumIntDanger, + color: getLabelColor(designVariables), ).merge(weightVariableTextStyle(context, wght: 600)); + final trailing = buildTrailing(PageRoot.contextOf(context)); return DecoratedBox( decoration: BoxDecoration( - color: designVariables.bannerBgIntDanger), + color: getBackgroundColor(designVariables)), child: SafeArea( - minimum: const EdgeInsetsDirectional.only(start: 8) + minimum: EdgeInsetsDirectional.only(start: 8, end: padEnd ? 8 : 0) // (SafeArea.minimum doesn't take an EdgeInsetsDirectional) .resolve(Directionality.of(context)), - child: Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB(8, 9, 0, 9), - child: Text(style: labelTextStyle, - label))), - const SizedBox(width: 8), - // TODO(#720) "x" button goes here. - // 24px square with 8px touchable padding in all directions? - ]))); + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 8), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 9), + child: Text( + style: labelTextStyle, + textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5), + getLabel(zulipLocalizations)))), + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing, + ], + ])))); + } +} + +class _ErrorBanner extends _Banner { + const _ErrorBanner({ + required String Function(ZulipLocalizations) getLabel, + }) : _getLabel = getLabel; + + @override + String getLabel(ZulipLocalizations zulipLocalizations) => + _getLabel(zulipLocalizations); + final String Function(ZulipLocalizations) _getLabel; + + @override + Color getLabelColor(DesignVariables designVariables) => + designVariables.btnLabelAttMediumIntDanger; + + @override + Color getBackgroundColor(DesignVariables designVariables) => + designVariables.bannerBgIntDanger; + + @override + Widget? buildTrailing(pageContext) { + // An "x" button can go here. + // 24px square with 8px touchable padding in all directions? + // and `bool get padEnd => false`; see Figma: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4031-17029&m=dev + return null; + } +} + +class _EditMessageBanner extends _Banner { + const _EditMessageBanner({required this.composeBoxState}); + + final ComposeBoxState composeBoxState; + + @override + String getLabel(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.composeBoxBannerLabelEditMessage; + + @override + Color getLabelColor(DesignVariables designVariables) => + designVariables.bannerTextIntInfo; + + @override + Color getBackgroundColor(DesignVariables designVariables) => + designVariables.bannerBgIntInfo; + + void _handleTapSave (BuildContext pageContext) async { + final store = PerAccountStoreWidget.of(pageContext); + final controller = composeBoxState.controller; + if (controller is! EditMessageComposeBoxController) return; // TODO(log) + final zulipLocalizations = ZulipLocalizations.of(pageContext); + + if (controller.content.hasValidationErrors.value) { + final validationErrorMessages = + controller.content.validationErrors.map((error) => + error.message(zulipLocalizations)); + showErrorDialog(context: pageContext, + title: zulipLocalizations.errorMessageEditNotSaved, + message: validationErrorMessages.join('\n\n')); + return; + } + + final originalRawContent = controller.originalRawContent; + if (originalRawContent == null) { + // Fetch-raw-content request hasn't finished; try again later. + // TODO show error dialog? + return; + } + + final messageId = controller.messageId; + final newContent = controller.content.textNormalized; + composeBoxState.endEditInteraction(); + + try { + await store.editMessage( + messageId: messageId, + originalRawContent: originalRawContent, + newContent: newContent); + } on ApiRequestException catch (e) { + if (!pageContext.mounted) return; + final zulipLocalizations = ZulipLocalizations.of(pageContext); + final message = switch (e) { + ZulipApiException() => zulipLocalizations.errorServerMessage(e.message), + _ => e.message, + }; + showErrorDialog(context: pageContext, + title: zulipLocalizations.errorMessageEditNotSaved, + message: message); + return; + } + } + + @override + Widget buildTrailing(pageContext) { + final zulipLocalizations = ZulipLocalizations.of(pageContext); + return Row(mainAxisSize: MainAxisSize.min, spacing: 8, children: [ + ZulipWebUiKitButton(label: zulipLocalizations.composeBoxBannerButtonCancel, + onPressed: composeBoxState.endEditInteraction), + // TODO(#1481) disabled appearance when there are validation errors + // or the original raw content hasn't loaded yet + ZulipWebUiKitButton(label: zulipLocalizations.composeBoxBannerButtonSave, + attention: ZulipWebUiKitButtonAttention.high, + onPressed: () => _handleTapSave(pageContext)), + ]); } } @@ -1359,6 +1916,7 @@ class ComposeBox extends StatefulWidget { case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): return false; } } @@ -1370,28 +1928,214 @@ class ComposeBox extends StatefulWidget { /// The interface for the state of a [ComposeBox]. abstract class ComposeBoxState extends State { ComposeBoxController get controller; + + /// Fills the compose box with the content of an [OutboxMessage] + /// for a failed [sendMessage] request. + /// + /// If there is already text in the compose box, gives a confirmation dialog + /// to confirm that it is OK to discard that text. + /// + /// [localMessageId], as in [OutboxMessage.localMessageId], must be present + /// in the message store. + void restoreMessageNotSent(int localMessageId); + + /// Switch the compose box to editing mode. + /// + /// If there is already text in the compose box, gives a confirmation dialog + /// to confirm that it is OK to discard that text. + /// + /// If called from the message action sheet, fetches the raw message content + /// to fill in the edit-message compose box. + /// + /// If called by tapping a message in the message list with 'EDIT NOT SAVED', + /// fills the edit-message compose box with the content the user wanted + /// in the edit request that failed. + void startEditInteraction(int messageId); + + /// Switch the compose box back to regular non-edit mode, with no content. + void endEditInteraction(); } class _ComposeBoxState extends State with PerAccountStoreAwareStateMixin implements ComposeBoxState { @override ComposeBoxController get controller => _controller!; ComposeBoxController? _controller; + @override + void restoreMessageNotSent(int localMessageId) async { + final zulipLocalizations = ZulipLocalizations.of(context); + + final abort = await _abortBecauseContentInputNotEmpty( + dialogMessage: zulipLocalizations.discardDraftForOutboxConfirmationDialogMessage); + if (abort || !mounted) return; + + final store = PerAccountStoreWidget.of(context); + final outboxMessage = store.takeOutboxMessage(localMessageId); + setState(() { + _setNewController(store); + final controller = this.controller; + controller + ..content.value = TextEditingValue(text: outboxMessage.contentMarkdown) + ..contentFocusNode.requestFocus(); + if (controller is StreamComposeBoxController) { + controller.topic.setTopic( + (outboxMessage.conversation as StreamConversation).topic); + } + }); + } + + @override + void startEditInteraction(int messageId) async { + final zulipLocalizations = ZulipLocalizations.of(context); + + final abort = await _abortBecauseContentInputNotEmpty( + dialogMessage: zulipLocalizations.discardDraftForEditConfirmationDialogMessage); + if (abort || !mounted) return; + + final store = PerAccountStoreWidget.of(context); + + switch (store.getEditMessageErrorStatus(messageId)) { + case null: + _editFromRawContentFetch(messageId); + case true: + _editByRestoringFailedEdit(messageId); + case false: + // This can happen if you start an edit interaction on one + // MessageListPage and then do an edit on a different MessageListPage, + // and the second edit is still saving when you return to the first. + // + // Abort rather than sending a request with a prevContentSha256 + // that the server might not accept, and don't clear the compose + // box, so the user can try again after the request settles. + // TODO could write a test for this + showErrorDialog(context: context, + title: zulipLocalizations.editAlreadyInProgressTitle, + message: zulipLocalizations.editAlreadyInProgressMessage); + return; + } + } + + /// If there's text in the compose box, give a confirmation dialog + /// asking if it can be discarded and await the result. + Future _abortBecauseContentInputNotEmpty({ + required String dialogMessage, + }) async { + final zulipLocalizations = ZulipLocalizations.of(context); + if (controller.content.textNormalized.isNotEmpty) { + final dialog = showSuggestedActionDialog(context: context, + title: zulipLocalizations.discardDraftConfirmationDialogTitle, + message: dialogMessage, + // TODO(#1032) "destructive" style for action button + actionButtonText: zulipLocalizations.discardDraftConfirmationDialogConfirmButton); + if (await dialog.result != true) return true; + } + return false; + } + + void _editByRestoringFailedEdit(int messageId) { + final store = PerAccountStoreWidget.of(context); + // Fill the content input with the content the user wanted in the failed + // edit attempt, not the original content. + // Side effect: Clears the "EDIT NOT SAVED" text in the message list. + final failedEdit = store.takeFailedMessageEdit(messageId); + setState(() { + controller.dispose(); + _controller = EditMessageComposeBoxController( + messageId: messageId, + originalRawContent: failedEdit.originalRawContent, + initialText: failedEdit.newContent, + ) + ..contentFocusNode.requestFocus(); + }); + } + + void _editFromRawContentFetch(int messageId) async { + final zulipLocalizations = ZulipLocalizations.of(context); + final emptyEditController = EditMessageComposeBoxController.empty(messageId); + setState(() { + controller.dispose(); + _controller = emptyEditController; + }); + final fetchedRawContent = await ZulipAction.fetchRawContentWithFeedback( + context: context, + messageId: messageId, + errorDialogTitle: zulipLocalizations.errorCouldNotEditMessageTitle, + ); + // TODO timeout this request? + if (!mounted) return; + if (!identical(controller, emptyEditController)) { + // During the fetch-raw-content request, the user tapped Cancel + // or tapped a failed message edit or failed outbox message to restore. + // TODO in this case we don't want the error dialog caused by + // ZulipAction.fetchRawContentWithFeedback; suppress that + return; + } + if (fetchedRawContent == null) { + // Fetch-raw-content failed; abort the edit session. + // An error dialog was already shown, by fetchRawContentWithFeedback. + setState(() { + controller.dispose(); + _setNewController(PerAccountStoreWidget.of(context)); + }); + return; + } + // TODO scroll message list to ensure the message is still in view; + // highlight it? + assert(controller is EditMessageComposeBoxController); + final editMessageController = controller as EditMessageComposeBoxController; + setState(() { + // setState to refresh the input, upload buttons, etc. + // out of the disabled "Preparing…" state. + editMessageController.originalRawContent = fetchedRawContent; + }); + editMessageController.content.value = TextEditingValue(text: fetchedRawContent); + SchedulerBinding.instance.addPostFrameCallback((_) { + // post-frame callback so this happens after the input is enabled + editMessageController.contentFocusNode.requestFocus(); + }); + } + + @override + void endEditInteraction() { + assert(controller is EditMessageComposeBoxController); + if (controller is! EditMessageComposeBoxController) return; // TODO(log) + + final store = PerAccountStoreWidget.of(context); + setState(() { + controller.dispose(); + _setNewController(store); + }); + } + @override void onNewStore() { + final newStore = PerAccountStoreWidget.of(context); + + final controller = _controller; + if (controller == null) { + _setNewController(newStore); + return; + } + + switch (controller) { + case StreamComposeBoxController(): + controller.topic.store = newStore; + case FixedDestinationComposeBoxController(): + case EditMessageComposeBoxController(): + // no reference to the store that needs updating + } + } + + void _setNewController(PerAccountStore store) { switch (widget.narrow) { case ChannelNarrow(): - final store = PerAccountStoreWidget.of(context); - if (_controller == null) { - _controller = StreamComposeBoxController(store: store); - } else { - (controller as StreamComposeBoxController).topic.store = store; - } + _controller = StreamComposeBoxController(store: store); case TopicNarrow(): case DmNarrow(): _controller = FixedDestinationComposeBoxController(); case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): assert(false); } } @@ -1402,28 +2146,31 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM super.dispose(); } - Widget? _errorBanner(BuildContext context) { + /// An [_ErrorBanner] that replaces the compose box's text inputs. + Widget? _errorBannerComposingNotAllowed(BuildContext context) { final store = PerAccountStoreWidget.of(context); - final selfUser = store.users[store.selfUserId]!; switch (widget.narrow) { case ChannelNarrow(:final streamId): case TopicNarrow(:final streamId): final channel = store.streams[streamId]; if (channel == null || !store.hasPostingPermission(inChannel: channel, - user: selfUser, byDate: DateTime.now())) { - return _ErrorBanner(label: - ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel); + user: store.selfUser, byDate: DateTime.now())) { + return _ErrorBanner(getLabel: (zulipLocalizations) => + zulipLocalizations.errorBannerCannotPostInChannelLabel); } + case DmNarrow(:final otherRecipientIds): final hasDeactivatedUser = otherRecipientIds.any((id) => - !(store.users[id]?.isActive ?? true)); + !(store.getUser(id)?.isActive ?? true)); if (hasDeactivatedUser) { - return _ErrorBanner(label: - ZulipLocalizations.of(context).errorBannerDeactivatedDmLabel); + return _ErrorBanner(getLabel: (zulipLocalizations) => + zulipLocalizations.errorBannerDeactivatedDmLabel); } + case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): return null; } return null; @@ -1431,13 +2178,15 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM @override Widget build(BuildContext context) { - final Widget? body; - - final errorBanner = _errorBanner(context); + final errorBanner = _errorBannerComposingNotAllowed(context); if (errorBanner != null) { - return _ComposeBoxContainer(body: null, errorBanner: errorBanner); + return ComposeBoxInheritedWidget.fromComposeBoxState(this, + child: _ComposeBoxContainer(body: null, banner: errorBanner)); } + final Widget? body; + Widget? banner; + final controller = this.controller; final narrow = widget.narrow; switch (controller) { @@ -1449,13 +2198,47 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM narrow as SendableNarrow; body = _FixedDestinationComposeBoxBody(controller: controller, narrow: narrow); } + case EditMessageComposeBoxController(): { + body = _EditMessageComposeBoxBody(controller: controller, narrow: narrow); + banner = _EditMessageBanner(composeBoxState: this); + } } - // TODO(#720) dismissable message-send error, maybe something like: - // if (controller.sendMessageError.value != null) { - // errorBanner = _ErrorBanner(label: - // ZulipLocalizations.of(context).errorSendMessageTimeout); - // } - return _ComposeBoxContainer(body: body, errorBanner: null); + return ComposeBoxInheritedWidget.fromComposeBoxState(this, + child: _ComposeBoxContainer(body: body, banner: banner)); + } +} + +/// An [InheritedWidget] to provide data to leafward [StatelessWidget]s, +/// such as flags that should cause the upload buttons to be disabled. +class ComposeBoxInheritedWidget extends InheritedWidget { + factory ComposeBoxInheritedWidget.fromComposeBoxState( + ComposeBoxState state, { + required Widget child, + }) { + final controller = state.controller; + return ComposeBoxInheritedWidget._( + awaitingRawMessageContentForEdit: + controller is EditMessageComposeBoxController + && controller.originalRawContent == null, + child: child, + ); + } + + const ComposeBoxInheritedWidget._({ + required this.awaitingRawMessageContentForEdit, + required super.child, + }); + + final bool awaitingRawMessageContentForEdit; + + @override + bool updateShouldNotify(covariant ComposeBoxInheritedWidget oldWidget) => + awaitingRawMessageContentForEdit != oldWidget.awaitingRawMessageContentForEdit; + + static ComposeBoxInheritedWidget of(BuildContext context) { + final widget = context.dependOnInheritedWidgetOfExactType(); + assert(widget != null, 'No ComposeBoxInheritedWidget ancestor'); + return widget!; } } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 8799e8d192..3626d55ae4 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1,28 +1,30 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter/rendering.dart'; import 'package:html/dom.dart' as dom; -import 'package:intl/intl.dart'; +import 'package:intl/intl.dart' as intl; import '../api/core.dart'; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; -import '../model/avatar_url.dart'; -import '../model/binding.dart'; import '../model/content.dart'; import '../model/internal_link.dart'; +import 'actions.dart'; import 'code_block.dart'; import 'dialog.dart'; import 'icons.dart'; +import 'inset_shadow.dart'; +import 'katex.dart'; import 'lightbox.dart'; import 'message_list.dart'; import 'poll.dart'; +import 'scrolling.dart'; import 'store.dart'; import 'text.dart'; +import 'theme.dart'; /// A central place for styles for Zulip content (rendered Zulip Markdown). /// @@ -42,6 +44,7 @@ class ContentTheme extends ThemeExtension { colorDirectMentionBackground: const HSLColor.fromAHSL(0.2, 240, 0.7, 0.7).toColor(), colorGlobalTimeBackground: const HSLColor.fromAHSL(1, 0, 0, 0.93).toColor(), colorGlobalTimeBorder: const HSLColor.fromAHSL(1, 0, 0, 0.8).toColor(), + colorLink: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor(), colorMathBlockBorder: const HSLColor.fromAHSL(0.15, 240, 0.8, 0.5).toColor(), colorMessageMediaContainerBackground: const Color.fromRGBO(0, 0, 0, 0.03), colorPollNames: const HSLColor.fromAHSL(1, 0, 0, .45).toColor(), @@ -75,6 +78,7 @@ class ContentTheme extends ThemeExtension { colorDirectMentionBackground: const HSLColor.fromAHSL(0.25, 240, 0.52, 0.6).toColor(), colorGlobalTimeBackground: const HSLColor.fromAHSL(0.2, 0, 0, 0).toColor(), colorGlobalTimeBorder: const HSLColor.fromAHSL(0.4, 0, 0, 0).toColor(), + colorLink: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor(), // the same as light in Web colorMathBlockBorder: const HSLColor.fromAHSL(1, 240, 0.4, 0.4).toColor(), colorMessageMediaContainerBackground: const HSLColor.fromAHSL(0.03, 0, 0, 1).toColor(), colorPollNames: const HSLColor.fromAHSL(1, 236, .15, .7).toColor(), @@ -107,6 +111,7 @@ class ContentTheme extends ThemeExtension { required this.colorDirectMentionBackground, required this.colorGlobalTimeBackground, required this.colorGlobalTimeBorder, + required this.colorLink, required this.colorMathBlockBorder, required this.colorMessageMediaContainerBackground, required this.colorPollNames, @@ -139,6 +144,7 @@ class ContentTheme extends ThemeExtension { final Color colorDirectMentionBackground; final Color colorGlobalTimeBackground; final Color colorGlobalTimeBorder; + final Color colorLink; final Color colorMathBlockBorder; // TODO(#46) this won't be needed final Color colorMessageMediaContainerBackground; final Color colorPollNames; @@ -199,6 +205,7 @@ class ContentTheme extends ThemeExtension { Color? colorDirectMentionBackground, Color? colorGlobalTimeBackground, Color? colorGlobalTimeBorder, + Color? colorLink, Color? colorMathBlockBorder, Color? colorMessageMediaContainerBackground, Color? colorPollNames, @@ -221,6 +228,7 @@ class ContentTheme extends ThemeExtension { colorDirectMentionBackground: colorDirectMentionBackground ?? this.colorDirectMentionBackground, colorGlobalTimeBackground: colorGlobalTimeBackground ?? this.colorGlobalTimeBackground, colorGlobalTimeBorder: colorGlobalTimeBorder ?? this.colorGlobalTimeBorder, + colorLink: colorLink ?? this.colorLink, colorMathBlockBorder: colorMathBlockBorder ?? this.colorMathBlockBorder, colorMessageMediaContainerBackground: colorMessageMediaContainerBackground ?? this.colorMessageMediaContainerBackground, colorPollNames: colorPollNames ?? this.colorPollNames, @@ -250,6 +258,7 @@ class ContentTheme extends ThemeExtension { colorDirectMentionBackground: Color.lerp(colorDirectMentionBackground, other.colorDirectMentionBackground, t)!, colorGlobalTimeBackground: Color.lerp(colorGlobalTimeBackground, other.colorGlobalTimeBackground, t)!, colorGlobalTimeBorder: Color.lerp(colorGlobalTimeBorder, other.colorGlobalTimeBorder, t)!, + colorLink: Color.lerp(colorLink, other.colorLink, t)!, colorMathBlockBorder: Color.lerp(colorMathBlockBorder, other.colorMathBlockBorder, t)!, colorMessageMediaContainerBackground: Color.lerp(colorMessageMediaContainerBackground, other.colorMessageMediaContainerBackground, t)!, colorPollNames: Color.lerp(colorPollNames, other.colorPollNames, t)!, @@ -364,6 +373,7 @@ class BlockContentList extends StatelessWidget { ); return const SizedBox.shrink(); }(), + WebsitePreviewNode() => WebsitePreview(node: node), UnimplementedBlockContentNode() => Text.rich(_errorUnimplemented(node, context: context)), }; @@ -482,7 +492,7 @@ class ListNodeWidget extends StatelessWidget { final items = List.generate(node.items.length, (index) { final item = node.items[index]; String marker; - switch (node.style) { + switch (node) { // TODO(#161): different unordered marker styles at different levels of nesting // see: // https://html.spec.whatwg.org/multipage/rendering.html#lists @@ -490,37 +500,27 @@ class ListNodeWidget extends StatelessWidget { // TODO proper alignment of unordered marker; should be "• ", one space, // but that comes out too close to item; not sure what's fixing that // in a browser - case ListStyle.unordered: marker = "• "; break; - // TODO(#59) ordered lists starting not at 1 - case ListStyle.ordered: marker = "${index+1}. "; break; + case UnorderedListNode(): marker = "• "; break; + case OrderedListNode(:final start): marker = "${start + index}. "; break; } - return ListItemWidget(marker: marker, nodes: item); + return TableRow(children: [ + Align( + alignment: AlignmentDirectional.topEnd, + child: Text(marker)), + BlockContentList(nodes: item), + ]); }); + return Padding( padding: const EdgeInsets.only(top: 2, bottom: 5), - child: Column(children: items)); - } -} - -class ListItemWidget extends StatelessWidget { - const ListItemWidget({super.key, required this.marker, required this.nodes}); - - final String marker; - final List nodes; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: localizedTextBaseline(context), - children: [ - SizedBox( - width: 20, // TODO handle long numbers in

    , like https://github.com/zulip/zulip/pull/25063 - child: Align( - alignment: AlignmentDirectional.topEnd, child: Text(marker))), - Expanded(child: BlockContentList(nodes: nodes)), - ]); + child: Table( + defaultVerticalAlignment: TableCellVerticalAlignment.baseline, + textBaseline: localizedTextBaseline(context), + columnWidths: const { + 0: IntrinsicColumnWidth(), + 1: FlexColumnWidth(), + }, + children: items)); } } @@ -650,6 +650,7 @@ class MessageImage extends StatelessWidget { Navigator.of(context).push(getImageLightboxRoute( context: context, message: message, + messageImageContext: context, src: resolvedSrcUrl, thumbnailUrl: resolvedThumbnailUrl, originalWidth: node.originalWidth, @@ -658,7 +659,7 @@ class MessageImage extends StatelessWidget { child: node.loading ? const CupertinoActivityIndicator() : resolvedSrcUrl == null ? null : LightboxHero( - message: message, + messageImageContext: context, src: resolvedSrcUrl, child: RealmContentNetworkImage( resolvedThumbnailUrl ?? resolvedSrcUrl, @@ -797,45 +798,129 @@ class _CodeBlockContainer extends StatelessWidget { } } -class SingleChildScrollViewWithScrollbar extends StatefulWidget { - const SingleChildScrollViewWithScrollbar( - {super.key, required this.scrollDirection, required this.child}); +class MathBlock extends StatelessWidget { + const MathBlock({super.key, required this.node}); - final Axis scrollDirection; - final Widget child; + final MathBlockNode node; @override - State createState() => - _SingleChildScrollViewWithScrollbarState(); -} + Widget build(BuildContext context) { + final contentTheme = ContentTheme.of(context); -class _SingleChildScrollViewWithScrollbarState - extends State { - final ScrollController controller = ScrollController(); + final nodes = node.nodes; + if (nodes == null) { + return _CodeBlockContainer( + borderColor: contentTheme.colorMathBlockBorder, + child: Text.rich(TextSpan( + style: contentTheme.codeBlockTextStyles.plain, + children: [TextSpan(text: node.texSource)]))); + } - @override - Widget build(BuildContext context) { - return Scrollbar( - controller: controller, - child: SingleChildScrollView( - controller: controller, - scrollDirection: widget.scrollDirection, - child: widget.child)); + return Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: SingleChildScrollViewWithScrollbar( + scrollDirection: Axis.horizontal, + child: KatexWidget( + textStyle: ContentTheme.of(context).textStylePlainParagraph, + nodes: nodes)))); } } -class MathBlock extends StatelessWidget { - const MathBlock({super.key, required this.node}); +class WebsitePreview extends StatelessWidget { + const WebsitePreview({super.key, required this.node}); - final MathBlockNode node; + final WebsitePreviewNode node; @override Widget build(BuildContext context) { - return _CodeBlockContainer( - borderColor: ContentTheme.of(context).colorMathBlockBorder, - child: Text.rich(TextSpan( - style: ContentTheme.of(context).codeBlockTextStyles.plain, - children: [TextSpan(text: node.texSource)]))); + final store = PerAccountStoreWidget.of(context); + final resolvedImageSrcUrl = store.tryResolveUrl(node.imageSrcUrl); + final isSmallWidth = MediaQuery.sizeOf(context).width <= 576; + + // On Web on larger width viewports, the title and description container's + // width is constrained using `max-width: calc(100% - 115px)`, we do not + // follow the same here for potential benefits listed here: + // https://github.com/zulip/zulip-flutter/pull/1049#discussion_r1915740997 + final titleAndDescription = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (node.title != null) + GestureDetector( + onTap: () => _launchUrl(context, node.hrefUrl), + child: Text(node.title!, + style: TextStyle( + fontSize: 1.2 * kBaseFontSize, + // Web uses `line-height: normal` for title. MDN docs for it: + // https://developer.mozilla.org/en-US/docs/Web/CSS/line-height#normal + // says actual value depends on user-agent, and default value + // can be roughly 1.2 (unitless). So, use the same here. + height: 1.2, + color: ContentTheme.of(context).colorLink))), + if (node.description != null) + Container( + padding: const EdgeInsets.only(top: 3), + constraints: const BoxConstraints(maxWidth: 500), + child: Text(node.description!)), + ]); + + final clippedTitleAndDescription = Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: InsetShadowBox( + bottom: 8, + // TODO(#488) use different color for non-message contexts + // TODO(#647) use different color for highlighted messages + // TODO(#681) use different color for DM messages + color: DesignVariables.of(context).bgMessageRegular, + child: ClipRect( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: 80), + child: OverflowBox( + maxHeight: double.infinity, + alignment: AlignmentDirectional.topStart, + fit: OverflowBoxFit.deferToChild, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: titleAndDescription)))))); + + final image = resolvedImageSrcUrl == null ? null + : GestureDetector( + onTap: () => _launchUrl(context, node.hrefUrl), + child: RealmContentNetworkImage( + resolvedImageSrcUrl, + fit: BoxFit.cover)); + + final result = isSmallWidth + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 15, + children: [ + if (image != null) + SizedBox(height: 110, width: double.infinity, child: image), + clippedTitleAndDescription, + ]) + : Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (image != null) + SizedBox.square(dimension: 80, child: image), + Flexible(child: clippedTitleAndDescription), + ]); + + return Padding( + // TODO(?) Web has a bottom margin `--markdown-interelement-space-px` + // around the `message_embed` container, which is calculated here: + // https://github.com/zulip/zulip/blob/d28f7d86223bab4f11629637d4237381943f6fc1/web/src/information_density.ts#L80-L102 + // But for now we use a static value of 6.72px instead which is the + // default in the web client, see discussion: + // https://github.com/zulip/zulip-flutter/pull/1049#discussion_r1915747908 + padding: const EdgeInsets.only(bottom: 6.72), + child: Container( + height: !isSmallWidth ? 90 : null, + decoration: const BoxDecoration( + border: BorderDirectional(start: BorderSide( + // Web has the same color in light and dark mode. + color: Color(0xffededed), width: 3))), + padding: const EdgeInsets.all(5), + child: result)); } } @@ -1029,8 +1114,7 @@ class _InlineContentBuilder { assert(recognizer != null); _pushRecognizer(recognizer); final result = _buildNodes(node.nodes, - // Web has the same color in light and dark mode. - style: TextStyle(color: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor())); + style: TextStyle(color: ContentTheme.of(_context!).colorLink)); _popRecognizer(); return result; @@ -1043,19 +1127,23 @@ class _InlineContentBuilder { case UnicodeEmojiNode(): return TextSpan(text: node.emojiUnicode, recognizer: _recognizer, - style: widget.style - .merge(ContentTheme.of(_context!).textStyleEmoji)); + style: ContentTheme.of(_context!).textStyleEmoji); case ImageEmojiNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, child: MessageImageEmoji(node: node)); case MathInlineNode(): - return TextSpan( - style: widget.style - .merge(ContentTheme.of(_context!).textStyleInlineMath) - .apply(fontSizeFactor: kInlineCodeFontSizeFactor), - children: [TextSpan(text: node.texSource)]); + final nodes = node.nodes; + return nodes == null + ? TextSpan( + style: ContentTheme.of(_context!).textStyleInlineMath + .copyWith(fontSize: widget.style.fontSize! * kInlineCodeFontSizeFactor), + children: [TextSpan(text: node.texSource)]) + : WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: KatexWidget(textStyle: widget.style, nodes: nodes)); case GlobalTimeNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, @@ -1091,11 +1179,9 @@ class _InlineContentBuilder { // TODO `code`: find equivalent of web's `unicode-bidi: embed; direction: ltr` return _buildNodes( - style: widget.style - .merge(ContentTheme.of(_context!).textStyleInlineCode) - .apply(fontSizeFactor: kInlineCodeFontSizeFactor), - node.nodes, - ); + style: ContentTheme.of(_context!).textStyleInlineCode + .copyWith(fontSize: widget.style.fontSize! * kInlineCodeFontSizeFactor), + node.nodes); // Another fun solution -- we can in fact have a border! Like so: // TextStyle( @@ -1214,13 +1300,26 @@ class GlobalTime extends StatelessWidget { final GlobalTimeNode node; final TextStyle ambientTextStyle; - static final _dateFormat = DateFormat('EEE, MMM d, y, h:mm a'); // TODO(i18n): localize date + static final _format12 = + intl.DateFormat('EEE, MMM d, y').addPattern('h:mm aa', ', '); + static final _format24 = + intl.DateFormat('EEE, MMM d, y').addPattern('Hm', ', '); + static final _formatLocaleDefault = + intl.DateFormat('EEE, MMM d, y').addPattern('jm', ', '); @override Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final twentyFourHourTimeMode = store.userSettings.twentyFourHourTime; // Design taken from css for `.rendered_markdown & time` in web, // see zulip:web/styles/rendered_markdown.css . - final text = _dateFormat.format(node.datetime.toLocal()); + // TODO(i18n): localize; see plan with ffi in #45 + final format = switch (twentyFourHourTimeMode) { + TwentyFourHourTimeMode.twelveHour => _format12, + TwentyFourHourTimeMode.twentyFourHour => _format24, + TwentyFourHourTimeMode.localeDefault => _formatLocaleDefault, + }; + final text = format.format(node.datetime.toLocal()); final contentTheme = ContentTheme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), @@ -1318,49 +1417,27 @@ 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: zulipLocalizations.errorCouldNotOpenLinkTitle, - message: [ - zulipLocalizations.errorCouldNotOpenLink(urlString), - if (message != null) message, - ].join("\n\n")); - } - final store = PerAccountStoreWidget.of(context); final url = store.tryResolveUrl(urlString); if (url == null) { // TODO(log) - showError(context, null); - return; - } - - final internalNarrow = parseInternalLink(url, store); - if (internalNarrow != null) { - unawaited(Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: internalNarrow))); + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorCouldNotOpenLinkTitle, + message: zulipLocalizations.errorCouldNotOpenLink(urlString)); return; } - bool launched = false; - String? errorMessage; - try { - launched = await ZulipBinding.instance.launchUrl(url, - mode: switch (defaultTargetPlatform) { - // On iOS we prefer LaunchMode.externalApplication because (for - // HTTP URLs) LaunchMode.platformDefault uses SFSafariViewController, - // which gives an awkward UX as described here: - // https://chat.zulip.org/#narrow/stream/48-mobile/topic/in-app.20browser/near/1169118 - TargetPlatform.iOS => UrlLaunchMode.externalApplication, - _ => UrlLaunchMode.platformDefault, - }); - } on PlatformException catch (e) { - errorMessage = e.message; - } - if (!launched) { // TODO(log) - if (!context.mounted) return; - showError(context, errorMessage); + final internalLink = parseInternalLink(url, store); + assert(internalLink == null || internalLink.realmUrl == store.realmUrl); + switch (internalLink) { + case NarrowLink(): + unawaited(Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: internalLink.narrow, + initAnchorMessageId: internalLink.nearMessageId))); + + case null: + await PlatformActions.launchUrl(context, url); } } @@ -1469,96 +1546,6 @@ class RealmContentNetworkImage extends StatelessWidget { } } -/// A rounded square with size [size] showing a user's avatar. -class Avatar extends StatelessWidget { - const Avatar({ - super.key, - required this.userId, - required this.size, - required this.borderRadius, - }); - - final int userId; - final double size; - final double borderRadius; - - @override - Widget build(BuildContext context) { - return AvatarShape( - size: size, - borderRadius: borderRadius, - child: AvatarImage(userId: userId, size: size)); - } -} - -/// The appropriate avatar image for a user ID. -/// -/// If the user isn't found, gives a [SizedBox.shrink]. -/// -/// Wrap this with [AvatarShape]. -class AvatarImage extends StatelessWidget { - const AvatarImage({ - super.key, - required this.userId, - required this.size, - }); - - final int userId; - final double size; - - @override - Widget build(BuildContext context) { - final store = PerAccountStoreWidget.of(context); - final user = store.users[userId]; - - if (user == null) { // TODO(log) - return const SizedBox.shrink(); - } - - final resolvedUrl = switch (user.avatarUrl) { - null => null, // TODO(#255): handle computing gravatars - var avatarUrl => store.tryResolveUrl(avatarUrl), - }; - - if (resolvedUrl == null) { - return const SizedBox.shrink(); - } - - final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: resolvedUrl); - final physicalSize = (MediaQuery.devicePixelRatioOf(context) * size).ceil(); - - return RealmContentNetworkImage( - avatarUrl.get(physicalSize), - filterQuality: FilterQuality.medium, - fit: BoxFit.cover, - ); - } -} - -/// A rounded square shape, to wrap an [AvatarImage] or similar. -class AvatarShape extends StatelessWidget { - const AvatarShape({ - super.key, - required this.size, - required this.borderRadius, - required this.child, - }); - - final double size; - final double borderRadius; - final Widget child; - - @override - Widget build(BuildContext context) { - return SizedBox.square( - dimension: size, - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(borderRadius)), - clipBehavior: Clip.antiAlias, - child: child)); - } -} - // // Small helpers. // diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 1b1c1d4713..c5e2e6e562 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -1,81 +1,219 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/settings.dart'; +import 'actions.dart'; +import 'app.dart'; +import 'content.dart'; +import 'store.dart'; -Widget _dialogActionText(String text) { - return Text( - text, - - // As suggested by - // https://api.flutter.dev/flutter/material/AlertDialog/actions.html : - // > It is recommended to set the Text.textAlign to TextAlign.end - // > for the Text within the TextButton, so that buttons whose - // > labels wrap to an extra line align with the overall - // > OverflowBar's alignment within the dialog. - textAlign: TextAlign.end, - ); +/// A platform-appropriate action for [AlertDialog.adaptive]'s [actions] param. +Widget _adaptiveAction({required VoidCallback onPressed, required String text}) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return TextButton( + onPressed: onPressed, + child: Text( + text, + // As suggested by + // https://api.flutter.dev/flutter/material/AlertDialog/actions.html : + // > It is recommended to set the Text.textAlign to TextAlign.end + // > for the Text within the TextButton, so that buttons whose + // > labels wrap to an extra line align with the overall + // > OverflowBar's alignment within the dialog. + textAlign: TextAlign.end)); + + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return CupertinoDialogAction(onPressed: onPressed, child: Text(text)); + } +} + +/// Platform-appropriate content for [AlertDialog.adaptive]'s [content] param. +Widget? _adaptiveContent(Widget? content) { + if (content == null) return null; + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + // [AlertDialog] does not create a [SingleChildScrollView]; + // callers are asked to do that themselves, to handle long content. + return SingleChildScrollView(child: content); + + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // A [SingleChildScrollView] (wrapping both title and content) is already + // created by [CupertinoAlertDialog]. + return content; + } } /// Tracks the status of a dialog, in being still open or already closed. /// +/// Use [T] to identify the outcome of the interaction: +/// - Pass `void` for an informational dialog with just the option to dismiss. +/// - For confirmation dialogs with an option to dismiss +/// plus an option to proceed with an action, pass `bool`. +/// The action button should pass true for Navigator.pop's `result` argument. +/// - For dialogs with an option to dismiss plus multiple other options, +/// pass a custom enum. +/// For the latter two cases, a cancel button should call Navigator.pop +/// with null for the `result` argument, to match what Flutter does +/// when you dismiss the dialog by tapping outside its area. +/// /// See also: /// * [showDialog], whose return value this class is intended to wrap. -class DialogStatus { - const DialogStatus(this.closed); +class DialogStatus { + const DialogStatus(this.result); /// Resolves when the dialog is closed. - final Future closed; + /// + /// If this completes with null, the dialog was dismissed. + /// Otherwise, completes with a [T] identifying the interaction's outcome. + /// + /// See, e.g., [showSuggestedActionDialog]. + final Future result; } -/// Displays an [AlertDialog] with a dismiss button. +/// Displays an [AlertDialog] with a dismiss button +/// and optional "Learn more" button. /// -/// The [DialogStatus.closed] field of the return value can be used +/// The [DialogStatus.result] field of the return value can be used /// for waiting for the dialog to be closed. +/// +/// Prose in [message] should have final punctuation: +/// https://github.com/zulip/zulip-flutter/pull/1498#issuecomment-2853578577 +/// +/// The context argument should be a descendant of the app's main [Navigator]. // This API is inspired by [ScaffoldManager.showSnackBar]. We wrap // [showDialog]'s return value, a [Future], inside [DialogStatus] // whose documentation can be accessed. This helps avoid confusion when -// intepreting the meaning of the [Future]. -DialogStatus showErrorDialog({ +// interpreting the meaning of the [Future]. +DialogStatus showErrorDialog({ required BuildContext context, required String title, String? message, + Uri? learnMoreButtonUrl, }) { final zulipLocalizations = ZulipLocalizations.of(context); final future = showDialog( context: context, - builder: (BuildContext context) => AlertDialog( + builder: (BuildContext context) => AlertDialog.adaptive( title: Text(title), - content: message != null ? SingleChildScrollView(child: Text(message)) : null, + content: message != null ? _adaptiveContent(Text(message)) : null, actions: [ - TextButton( + if (learnMoreButtonUrl != null) + _adaptiveAction( + onPressed: () => PlatformActions.launchUrl(context, learnMoreButtonUrl), + text: zulipLocalizations.errorDialogLearnMore), + _adaptiveAction( onPressed: () => Navigator.pop(context), - child: _dialogActionText(zulipLocalizations.errorDialogContinue)), + text: zulipLocalizations.errorDialogContinue), ])); return DialogStatus(future); } -void showSuggestedActionDialog({ +/// Displays an alert dialog with a cancel button and an action button. +/// +/// The [DialogStatus.result] Future gives true if the action button was tapped. +/// If the dialog was canceled, +/// either with the cancel button or by tapping outside the dialog's area, +/// it completes with null. +/// +/// The context argument should be a descendant of the app's main [Navigator]. +DialogStatus showSuggestedActionDialog({ required BuildContext context, required String title, required String message, required String? actionButtonText, - required VoidCallback onActionButtonPress, }) { final zulipLocalizations = ZulipLocalizations.of(context); - showDialog( + final future = showDialog( context: context, - builder: (BuildContext context) => AlertDialog( + builder: (BuildContext context) => AlertDialog.adaptive( title: Text(title), - content: SingleChildScrollView(child: Text(message)), + content: _adaptiveContent(Text(message)), actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: _dialogActionText(zulipLocalizations.dialogCancel)), - TextButton( - onPressed: () { - onActionButtonPress(); - Navigator.pop(context); - }, - child: _dialogActionText(actionButtonText ?? zulipLocalizations.dialogContinue)), + _adaptiveAction( + onPressed: () => Navigator.pop(context, null), + text: zulipLocalizations.dialogCancel), + _adaptiveAction( + onPressed: () => Navigator.pop(context, true), + text: actionButtonText ?? zulipLocalizations.dialogContinue), ])); + return DialogStatus(future); +} + +/// A brief dialog box welcoming the user to this new Zulip app, +/// shown upon upgrading from the legacy app. +class UpgradeWelcomeDialog extends StatelessWidget { + const UpgradeWelcomeDialog._(); + + static void maybeShow() async { + final navigator = await ZulipApp.navigator; + final context = navigator.context; + assert(context.mounted); + if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that + + final globalSettings = GlobalStoreWidget.settingsOf(context); + switch (globalSettings.legacyUpgradeState) { + case LegacyUpgradeState.noLegacy: + // This install didn't replace the legacy app. + return; + + case LegacyUpgradeState.unknown: + // Not clear if this replaced the legacy app; + // skip the dialog that would assume it had. + // TODO(log) + return; + + case LegacyUpgradeState.found: + case LegacyUpgradeState.migrated: + // This install replaced the legacy app. + // Show the dialog, if we haven't already. + if (globalSettings.getBool(BoolGlobalSetting.upgradeWelcomeDialogShown)) { + return; + } + } + + final future = showDialog( + context: context, + builder: (context) => UpgradeWelcomeDialog._()); + + await future; // Wait for the dialog to be dismissed. + + await globalSettings.setBool(BoolGlobalSetting.upgradeWelcomeDialogShown, true); + } + + static const String _announcementUrl = + 'https://blog.zulip.com/flutter-mobile-app-launch'; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return AlertDialog.adaptive( + title: Text(zulipLocalizations.upgradeWelcomeDialogTitle), + content: _adaptiveContent( + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(zulipLocalizations.upgradeWelcomeDialogMessage), + GestureDetector( + onTap: () => PlatformActions.launchUrl(context, + Uri.parse(_announcementUrl)), + child: Text( + style: TextStyle(color: ContentTheme.of(context).colorLink), + zulipLocalizations.upgradeWelcomeDialogLinkText)), + ])), + actions: [ + _adaptiveAction( + onPressed: () => Navigator.pop(context), + text: zulipLocalizations.upgradeWelcomeDialogDismiss) + ]); + } } diff --git a/lib/widgets/emoji.dart b/lib/widgets/emoji.dart index dafeb7b6d8..ba26af3450 100644 --- a/lib/widgets/emoji.dart +++ b/lib/widgets/emoji.dart @@ -9,7 +9,6 @@ class UnicodeEmojiWidget extends StatelessWidget { super.key, required this.emojiDisplay, required this.size, - required this.notoColorEmojiTextSize, this.textScaler = TextScaler.noScaling, }); @@ -20,12 +19,6 @@ class UnicodeEmojiWidget extends StatelessWidget { /// This will be scaled by [textScaler]. final double size; - /// A font size that, with Noto Color Emoji and our line-height config, - /// causes a Unicode emoji to occupy a square of size [size] in the layout. - /// - /// This has to be determined experimentally, as far as we know. - final double notoColorEmojiTextSize; - /// The text scaler to apply to [size]. /// /// Defaults to [TextScaler.noScaling]. @@ -38,6 +31,15 @@ class UnicodeEmojiWidget extends StatelessWidget { case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: + // A font size that, with Noto Color Emoji and our line-height + // config (the use of `forceStrutHeight: true`), causes a Unicode emoji + // to occupy a square of size [size] in the layout. + // + // Determined experimentally: + // + // + final double notoColorEmojiTextSize = size * (14.5 / 17); + return Text( textScaler: textScaler, style: TextStyle( @@ -45,7 +47,10 @@ class UnicodeEmojiWidget extends StatelessWidget { fontSize: notoColorEmojiTextSize, ), strutStyle: StrutStyle( - fontSize: notoColorEmojiTextSize, forceStrutHeight: true), + fontSize: notoColorEmojiTextSize, + // Responsible for keeping the line height constant, even + // with ambient DefaultTextStyle. + forceStrutHeight: true), emojiDisplay.emojiUnicode); case TargetPlatform.iOS: @@ -74,7 +79,11 @@ class UnicodeEmojiWidget extends StatelessWidget { style: TextStyle( fontFamily: 'Apple Color Emoji', fontSize: size), - strutStyle: StrutStyle(fontSize: size, forceStrutHeight: true), + strutStyle: StrutStyle( + fontSize: size, + // Responsible for keeping the line height constant, even + // with ambient DefaultTextStyle. + forceStrutHeight: true), emojiDisplay.emojiUnicode)), ]); } @@ -89,6 +98,7 @@ class ImageEmojiWidget extends StatelessWidget { required this.size, this.textScaler = TextScaler.noScaling, this.errorBuilder, + this.neverAnimate = false, }); final ImageEmojiDisplay emojiDisplay; @@ -105,13 +115,20 @@ class ImageEmojiWidget extends StatelessWidget { final ImageErrorWidgetBuilder? errorBuilder; + /// Whether to show an animated emoji in its still (non-animated) variant + /// only, even if device settings permit animation. + /// + /// Defaults to false. + final bool neverAnimate; + @override Widget build(BuildContext context) { // Some people really dislike animated emoji. final doNotAnimate = + neverAnimate // From reading code, this doesn't actually get set on iOS: // https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408522293 - MediaQuery.disableAnimationsOf(context) + || MediaQuery.disableAnimationsOf(context) || (defaultTargetPlatform == TargetPlatform.iOS // TODO(upstream) On iOS 17+ (new in 2023), there's a more closely // relevant setting than "reduce motion". It's called "auto-play diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index de3e2baa26..b3117e465e 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -1,3 +1,6 @@ +import 'dart:ui'; + +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../api/exception.dart'; @@ -6,13 +9,18 @@ import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/autocomplete.dart'; import '../model/emoji.dart'; +import '../model/store.dart'; +import 'action_sheet.dart'; import 'color.dart'; import 'dialog.dart'; import 'emoji.dart'; import 'inset_shadow.dart'; +import 'page.dart'; +import 'profile.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'user.dart'; /// Emoji-reaction styles that differ between light and dark themes. class EmojiReactionTheme extends ThemeExtension { @@ -120,15 +128,22 @@ class ReactionChipsList extends StatelessWidget { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); - final displayEmojiReactionUsers = store.userSettings?.displayEmojiReactionUsers ?? false; + final displayEmojiReactionUsers = store.userSettings.displayEmojiReactionUsers ?? false; final showNames = displayEmojiReactionUsers && reactions.total <= 3; - return Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, + Widget result = Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: reactions.aggregated.map((reactionVotes) => ReactionChip( showName: showNames, messageId: messageId, reactionWithVotes: reactionVotes), ).toList()); + + return Semantics( + label: zulipLocalizations.reactionChipsLabel, + container: true, + explicitChildNodes: true, + child: result); } } @@ -144,6 +159,23 @@ class ReactionChip extends StatelessWidget { required this.reactionWithVotes, }); + // Linear in the number of voters (of course); + // best to avoid calling this unless we know there are few voters. + String _voterNames(PerAccountStore store, ZulipLocalizations zulipLocalizations) { + final selfUserId = store.selfUserId; + final userIds = reactionWithVotes.userIds; + final result = []; + if (userIds.contains(selfUserId)) { + // Putting "You" first is helpful when this is used in the semantics label. + result.add(zulipLocalizations.reactedEmojiSelfUser); + } + result.addAll(userIds.whereNot((userId) => userId == selfUserId).map(store.userDisplayName)); + // TODO(i18n): List formatting, like you can do in JavaScript: + // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Shu']) + // // 'Chris、Greg、Alya、Shu' + return result.join(', '); + } + @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); @@ -155,16 +187,23 @@ class ReactionChip extends StatelessWidget { final userIds = reactionWithVotes.userIds; final selfVoted = userIds.contains(store.selfUserId); - final label = showName - // TODO(i18n): List formatting, like you can do in JavaScript: - // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Shu']) - // // 'Chris、Greg、Alya、Shu' - ? userIds.map((id) { - return id == store.selfUserId + final String label; + final String semanticsLabel; + if (showName) { + final names = _voterNames(store, zulipLocalizations); + label = names; + semanticsLabel = zulipLocalizations.reactionChipLabel(emojiName, names); + } else { + final count = userIds.length; + final countStr = count.toString(); // TODO(i18n) number formatting? + label = countStr; + semanticsLabel = zulipLocalizations.reactionChipLabel(emojiName, + selfVoted + ? count == 1 ? zulipLocalizations.reactedEmojiSelfUser - : store.users[id]?.fullName ?? zulipLocalizations.unknownUserName; - }).join(', ') - : userIds.length.toString(); + : zulipLocalizations.reactionChipVotesYouAndOthers(count - 1) + : countStr); + } final reactionTheme = EmojiReactionTheme.of(context); final borderColor = selfVoted ? reactionTheme.borderSelected : reactionTheme.borderUnselected; @@ -194,74 +233,81 @@ class ReactionChip extends StatelessWidget { emojiDisplay: emojiDisplay, selected: selfVoted), }; - return Tooltip( - // TODO(#434): Semantics with eg "Reaction: ; you and N others: " - excludeFromSemantics: true, - message: emojiName, - child: Material( - color: backgroundColor, - shape: shape, - child: InkWell( - customBorder: shape, - splashColor: splashColor, - highlightColor: highlightColor, - onTap: () { - (selfVoted ? removeReaction : addReaction).call(store.connection, - messageId: messageId, - reactionType: reactionType, - emojiCode: emojiCode, - emojiName: emojiName, - ); - }, - child: Padding( - // 1px of this padding accounts for the border, which Flutter - // just paints without changing size. - padding: const EdgeInsetsDirectional.fromSTEB(4, 3, 5, 3), - child: LayoutBuilder( - builder: (context, constraints) { - final maxRowWidth = constraints.maxWidth; - // To give text emojis some room so they need fewer line breaks - // when the label is long. - // TODO(#433) This is a bit overzealous. The shorter width - // won't be necessary when the text emoji is very short, or - // in the near-universal case of small, square emoji (i.e. - // Unicode and image emoji). But it's not simple to recognize - // those cases here: we don't know at this point whether we'll - // be showing a text emoji, because we use that for various - // error conditions (including when an image fails to load, - // which we learn about especially late). - final maxLabelWidth = (maxRowWidth - 6) * 0.75; // 6 is padding - - final labelScaler = _labelTextScalerClamped(context); - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // So text-emoji chips are at least as tall as square-emoji - // ones (probably a good thing). - SizedBox(height: _squareEmojiScalerClamped(context).scale(_squareEmojiSize)), - Flexible( // [Flexible] to let text emojis expand if they can - child: Padding(padding: const EdgeInsets.symmetric(horizontal: 3), - child: emoji)), - Padding(padding: const EdgeInsets.symmetric(horizontal: 3), - child: Container( - constraints: BoxConstraints(maxWidth: maxLabelWidth), - child: Text( - textWidthBasis: TextWidthBasis.longestLine, - textScaler: labelScaler, - style: TextStyle( - fontSize: (14 * 0.90), - letterSpacing: proportionalLetterSpacing(context, - kButtonTextLetterSpacingProportion, - baseFontSize: (14 * 0.90), - textScaler: labelScaler), - height: 13 / (14 * 0.90), - color: labelColor, - ).merge(weightVariableTextStyle(context, - wght: selfVoted ? 600 : null)), - label))), - ]); - }))))); + Widget result = Material( + color: backgroundColor, + shape: shape, + child: InkWell( + customBorder: shape, + splashColor: splashColor, + highlightColor: highlightColor, + onLongPress: () { + showViewReactionsSheet(PageRoot.contextOf(context), + messageId: messageId, + initialReactionType: reactionType, + initialEmojiCode: emojiCode); + }, + onTap: () { + (selfVoted ? removeReaction : addReaction).call(store.connection, + messageId: messageId, + reactionType: reactionType, + emojiCode: emojiCode, + emojiName: emojiName, + ); + }, + child: Padding( + // 1px of this padding accounts for the border, which Flutter + // just paints without changing size. + padding: const EdgeInsetsDirectional.fromSTEB(4, 3, 5, 3), + child: LayoutBuilder( + builder: (context, constraints) { + final maxRowWidth = constraints.maxWidth; + // To give text emojis some room so they need fewer line breaks + // when the label is long. + // TODO(#433) This is a bit overzealous. The shorter width + // won't be necessary when the text emoji is very short, or + // in the near-universal case of small, square emoji (i.e. + // Unicode and image emoji). But it's not simple to recognize + // those cases here: we don't know at this point whether we'll + // be showing a text emoji, because we use that for various + // error conditions (including when an image fails to load, + // which we learn about especially late). + final maxLabelWidth = (maxRowWidth - 6) * 0.75; // 6 is padding + + final labelScaler = _labelTextScalerClamped(context); + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // So text-emoji chips are at least as tall as square-emoji + // ones (probably a good thing). + SizedBox(height: _squareEmojiScalerClamped(context).scale(_squareEmojiSize)), + Flexible( // [Flexible] to let text emojis expand if they can + child: Padding(padding: const EdgeInsets.symmetric(horizontal: 3), + child: emoji)), + Padding(padding: const EdgeInsets.symmetric(horizontal: 3), + child: Container( + constraints: BoxConstraints(maxWidth: maxLabelWidth), + child: Text( + textWidthBasis: TextWidthBasis.longestLine, + textScaler: labelScaler, + style: TextStyle( + fontSize: (14 * 0.90), + letterSpacing: proportionalLetterSpacing(context, + kButtonTextLetterSpacingProportion, + baseFontSize: (14 * 0.90), + textScaler: labelScaler), + height: 13 / (14 * 0.90), + color: labelColor, + ).merge(weightVariableTextStyle(context, + wght: selfVoted ? 600 : null)), + label))), + ]); + })))); + + return Semantics( + label: semanticsLabel, + container: true, + child: ExcludeSemantics(child: result)); } } @@ -270,13 +316,6 @@ class ReactionChip extends StatelessWidget { /// Should be scaled by [_emojiTextScalerClamped]. const _squareEmojiSize = 17.0; -/// A font size that, with Noto Color Emoji and our line-height config, -/// causes a Unicode emoji to occupy a [_squareEmojiSize] square in the layout. -/// -/// Determined experimentally: -/// -const _notoColorEmojiTextSize = 14.5; - /// A [TextScaler] that limits Unicode and image emojis' max scale factor, /// to leave space for the label. /// @@ -306,7 +345,6 @@ class _UnicodeEmoji extends StatelessWidget { Widget build(BuildContext context) { return UnicodeEmojiWidget( size: _squareEmojiSize, - notoColorEmojiTextSize: _notoColorEmojiTextSize, textScaler: _squareEmojiScalerClamped(context), emojiDisplay: emojiDisplay); } @@ -330,7 +368,7 @@ class _ImageEmoji extends StatelessWidget { // Unicode and text emoji get scaled; it would look weird if image emoji didn't. textScaler: _squareEmojiScalerClamped(context), emojiDisplay: emojiDisplay, - errorBuilder: (context, _, __) => _TextEmoji( + errorBuilder: (context, _, _) => _TextEmoji( emojiDisplay: TextEmojiDisplay(emojiName: emojiName), selected: selected), ); } @@ -406,44 +444,39 @@ Future doAddOrRemoveReaction({ } /// Opens a browsable and searchable emoji picker bottom sheet. -void showEmojiPickerSheet({ +Future showEmojiPickerSheet({ required BuildContext pageContext, - required Message message, -}) { +}) async { final store = PerAccountStoreWidget.of(pageContext); - showModalBottomSheet( + return showModalBottomSheet( context: pageContext, // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect // on my iPhone 13 Pro but is marked as "much slower": // https://api.flutter.dev/flutter/dart-ui/Clip.html clipBehavior: Clip.antiAlias, + // The bottom inset is left for [builder] to handle; + // see [EmojiPicker] and its [CustomScrollView] for how we do that. useSafeArea: true, isScrollControlled: true, builder: (BuildContext context) { - return SafeArea( - child: Padding( - // By default, when software keyboard is opened, the ListView - // expands behind the software keyboard — resulting in some - // list entries being covered by the keyboard. Add explicit - // bottom padding the size of the keyboard, which fixes this. - padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), - // For _EmojiPickerItem, and RealmContentNetworkImage used in ImageEmojiWidget. - child: PerAccountStoreWidget( - accountId: store.accountId, - child: EmojiPicker(pageContext: pageContext, message: message)))); + return Padding( + // By default, when software keyboard is opened, the ListView + // expands behind the software keyboard — resulting in some + // list entries being covered by the keyboard. Add explicit + // bottom padding the size of the keyboard, which fixes this. + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + // For _EmojiPickerItem, and RealmContentNetworkImage used in ImageEmojiWidget. + child: PerAccountStoreWidget( + accountId: store.accountId, + child: EmojiPicker(pageContext: pageContext))); }); } @visibleForTesting class EmojiPicker extends StatefulWidget { - const EmojiPicker({ - super.key, - required this.pageContext, - required this.message, - }); + const EmojiPicker({super.key, required this.pageContext}); final BuildContext pageContext; - final Message message; @override State createState() => _EmojiPickerState(); @@ -521,23 +554,26 @@ class _EmojiPickerState extends State with PerAccountStoreAwareStat padding: const EdgeInsets.symmetric(horizontal: 8), splashFactory: NoSplash.splashFactory, foregroundColor: designVariables.contextMenuItemText, - ).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) => - states.contains(WidgetState.pressed) - ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) - : Colors.transparent)), - child: Text(zulipLocalizations.dialogClose, + overlayColor: Colors.transparent, + ), + child: Text(zulipLocalizations.dialogCancel, style: const TextStyle(fontSize: 20, height: 30 / 20))), ])), Expanded(child: InsetShadowBox( - top: 8, bottom: 8, + top: 8, color: designVariables.bgContextMenu, - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _resultsToDisplay.length, - itemBuilder: (context, i) => EmojiPickerListEntry( - pageContext: widget.pageContext, - emoji: _resultsToDisplay[i].candidate, - message: widget.message)))), + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: EdgeInsets.only(top: 8), + sliver: SliverSafeArea( + minimum: EdgeInsets.only(bottom: 8), + sliver: SliverList.builder( + itemCount: _resultsToDisplay.length, + itemBuilder: (context, i) => EmojiPickerListEntry( + pageContext: widget.pageContext, + emoji: _resultsToDisplay[i].candidate)))), + ]))), ]); } } @@ -548,28 +584,15 @@ class EmojiPickerListEntry extends StatelessWidget { super.key, required this.pageContext, required this.emoji, - required this.message, }); final BuildContext pageContext; final EmojiCandidate emoji; - final Message message; static const _emojiSize = 24.0; - static const _notoColorEmojiTextSize = 20.1; void _onPressed() { - // Dismiss the enclosing action sheet immediately, - // for swift UI feedback that the user's selection was received. - Navigator.pop(pageContext); - - doAddOrRemoveReaction( - context: pageContext, - doRemoveReaction: false, - messageId: message.id, - emoji: emoji, - errorDialogTitle: - ZulipLocalizations.of(pageContext).errorReactionAddingFailedTitle); + Navigator.pop(pageContext, emoji); } @override @@ -583,9 +606,7 @@ class EmojiPickerListEntry extends StatelessWidget { ImageEmojiDisplay() => ImageEmojiWidget(size: _emojiSize, emojiDisplay: emojiDisplay), UnicodeEmojiDisplay() => - UnicodeEmojiWidget( - size: _emojiSize, notoColorEmojiTextSize: _notoColorEmojiTextSize, - emojiDisplay: emojiDisplay), + UnicodeEmojiWidget(size: _emojiSize, emojiDisplay: emojiDisplay), TextEmojiDisplay() => null, // The text is already shown separately. }; @@ -618,3 +639,426 @@ class EmojiPickerListEntry extends StatelessWidget { )); } } + +/// Opens a bottom sheet showing who reacted to the message. +void showViewReactionsSheet(BuildContext pageContext, { + required int messageId, + ReactionType? initialReactionType, + String? initialEmojiCode, +}) { + final accountId = PerAccountStoreWidget.accountIdOf(pageContext); + + showModalBottomSheet( + context: pageContext, + // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect + // on my iPhone 13 Pro but is marked as "much slower": + // https://api.flutter.dev/flutter/dart-ui/Clip.html + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + builder: (_) { + return PerAccountStoreWidget( + accountId: accountId, + child: SafeArea( + minimum: const EdgeInsets.only(bottom: 16), + child: ViewReactions( + messageId: messageId, + initialEmojiCode: initialEmojiCode, + initialReactionType: initialReactionType))); + }); +} + +class ViewReactions extends StatefulWidget { + const ViewReactions({ + super.key, + required this.messageId, + this.initialReactionType, + this.initialEmojiCode, + }); + + final int messageId; + final ReactionType? initialReactionType; + final String? initialEmojiCode; + + @override + State createState() => _ViewReactionsState(); +} + +class _ViewReactionsState extends State with PerAccountStoreAwareStateMixin { + ReactionType? reactionType; + String? emojiCode; + String? emojiName; + + PerAccountStore? store; + + void _setSelection(ReactionWithVotes? selection) { + setState(() { + reactionType = selection?.reactionType; + emojiCode = selection?.emojiCode; + emojiName = selection?.emojiName; + }); + } + + void _storeChanged() { + _reconcile(); + } + + /// Check that the given reaction still has votes; + /// if not, select a different one if possible or clear the selection. + void _reconcile() { + // TODO scroll into view + _setSelection(_findMatchingReaction()); + } + + ReactionWithVotes? _findMatchingReaction() { + final message = PerAccountStoreWidget.of(context).messages[widget.messageId]; + + final reactions = message?.reactions?.aggregated; + + if (reactions == null || reactions.isEmpty) { + return null; + } + + return reactions + .firstWhereOrNull((x) => + x.reactionType == reactionType && x.emojiCode == emojiCode) + // first item will exist; early-return above on reactions.isEmpty + ?? reactions.first; + } + + @override + void initState() { + super.initState(); + if (widget.initialReactionType != null) { + assert(widget.initialEmojiCode != null); + reactionType = widget.initialReactionType!; + emojiCode = widget.initialEmojiCode!; + } + } + + @override + void onNewStore() { + // TODO(#1747) listen for changes in the message's reactions + store?.removeListener(_storeChanged); + store = PerAccountStoreWidget.of(context); + store!.addListener(_storeChanged); + _reconcile(); + } + + @override + void dispose() { + store?.removeListener(_storeChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableModalBottomSheet( + header: ViewReactionsHeader( + messageId: widget.messageId, + reactionType: reactionType, + emojiCode: emojiCode, + onRequestSelect: _setSelection, + ), + contentSliver: ViewReactionsUserListSliver( + messageId: widget.messageId, + reactionType: reactionType, + emojiCode: emojiCode, + emojiName: emojiName)); + } +} + +class ViewReactionsHeader extends StatelessWidget { + const ViewReactionsHeader({ + super.key, + required this.messageId, + required this.reactionType, + required this.emojiCode, + required this.onRequestSelect, + }); + + final int messageId; + final ReactionType? reactionType; + final String? emojiCode; + final void Function(ReactionWithVotes) onRequestSelect; + + /// A [double] between 0.0 and 1.0 for an emoji's position in the list. + /// + /// When auto-scrolling an emoji into view, + /// this is where the scroll position will land + /// (the min- and max- scroll extent lerped at this value). + double _emojiItemPosition(int index, int aggregatedLength) { + if (aggregatedLength == 1) { + assert(index == 0); + return 0.5; + } + return index / (aggregatedLength - 1); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final message = PerAccountStoreWidget.of(context).messages[messageId]; + + final reactions = message?.reactions; + + if (reactions == null || reactions.aggregated.isEmpty) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: BottomSheetHeader(message: zulipLocalizations.seeWhoReactedSheetNoReactions), + ); + } + + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 4), + child: InsetShadowBox(start: 8, end: 8, + color: designVariables.bgContextMenu, + child: Center( + child: SingleChildScrollView( + // TODO(upstream) we want to pass excludeFromSemantics: true + // to the underlying Scrollable to remove an unwanted node + // in accessibility focus traversal when there are many items. + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Semantics( + role: SemanticsRole.tabBar, + container: true, + explicitChildNodes: true, + label: zulipLocalizations.seeWhoReactedSheetHeaderLabel(reactions.total), + child: Row( + children: reactions.aggregated.mapIndexed((i, r) => + _ViewReactionsEmojiItem( + reactionWithVotes: r, + position: _emojiItemPosition(i, reactions.aggregated.length), + selected: r.reactionType == reactionType && r.emojiCode == emojiCode, + onRequestSelect: onRequestSelect), + ).toList()))))))); + } +} + +class _ViewReactionsEmojiItem extends StatelessWidget { + const _ViewReactionsEmojiItem({ + required this.reactionWithVotes, + required this.position, + required this.selected, + required this.onRequestSelect, + }); + + final ReactionWithVotes reactionWithVotes; + final double position; + final bool selected; + final void Function(ReactionWithVotes) onRequestSelect; + + static const double emojiSize = 24; + + /// Animates the list's scroll position for this item. + /// + /// This serves two purposes when the list is longer than the viewport width: + /// - Ensures the item is in view + /// - By animating, draws attention to the fact that this is a scrollable list + /// and there may be more items in view. (In particular, does this when + /// any item is tapped, because each item has a different [position].) + void _scrollIntoView(BuildContext context) { + final scrollPosition = Scrollable.of(context, axis: Axis.horizontal).position; + final destination = lerpDouble( + scrollPosition.minScrollExtent, + scrollPosition.maxScrollExtent, + position)!; + + scrollPosition.animateTo(destination, + duration: Duration(milliseconds: 200), + curve: Curves.ease); + } + + void _handleTap(BuildContext context) { + _scrollIntoView(context); + onRequestSelect(reactionWithVotes); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + final count = reactionWithVotes.userIds.length; + + final emojiName = reactionWithVotes.emojiName; + final emojiDisplay = store.emojiDisplayFor( + emojiType: reactionWithVotes.reactionType, + emojiCode: reactionWithVotes.emojiCode, + emojiName: emojiName); + + // Don't use a :text_emoji:-style display here. + final placeholder = SizedBox.square(dimension: emojiSize); + + // TODO make a helper widget for this + final emoji = switch (emojiDisplay) { + UnicodeEmojiDisplay() => UnicodeEmojiWidget( + size: emojiSize, + emojiDisplay: emojiDisplay), + ImageEmojiDisplay() => ImageEmojiWidget( + size: emojiSize, + emojiDisplay: emojiDisplay, + // If image emoji fails to load, show nothing. + errorBuilder: (_, _, _) => placeholder), + TextEmojiDisplay() => placeholder, + }; + + Widget result = Tooltip( + message: emojiName, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _handleTap(context), + child: DecoratedBox( + decoration: BoxDecoration( + border: selected + ? Border.all(color: designVariables.borderBar) + : null, + borderRadius: BorderRadius.circular(10), + color: selected ? designVariables.background : null, + ), + child: Padding( + padding: EdgeInsets.fromLTRB(14, 4.5, 14, 4.5), + child: Center( + child: Column( + spacing: 3, + mainAxisSize: MainAxisSize.min, + children: [ + emoji, + Text( + style: TextStyle( + color: designVariables.title, + fontSize: 14, + height: 14 / 14), + count.toString()), // TODO(i18n) number formatting? + ])), + )))); + + return Semantics( + role: SemanticsRole.tab, + onDidGainAccessibilityFocus: () => _scrollIntoView(context), + + // I *think* we're following the doc with this but it's hard to tell; + // I've only tested on iOS and I didn't notice a behavior change. + controlsNodes: {ViewReactionsUserListSliver.semanticsIdentifier}, + + selected: selected, + label: zulipLocalizations.seeWhoReactedSheetEmojiNameWithVoteCount(emojiName, count), + onTap: () => _handleTap(context), + child: ExcludeSemantics( + child: result)); + } +} + +@visibleForTesting +class ViewReactionsUserListSliver extends StatelessWidget { + const ViewReactionsUserListSliver({ + super.key, + required this.messageId, + required this.reactionType, + required this.emojiCode, + required this.emojiName, + }); + + final int messageId; + final ReactionType? reactionType; + final String? emojiCode; + final String? emojiName; + + static const semanticsIdentifier = 'view-reactions-user-list'; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(context); + + if (reactionType == null || emojiCode == null) { + // The emoji selection was cleared, + // which happens when the message is deleted or loses all its reactions. + // The sheet's header will have a message like + // "This message has no reactions." + return SliverPadding(padding: EdgeInsets.zero); + } + assert(emojiName != null); + + final message = store.messages[messageId]; + + final userIds = message?.reactions?.aggregated.firstWhereOrNull( + (x) => x.reactionType == reactionType && x.emojiCode == emojiCode + )?.userIds.toList(); + + // (No filtering of muted or deactivated users. + // Muted users will be shown as muted.) + + if (userIds == null) { + // The selected emoji lost all its votes. This won't show long if at all; + // a different emoji will be automatically selected if there is one. + return SliverPadding(padding: EdgeInsets.zero); + } + + Widget result = SliverList.builder( + itemCount: userIds.length, + itemBuilder: (_, index) => ViewReactionsUserItem(userId: userIds[index])); + + return SliverSemantics( + identifier: semanticsIdentifier, // See note on `controlsNodes` on the tab. + label: zulipLocalizations.seeWhoReactedSheetUserListLabel(emojiName!, userIds.length), + role: SemanticsRole.tabPanel, + container: true, + explicitChildNodes: true, + sliver: result); + } +} + +// TODO: deduplicate the code with [ReadReceiptsUserItem] +@visibleForTesting +class ViewReactionsUserItem extends StatelessWidget { + const ViewReactionsUserItem({ + super.key, + required this.userId, + }); + + final int userId; + + void _onPressed(BuildContext context) { + // Dismiss the action sheet. + Navigator.pop(context); + + Navigator.push(context, + ProfilePage.buildRoute(context: context, userId: userId)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + return InkWell( + onTap: () => _onPressed(context), + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.20), + }), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row(spacing: 8, children: [ + Avatar( + size: 32, + borderRadius: 3, + backgroundColor: designVariables.bgContextMenu, + userId: userId), + Flexible( + child: Text( + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17, + height: 17 / 17, + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 500)), + store.userDisplayName(userId))), + ]))); + } +} diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index ad70b57c32..62c09c0857 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -8,8 +8,8 @@ import 'about_zulip.dart'; import 'action_sheet.dart'; import 'app.dart'; import 'app_bar.dart'; +import 'button.dart'; import 'color.dart'; -import 'content.dart'; import 'icons.dart'; import 'inbox.dart'; import 'inset_shadow.dart'; @@ -17,10 +17,12 @@ import 'message_list.dart'; import 'page.dart'; import 'profile.dart'; import 'recent_dm_conversations.dart'; +import 'settings.dart'; import 'store.dart'; import 'subscription_list.dart'; import 'text.dart'; import 'theme.dart'; +import 'user.dart'; enum _HomePageTab { inbox, @@ -31,7 +33,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()); @@ -109,7 +111,7 @@ class _HomePageState extends State { narrow: const CombinedFeedNarrow()))), button(_HomePageTab.channels, ZulipIcons.hash_italic), // TODO(#1094): Users - button(_HomePageTab.directMessages, ZulipIcons.user), + button(_HomePageTab.directMessages, ZulipIcons.two_person), _NavigationBarButton( icon: ZulipIcons.menu, selected: false, onPressed: () => _showMainMenu(context, tabNotifier: _tab)), @@ -212,7 +214,8 @@ class _LoadingPlaceholderPageState extends State<_LoadingPlaceholderPage> { child: Column( children: [ const SizedBox(height: 16), - Text(zulipLocalizations.tryAnotherAccountMessage(account.realmUrl.toString())), + Text(textAlign: TextAlign.center, + zulipLocalizations.tryAnotherAccountMessage(account.realmUrl.toString())), const SizedBox(height: 8), ElevatedButton( onPressed: () => Navigator.push(context, @@ -264,7 +267,7 @@ void _showMainMenu(BuildContext context, { required ValueNotifier<_HomePageTab> tabNotifier, }) { final menuItems = [ - // TODO(#252): Search + const _SearchButton(), // const SizedBox(height: 8), _InboxButton(tabNotifier: tabNotifier), // TODO: Recent conversations @@ -279,7 +282,7 @@ void _showMainMenu(BuildContext context, { const _SwitchAccountButton(), // TODO(#198): Set my status // const SizedBox(height: 8), - // TODO(#97): Settings + const _SettingsButton(), // TODO(#661): Notifications // const SizedBox(height: 8), const _AboutZulipButton(), @@ -321,7 +324,8 @@ void _showMainMenu(BuildContext context, { child: AnimatedScaleOnTap( scaleEnd: 0.95, duration: Duration(milliseconds: 100), - child: ActionSheetCancelButton())), + child: BottomSheetDismissButton( + style: BottomSheetDismissButtonStyle.close))), ]))); }); } @@ -424,6 +428,24 @@ abstract class _NavigationBarMenuButton extends _MenuButton { } } +class _SearchButton extends _MenuButton { + const _SearchButton(); + + @override + IconData get icon => ZulipIcons.search; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.searchMessagesPageTitle; + } + + @override + void onPressed(BuildContext context) { + Navigator.of(context).push(MessageListPage.buildRoute( + context: context, narrow: KeywordSearchNarrow(''))); + } +} + class _InboxButton extends _NavigationBarMenuButton { const _InboxButton({required super.tabNotifier}); @@ -512,7 +534,7 @@ class _DirectMessagesButton extends _NavigationBarMenuButton { const _DirectMessagesButton({required super.tabNotifier}); @override - IconData get icon => ZulipIcons.user; + IconData get icon => ZulipIcons.two_person; @override String label(ZulipLocalizations zulipLocalizations) { @@ -533,7 +555,11 @@ class _MyProfileButton extends _MenuButton { Widget buildLeading(BuildContext context) { final store = PerAccountStoreWidget.of(context); return Avatar( - userId: store.selfUserId, size: _MenuButton._iconSize, borderRadius: 4); + userId: store.selfUserId, + size: _MenuButton._iconSize, + borderRadius: 4, + showPresence: false, + ); } @override @@ -553,11 +579,7 @@ class _SwitchAccountButton extends _MenuButton { const _SwitchAccountButton(); @override - // TODO(design): choose an icon - IconData? get icon => null; - - @override - Widget buildLeading(BuildContext context) => const SizedBox.shrink(); + IconData? get icon => ZulipIcons.arrow_left_right; @override String label(ZulipLocalizations zulipLocalizations) { @@ -570,65 +592,36 @@ class _SwitchAccountButton extends _MenuButton { } } -class _AboutZulipButton extends _MenuButton { - const _AboutZulipButton(); +class _SettingsButton extends _MenuButton { + const _SettingsButton(); @override - IconData get icon => ZulipIcons.info; + IconData get icon => ZulipIcons.settings; @override String label(ZulipLocalizations zulipLocalizations) { - return zulipLocalizations.aboutPageTitle; + return zulipLocalizations.settingsPageTitle; } @override void onPressed(BuildContext context) { - Navigator.of(context).push(AboutZulipPage.buildRoute(context)); + Navigator.of(context).push(SettingsPage.buildRoute(context: context)); } } -/// Apply [Transform.scale] to the child widget when tapped, and reset its scale -/// when released, while animating the transitions. -class AnimatedScaleOnTap extends StatefulWidget { - const AnimatedScaleOnTap({ - super.key, - required this.scaleEnd, - required this.duration, - required this.child, - }); - - /// The terminal scale to animate to. - final double scaleEnd; - - /// The duration over which to animate the scale change. - final Duration duration; - - final Widget child; +class _AboutZulipButton extends _MenuButton { + const _AboutZulipButton(); @override - State createState() => _AnimatedScaleOnTapState(); -} - -class _AnimatedScaleOnTapState extends State { - double _scale = 1; + IconData get icon => ZulipIcons.info; - void _changeScale(double scale) { - setState(() { - _scale = scale; - }); + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.aboutPageTitle; } @override - Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTapDown: (_) => _changeScale(widget.scaleEnd), - onTapUp: (_) => _changeScale(1), - onTapCancel: () => _changeScale(1), - child: AnimatedScale( - scale: _scale, - duration: widget.duration, - curve: Curves.easeOut, - child: widget.child)); + void onPressed(BuildContext context) { + Navigator.of(context).push(AboutZulipPage.buildRoute(context)); } } diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 7d58305fb4..ed352feeba 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -27,110 +27,170 @@ abstract final class ZulipIcons { /// The Zulip custom icon "arrow_down". static const IconData arrow_down = IconData(0xf101, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "arrow_left_right". + static const IconData arrow_left_right = IconData(0xf102, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "arrow_right". - static const IconData arrow_right = IconData(0xf102, fontFamily: "Zulip Icons"); + static const IconData arrow_right = IconData(0xf103, fontFamily: "Zulip Icons"); /// The Zulip custom icon "at_sign". - static const IconData at_sign = IconData(0xf103, fontFamily: "Zulip Icons"); + static const IconData at_sign = IconData(0xf104, fontFamily: "Zulip Icons"); /// The Zulip custom icon "attach_file". - static const IconData attach_file = IconData(0xf104, fontFamily: "Zulip Icons"); + static const IconData attach_file = IconData(0xf105, fontFamily: "Zulip Icons"); /// The Zulip custom icon "bot". - static const IconData bot = IconData(0xf105, fontFamily: "Zulip Icons"); + static const IconData bot = IconData(0xf106, fontFamily: "Zulip Icons"); /// The Zulip custom icon "camera". - static const IconData camera = IconData(0xf106, fontFamily: "Zulip Icons"); + static const IconData camera = IconData(0xf107, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "check". + static const IconData check = IconData(0xf108, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "check_check". + static const IconData check_check = IconData(0xf109, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "check_circle_checked". + static const IconData check_circle_checked = IconData(0xf10a, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "check_circle_unchecked". + static const IconData check_circle_unchecked = IconData(0xf10b, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "check_remove". + static const IconData check_remove = IconData(0xf10c, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "chevron_down". + static const IconData chevron_down = IconData(0xf10d, 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(0xf10e, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "circle_x". + static const IconData circle_x = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf108, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "contacts". - static const IconData contacts = IconData(0xf109, fontFamily: "Zulip Icons"); + static const IconData contacts = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf10a, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf112, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "edit". + static const IconData edit = IconData(0xf113, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "eye". + static const IconData eye = IconData(0xf114, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "eye_off". + static const IconData eye_off = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf10b, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf116, 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(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf118, 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(0xf119, 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(0xf11a, 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(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf120, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "link". + static const IconData link = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf123, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "message_checked". + static const IconData message_checked = IconData(0xf124, 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(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf126, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "person". + static const IconData person = IconData(0xf127, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "plus". + static const IconData plus = IconData(0xf128, 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(0xf129, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "remove". + static const IconData remove = IconData(0xf12a, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "search". + static const IconData search = IconData(0xf12b, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "see_who_reacted". + static const IconData see_who_reacted = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf12d, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "settings". + static const IconData settings = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf12f, 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(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf132, 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(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf134, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf135, fontFamily: "Zulip Icons"); - /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf123, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "topics". + static const IconData topics = IconData(0xf136, fontFamily: "Zulip Icons"); - /// The Zulip custom icon "user". - static const IconData user = IconData(0xf124, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "two_person". + static const IconData two_person = IconData(0xf137, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "unmute". + static const IconData unmute = IconData(0xf138, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 6dbe31ce04..341d75bd0e 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -8,6 +8,7 @@ import '../model/unreads.dart'; import 'action_sheet.dart'; import 'icons.dart'; import 'message_list.dart'; +import 'page.dart'; import 'sticky_header.dart'; import 'store.dart'; import 'text.dart'; @@ -82,6 +83,7 @@ class _InboxPageState extends State with PerAccountStoreAwareStat @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); final subscriptions = store.subscriptions; @@ -160,9 +162,13 @@ class _InboxPageState extends State with PerAccountStoreAwareStat sections.add(_StreamSectionData(streamId, countInStream, streamHasMention, topicItems)); } - return SafeArea( - // Don't pad the bottom here; we want the list content to do that. - bottom: false, + if (sections.isEmpty) { + return PageBodyEmptyContentPlaceholder( + // TODO(#315) add e.g. "You might be interested in recent conversations." + message: zulipLocalizations.inboxEmptyPlaceholder); + } + + return SafeArea( // horizontal insets child: StickyHeaderListView.builder( itemCount: sections.length, itemBuilder: (context, index) { @@ -272,6 +278,9 @@ abstract class _HeaderItem extends StatelessWidget { // But that's in tension with the Figma, which gives these header rows // 40px min height. onTap: onCollapseButtonTap, + onLongPress: this is _LongPressable + ? (this as _LongPressable).onLongPress + : null, child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ Padding(padding: const EdgeInsets.all(10), child: Icon(size: 20, color: designVariables.sectionCollapseIcon, @@ -316,7 +325,7 @@ class _AllDmsHeaderItem extends _HeaderItem { @override String title(ZulipLocalizations zulipLocalizations) => zulipLocalizations.recentDmConversationsSectionHeader; - @override IconData get icon => ZulipIcons.user; + @override IconData get icon => ZulipIcons.two_person; // TODO(design) check if this is the right variable for these @override Color collapsedIconColor(context) => DesignVariables.of(context).labelMenuButton; @@ -382,22 +391,17 @@ class _DmItem extends StatelessWidget { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); - final selfUser = store.users[store.selfUserId]!; - - final zulipLocalizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); + // TODO write a test where a/the recipient is muted final title = switch (narrow.otherRecipientIds) { // TODO dedupe with [RecentDmConversationsItem] - [] => selfUser.fullName, - [var otherUserId] => - store.users[otherUserId]?.fullName ?? zulipLocalizations.unknownUserName, + [] => store.selfUser.fullName, + [var otherUserId] => store.userDisplayName(otherUserId), // 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 ?? zulipLocalizations.unknownUserName - ).join(', '), + _ => narrow.otherRecipientIds.map(store.userDisplayName).join(', '), }; return Material( @@ -431,7 +435,13 @@ class _DmItem extends StatelessWidget { } } -class _StreamHeaderItem extends _HeaderItem { +mixin _LongPressable on _HeaderItem { + // TODO(#1272) move to _HeaderItem base class + // when DM headers become long-pressable; remove mixin + Future onLongPress(); +} + +class _StreamHeaderItem extends _HeaderItem with _LongPressable { final Subscription subscription; const _StreamHeaderItem({ @@ -464,6 +474,11 @@ class _StreamHeaderItem extends _HeaderItem { } } @override Future onRowTap() => onCollapseButtonTap(); // TODO open channel narrow + + @override + Future onLongPress() async { + showChannelActionSheet(sectionContext, channelId: subscription.streamId); + } } class _StreamSection extends StatelessWidget { @@ -507,7 +522,8 @@ class _TopicItem extends StatelessWidget { @override Widget build(BuildContext context) { - final _StreamSectionTopicData(:topic, :count, :hasMention) = data; + final _StreamSectionTopicData( + :topic, :count, :hasMention, :lastUnreadId) = data; final store = PerAccountStoreWidget.of(context); final subscription = store.subscriptions[streamId]!; @@ -525,7 +541,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), @@ -535,12 +553,13 @@ class _TopicItem extends StatelessWidget { style: TextStyle( fontSize: 17, height: (20 / 17), + fontStyle: topic.displayName == null ? FontStyle.italic : null, // TODO(design) check if this is the right variable color: designVariables.labelMenuButton, ), maxLines: 2, overflow: TextOverflow.ellipsis, - topic.displayName))), + topic.displayName ?? store.realmEmptyTopicDisplayName))), 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/inset_shadow.dart b/lib/widgets/inset_shadow.dart index a4133ac7de..fe72d9ead6 100644 --- a/lib/widgets/inset_shadow.dart +++ b/lib/widgets/inset_shadow.dart @@ -17,6 +17,8 @@ class InsetShadowBox extends StatelessWidget { super.key, this.top = 0, this.bottom = 0, + this.start = 0, + this.end = 0, required this.color, required this.child, }); @@ -31,17 +33,21 @@ class InsetShadowBox extends StatelessWidget { /// This does not pad the child widget. final double bottom; - /// The shadow color to fade into transparency from the top and bottom borders. + /// The distance that the shadow from the child's start edge grows endwards. + /// + /// This does not pad the child widget. + final double start; + + /// The distance that the shadow from the child's end edge grows startwards. + /// + /// This does not pad the child widget. + final double end; + + /// The shadow color to fade into transparency from the edges, inward. final Color color; final Widget child; - BoxDecoration _shadowFrom(AlignmentGeometry begin) { - return BoxDecoration(gradient: LinearGradient( - begin: begin, end: -begin, - colors: [color, color.withValues(alpha: 0)])); - } - @override Widget build(BuildContext context) { return Stack( @@ -50,10 +56,33 @@ class InsetShadowBox extends StatelessWidget { fit: StackFit.passthrough, children: [ child, - Positioned(top: 0, height: top, left: 0, right: 0, - child: DecoratedBox(decoration: _shadowFrom(Alignment.topCenter))), - Positioned(bottom: 0, height: bottom, left: 0, right: 0, - child: DecoratedBox(decoration: _shadowFrom(Alignment.bottomCenter))), + if (top != 0) Positioned(top: 0, height: top, left: 0, right: 0, + child: DecoratedBox( + decoration: fadeToTransparencyDecoration(FadeToTransparencyDirection.down, color))), + if (bottom != 0) Positioned(bottom: 0, height: bottom, left: 0, right: 0, + child: DecoratedBox( + decoration: fadeToTransparencyDecoration(FadeToTransparencyDirection.up, color))), + if (start != 0) PositionedDirectional(start: 0, width: start, top: 0, bottom: 0, + child: DecoratedBox( + decoration: fadeToTransparencyDecoration(FadeToTransparencyDirection.end, color))), + if (end != 0) PositionedDirectional(end: 0, width: end, top: 0, bottom: 0, + child: DecoratedBox( + decoration: fadeToTransparencyDecoration(FadeToTransparencyDirection.start, color))), ]); } } + +enum FadeToTransparencyDirection { down, up, end, start } + +BoxDecoration fadeToTransparencyDecoration(FadeToTransparencyDirection direction, Color color) { + final begin = switch (direction) { + FadeToTransparencyDirection.down => Alignment.topCenter, + FadeToTransparencyDirection.up => Alignment.bottomCenter, + FadeToTransparencyDirection.end => AlignmentDirectional.centerStart, + FadeToTransparencyDirection.start => AlignmentDirectional.centerEnd, + }; + + return BoxDecoration(gradient: LinearGradient( + begin: begin, end: -begin, + colors: [color, color.withValues(alpha: 0)])); +} diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart new file mode 100644 index 0000000000..4b4f39aa3f --- /dev/null +++ b/lib/widgets/katex.dart @@ -0,0 +1,450 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/rendering.dart'; + +import '../model/content.dart'; +import '../model/katex.dart'; +import 'content.dart'; + +/// Creates a base text style for rendering KaTeX content. +/// +/// This applies the CSS styles defined in .katex class in katex.scss : +/// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 +/// +/// Requires the [style.fontSize] to be non-null. +TextStyle mkBaseKatexTextStyle(TextStyle style) { + return style.copyWith( + fontSize: style.fontSize! * 1.21, + fontFamily: 'KaTeX_Main', + height: 1.2, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + decoration: TextDecoration.none, + fontFamilyFallback: const []); +} + +@visibleForTesting +class KatexWidget extends StatelessWidget { + const KatexWidget({ + super.key, + required this.textStyle, + required this.nodes, + }); + + final TextStyle textStyle; + final List nodes; + + @override + Widget build(BuildContext context) { + Widget widget = _KatexNodeList(nodes: nodes); + + return Directionality( + textDirection: TextDirection.ltr, + child: DefaultTextStyle( + style: mkBaseKatexTextStyle(textStyle).copyWith( + color: ContentTheme.of(context).textStylePlainParagraph.color), + child: widget)); + } +} + +class _KatexNodeList extends StatelessWidget { + const _KatexNodeList({required this.nodes}); + + final List nodes; + + @override + Widget build(BuildContext context) { + return Text.rich(TextSpan( + children: List.unmodifiable(nodes.map((e) { + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + // Work around a bug where text inside a WidgetSpan could be scaled + // multiple times incorrectly, if the system font scale is larger + // than 1x. + // See: https://github.com/flutter/flutter/issues/126962 + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.noScaling), + child: switch (e) { + KatexSpanNode() => _KatexSpan(e), + KatexStrutNode() => _KatexStrut(e), + KatexVlistNode() => _KatexVlist(e), + KatexNegativeMarginNode() => _KatexNegativeMargin(e), + })); + })))); + } +} + +class _KatexSpan extends StatelessWidget { + const _KatexSpan(this.node); + + final KatexSpanNode node; + + @override + Widget build(BuildContext context) { + var em = DefaultTextStyle.of(context).style.fontSize!; + + Widget widget = const SizedBox.shrink(); + if (node.text != null) { + widget = Text(node.text!); + } else if (node.nodes != null && node.nodes!.isNotEmpty) { + widget = _KatexNodeList(nodes: node.nodes!); + } + + final styles = node.styles; + if (styles.topEm != null) { + // The meaning of `top` would be different without `position: relative`. + assert(styles.position == KatexSpanPosition.relative); + } + + final fontFamily = styles.fontFamily; + final fontSize = switch (styles.fontSizeEm) { + double fontSizeEm => fontSizeEm * em, + null => null, + }; + if (fontSize != null) em = fontSize; + + final fontWeight = switch (styles.fontWeight) { + KatexSpanFontWeight.bold => FontWeight.bold, + null => null, + }; + var fontStyle = switch (styles.fontStyle) { + KatexSpanFontStyle.normal => FontStyle.normal, + KatexSpanFontStyle.italic => FontStyle.italic, + null => null, + }; + final color = switch (styles.color) { + KatexSpanColor katexColor => + Color.fromARGB(katexColor.a, katexColor.r, katexColor.g, katexColor.b), + null => null, + }; + + TextStyle? textStyle; + if (fontFamily != null || + fontSize != null || + fontWeight != null || + fontStyle != null || + color != null) { + // TODO(upstream) remove this workaround when upstream fixes the broken + // rendering of KaTeX_Math font with italic font style on Android: + // https://github.com/flutter/flutter/issues/167474 + if (defaultTargetPlatform == TargetPlatform.android && + fontFamily == 'KaTeX_Math') { + fontStyle = FontStyle.normal; + } + + textStyle = TextStyle( + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: fontWeight, + fontStyle: fontStyle, + color: color, + ); + } + final textAlign = switch (styles.textAlign) { + KatexSpanTextAlign.left => TextAlign.left, + KatexSpanTextAlign.center => TextAlign.center, + KatexSpanTextAlign.right => TextAlign.right, + null => null, + }; + + if (textStyle != null || textAlign != null) { + widget = DefaultTextStyle.merge( + style: textStyle, + textAlign: textAlign, + child: widget); + } + + widget = SizedBox( + width: styles.widthEm != null + ? styles.widthEm! * em + : null, + height: styles.heightEm != null + ? styles.heightEm! * em + : null, + child: widget); + + final margin = switch ((styles.marginLeftEm, styles.marginRightEm)) { + (null, null) => null, + (null, final marginRightEm?) => + EdgeInsets.only(right: marginRightEm * em), + (final marginLeftEm?, null) => + EdgeInsets.only(left: marginLeftEm * em), + (final marginLeftEm?, final marginRightEm?) => + EdgeInsets.only(left: marginLeftEm * em, right: marginRightEm * em), + }; + + if (margin != null) { + assert(margin.isNonNegative); + widget = Padding(padding: margin, child: widget); + } + + switch (styles.position) { + case KatexSpanPosition.relative: + if (styles.topEm case final topEm?) { + widget = Transform.translate( + offset: Offset(0, topEm * em), + child: widget); + } + + case null: + break; + } + + return widget; + } +} + +class _KatexStrut extends StatelessWidget { + const _KatexStrut(this.node); + + final KatexStrutNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + final verticalAlignEm = node.verticalAlignEm; + if (verticalAlignEm == null) { + return SizedBox(height: node.heightEm * em); + } + + return SizedBox( + height: node.heightEm * em, + child: Baseline( + baseline: (verticalAlignEm + node.heightEm) * em, + baselineType: TextBaseline.alphabetic, + child: const Text('')), + ); + } +} + +class _KatexVlist extends StatelessWidget { + const _KatexVlist(this.node); + + final KatexVlistNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return Stack(children: List.unmodifiable(node.rows.map((row) { + return Transform.translate( + offset: Offset(0, row.verticalOffsetEm * em), + child: _KatexSpan(row.node)); + }))); + } +} + +class _KatexNegativeMargin extends StatelessWidget { + const _KatexNegativeMargin(this.node); + + final KatexNegativeMarginNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return NegativeLeftOffset( + leftOffset: node.leftOffsetEm * em, + child: _KatexNodeList(nodes: node.nodes)); + } +} + +class NegativeLeftOffset extends SingleChildRenderObjectWidget { + NegativeLeftOffset({super.key, required this.leftOffset, super.child}) + : assert(leftOffset.isNegative), + _padding = EdgeInsets.only(left: leftOffset); + + final double leftOffset; + final EdgeInsetsGeometry _padding; + + @override + RenderNegativePadding createRenderObject(BuildContext context) { + return RenderNegativePadding( + padding: _padding, + textDirection: Directionality.maybeOf(context)); + } + + @override + void updateRenderObject( + BuildContext context, + RenderNegativePadding renderObject, + ) { + renderObject + ..padding = _padding + ..textDirection = Directionality.maybeOf(context); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('padding', _padding)); + } +} + +// Like [RenderPadding] but only supports negative values. +// TODO(upstream): give Padding an option to accept negative padding (at cost of hit-testing not working) +class RenderNegativePadding extends RenderShiftedBox { + RenderNegativePadding({ + required EdgeInsetsGeometry padding, + TextDirection? textDirection, + RenderBox? child, + }) : assert(!padding.isNonNegative), + _textDirection = textDirection, + _padding = padding, + super(child); + + EdgeInsets? _resolvedPaddingCache; + EdgeInsets get _resolvedPadding { + final EdgeInsets returnValue = _resolvedPaddingCache ??= padding.resolve(textDirection); + return returnValue; + } + + void _markNeedResolution() { + _resolvedPaddingCache = null; + markNeedsLayout(); + } + + /// The amount to pad the child in each dimension. + /// + /// If this is set to an [EdgeInsetsDirectional] object, then [textDirection] + /// must not be null. + EdgeInsetsGeometry get padding => _padding; + EdgeInsetsGeometry _padding; + set padding(EdgeInsetsGeometry value) { + assert(!value.isNonNegative); + if (_padding == value) { + return; + } + _padding = value; + _markNeedResolution(); + } + + /// The text direction with which to resolve [padding]. + /// + /// This may be changed to null, but only after the [padding] has been changed + /// to a value that does not depend on the direction. + TextDirection? get textDirection => _textDirection; + TextDirection? _textDirection; + set textDirection(TextDirection? value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + _markNeedResolution(); + } + + @override + double computeMinIntrinsicWidth(double height) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMinIntrinsicWidth(math.max(0.0, height - padding.vertical)) + + padding.horizontal; + } + return padding.horizontal; + } + + @override + double computeMaxIntrinsicWidth(double height) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMaxIntrinsicWidth(math.max(0.0, height - padding.vertical)) + + padding.horizontal; + } + return padding.horizontal; + } + + @override + double computeMinIntrinsicHeight(double width) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMinIntrinsicHeight(math.max(0.0, width - padding.horizontal)) + + padding.vertical; + } + return padding.vertical; + } + + @override + double computeMaxIntrinsicHeight(double width) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMaxIntrinsicHeight(math.max(0.0, width - padding.horizontal)) + + padding.vertical; + } + return padding.vertical; + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + final EdgeInsets padding = _resolvedPadding; + if (child == null) { + return constraints.constrain(Size(padding.horizontal, padding.vertical)); + } + final BoxConstraints innerConstraints = constraints.deflate(padding); + final Size childSize = child!.getDryLayout(innerConstraints); + return constraints.constrain( + Size(padding.horizontal + childSize.width, padding.vertical + childSize.height), + ); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final EdgeInsets padding = _resolvedPadding; + final BoxConstraints innerConstraints = constraints.deflate(padding); + final BaselineOffset result = + BaselineOffset(child.getDryBaseline(innerConstraints, baseline)) + padding.top; + return result.offset; + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + final EdgeInsets padding = _resolvedPadding; + if (child == null) { + size = constraints.constrain(Size(padding.horizontal, padding.vertical)); + return; + } + final BoxConstraints innerConstraints = constraints.deflate(padding); + child!.layout(innerConstraints, parentUsesSize: true); + final BoxParentData childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Offset(padding.left, padding.top); + size = constraints.constrain( + Size(padding.horizontal + child!.size.width, padding.vertical + child!.size.height), + ); + } + + @override + void debugPaintSize(PaintingContext context, Offset offset) { + super.debugPaintSize(context, offset); + assert(() { + final Rect outerRect = offset & size; + debugPaintPadding( + context.canvas, + outerRect, + child != null ? _resolvedPaddingCache!.deflateRect(outerRect) : null, + ); + return true; + }()); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('padding', padding)); + properties.add(EnumProperty('textDirection', textDirection, defaultValue: null)); + } +} diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 65d013a4b6..cbb131d12b 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'package:intl/intl.dart'; import 'package:video_player/video_player.dart'; import '../api/core.dart'; @@ -9,51 +8,75 @@ import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../log.dart'; import '../model/binding.dart'; +import 'actions.dart'; import 'content.dart'; import 'dialog.dart'; +import 'message_list.dart'; import 'page.dart'; -import 'clipboard.dart'; import 'store.dart'; - -// TODO(#44): Add index of the image preview in the message, to not break if -// there are multiple image previews with the same URL in the same -// message. Maybe keep `src`, so that on exit the lightbox image doesn't -// fly to an image preview with a different URL, following a message edit -// while the lightbox was open. +import 'user.dart'; + +/// Identifies which [LightboxHero]s should match up with each other +/// to produce a hero animation. +/// +/// See [Hero.tag], the field where we use instances of this class. +/// +/// The intended behavior is that when the user acts on an image +/// in the message list to have the app expand it in the lightbox, +/// a hero animation goes from the original view of the image +/// to the version in the lightbox, +/// and back to the original upon exiting the lightbox. class _LightboxHeroTag { - _LightboxHeroTag({required this.messageId, required this.src}); + _LightboxHeroTag({ + required this.messageImageContext, + required this.src, + }); - final int messageId; + /// The [BuildContext] for the [MessageImage] being expanded into the lightbox. + /// + /// In particular this prevents hero animations between + /// different message lists that happen to have the same message. + /// It also distinguishes different copies of the same image + /// in a given message list. + // TODO: write a regression test for #44, duplicate images within a message + final BuildContext messageImageContext; + + /// The image source URL. + /// + /// This ensures the animation only occurs between matching images, even if + /// the message was edited before navigating back to the message list + /// so that the original [MessageImage] has been replaced in the tree + /// by a different image. final Uri src; @override bool operator ==(Object other) { return other is _LightboxHeroTag && - other.messageId == messageId && + other.messageImageContext == messageImageContext && other.src == src; } @override - int get hashCode => Object.hash('_LightboxHeroTag', messageId, src); + int get hashCode => Object.hash('_LightboxHeroTag', messageImageContext, src); } /// Builds a [Hero] from an image in the message list to the lightbox page. class LightboxHero extends StatelessWidget { const LightboxHero({ super.key, - required this.message, + required this.messageImageContext, required this.src, required this.child, }); - final Message message; + final BuildContext messageImageContext; final Uri src; final Widget child; @override Widget build(BuildContext context) { return Hero( - tag: _LightboxHeroTag(messageId: message.id, src: src), + tag: _LightboxHeroTag(messageImageContext: messageImageContext, src: src), flightShuttleBuilder: ( BuildContext flightContext, Animation animation, @@ -82,7 +105,7 @@ class _CopyLinkButton extends StatelessWidget { tooltip: zulipLocalizations.lightboxCopyLinkTooltip, icon: const Icon(Icons.copy), onPressed: () async { - copyWithPopup(context: context, + PlatformActions.copyWithPopup(context: context, successContent: Text(zulipLocalizations.successLinkCopied), data: ClipboardData(text: url.toString())); }); @@ -144,6 +167,8 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(context); final themeData = Theme.of(context); final appBarBackgroundColor = Colors.grey.shade900.withValues(alpha: 0.87); @@ -152,11 +177,11 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { PreferredSizeWidget? appBar; if (_headerFooterVisible) { - // TODO(#45): Format with e.g. "Yesterday at 4:47 PM" - final timestampText = DateFormat - .yMMMd(/* TODO(#278): Pass selected language here, I think? */) - .add_Hms() - .format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000)); + final timestampText = MessageTimestampStyle.lightbox + .format(widget.message.timestamp, + now: DateTime.now(), + twentyFourHourTimeMode: store.userSettings.twentyFourHourTime, + zulipLocalizations: zulipLocalizations); // We use plain [AppBar] instead of [ZulipAppBar], even though this page // has a [PerAccountStore], because: @@ -172,13 +197,19 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { shape: const Border(), // Remove bottom border from [AppBarTheme] elevation: appBarElevation, title: Row(children: [ - Avatar(size: 36, borderRadius: 36 / 8, userId: widget.message.senderId), + Avatar( + size: 36, + borderRadius: 36 / 8, + userId: widget.message.senderId, + replaceIfMuted: false, + ), const SizedBox(width: 8), Expanded( child: RichText( text: TextSpan(children: [ TextSpan( - text: '${widget.message.senderFullName}\n', + // TODO write a test where the sender is muted; check this and avatar + text: '${store.senderDisplayName(widget.message, replaceIfMuted: false)}\n', // Restate default style: themeData.textTheme.titleLarge!.copyWith(color: appBarForegroundColor)), @@ -226,6 +257,7 @@ class _ImageLightboxPage extends StatefulWidget { const _ImageLightboxPage({ required this.routeEntranceAnimation, required this.message, + required this.messageImageContext, required this.src, required this.thumbnailUrl, required this.originalWidth, @@ -234,6 +266,7 @@ class _ImageLightboxPage extends StatefulWidget { final Animation routeEntranceAnimation; final Message message; + final BuildContext messageImageContext; final Uri src; final Uri? thumbnailUrl; final double? originalWidth; @@ -315,9 +348,10 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> { buildBottomAppBar: _buildBottomAppBar, child: SizedBox.expand( child: InteractiveViewer( + maxScale: 10, // TODO adjust based on device and image size; see #1091 child: SafeArea( child: LightboxHero( - message: widget.message, + messageImageContext: widget.messageImageContext, src: widget.src, child: RealmContentNetworkImage(widget.src, filterQuality: FilterQuality.medium, @@ -484,7 +518,7 @@ class _VideoLightboxPageState extends State with PerAccountSt context: context, title: zulipLocalizations.errorDialogTitle, message: zulipLocalizations.errorVideoPlayerFailed); - await dialog.closed; + await dialog.result; if (!mounted) return; Navigator.pop(context); // Pops the lightbox } @@ -599,6 +633,7 @@ Route getImageLightboxRoute({ int? accountId, BuildContext? context, required Message message, + required BuildContext messageImageContext, required Uri src, required Uri? thumbnailUrl, required double? originalWidth, @@ -611,6 +646,7 @@ Route getImageLightboxRoute({ return _ImageLightboxPage( routeEntranceAnimation: animation, message: message, + messageImageContext: messageImageContext, src: src, thumbnailUrl: thumbnailUrl, originalWidth: originalWidth, diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index ce1cf09438..465c5409bf 100644 --- a/lib/widgets/login.dart +++ b/lib/widgets/login.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../api/core.dart'; import '../api/exception.dart'; import '../api/model/web_auth.dart'; import '../api/route/account.dart'; @@ -13,6 +14,7 @@ import '../api/route/users.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../log.dart'; import '../model/binding.dart'; +import '../model/server_support.dart'; import '../model/store.dart'; import 'dialog.dart'; import 'home.dart'; @@ -115,6 +117,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(); } @@ -166,25 +182,43 @@ class _AddAccountPageState extends State { final connection = globalStore.apiConnection(realmUrl: url!, zulipFeatureLevel: null); try { serverSettings = await getServerSettings(connection); + final zulipVersionData = ZulipVersionData.fromServerSettings(serverSettings); + if (zulipVersionData.isUnsupported) { + throw ServerVersionUnsupportedException(zulipVersionData); + } + } on MalformedServerResponseException catch (e) { + final zulipVersionData = ZulipVersionData.fromMalformedServerResponseException(e); + if (zulipVersionData != null && zulipVersionData.isUnsupported) { + throw ServerVersionUnsupportedException(zulipVersionData); + } + rethrow; } finally { connection.close(); } } catch (e) { - if (!context.mounted) { - return; + if (!context.mounted) return; + + String? message; + Uri? learnMoreButtonUrl; + switch (e) { + case ServerVersionUnsupportedException(:final data): + message = zulipLocalizations.errorServerVersionUnsupportedMessage( + url.toString(), + data.zulipVersion, + kMinSupportedZulipVersion); + learnMoreButtonUrl = kServerSupportDocUrl; + default: + // TODO(#105) give more helpful feedback; see `fetchServerSettings` + // in zulip-mobile's src/message/fetchActions.js. + message = zulipLocalizations.errorLoginCouldNotConnect(url.toString()); } - // TODO(#105) give more helpful feedback; see `fetchServerSettings` - // in zulip-mobile's src/message/fetchActions.js. showErrorDialog(context: context, - title: zulipLocalizations.errorLoginCouldNotConnectTitle, - message: zulipLocalizations.errorLoginCouldNotConnect(url.toString())); - return; - } - // https://github.com/dart-lang/linter/issues/4007 - // ignore: use_build_context_synchronously - if (!context.mounted) { + title: zulipLocalizations.errorCouldNotConnectTitle, + message: message, + learnMoreButtonUrl: learnMoreButtonUrl); return; } + if (!context.mounted) return; unawaited(Navigator.push(context, LoginPage.buildRoute(serverSettings: serverSettings))); @@ -230,10 +264,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 @@ -295,8 +329,7 @@ class _LoginPageState extends State { if (payload.realm.origin != widget.serverSettings.realmUrl.origin) throw Error(); final apiKey = payload.decodeApiKey(_otp!); await _tryInsertAccountAndNavigate( - // TODO(server-5): Rely on userId from payload. - userId: payload.userId ?? await _getUserId(payload.email, apiKey), + userId: payload.userId, email: payload.email, apiKey: apiKey, ); @@ -329,6 +362,9 @@ class _LoginPageState extends State { // Could set [_inProgress]… but we'd need to unset it if the web-auth // attempt is aborted (by the user closing the browser, for example), // and I don't think we can reliably know when that happens. + + // Not using [PlatformActions.launchUrl] because web auth needs special + // error handling. await ZulipBinding.instance.launchUrl(url, mode: LaunchMode.inAppBrowserView); } catch (e) { assert(debugLog(e.toString())); diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index a27a8051e9..77e2e8cb24 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1,12 +1,16 @@ -import 'dart:math'; +import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_color_models/flutter_color_models.dart'; -import 'package:intl/intl.dart'; +import 'package:intl/intl.dart' hide TextDirection; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/binding.dart'; +import '../model/database.dart'; +import '../model/message.dart'; import '../model/message_list.dart'; import '../model/narrow.dart'; import '../model/store.dart'; @@ -14,28 +18,28 @@ import '../model/typing_status.dart'; import 'action_sheet.dart'; import 'actions.dart'; import 'app_bar.dart'; +import 'button.dart'; +import 'color.dart'; import 'compose_box.dart'; import 'content.dart'; import 'emoji_reaction.dart'; import 'icons.dart'; import 'page.dart'; import 'profile.dart'; +import 'scrolling.dart'; import 'sticky_header.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'topic_list.dart'; +import 'user.dart'; /// Message-list styles that differ between light and dark themes. class MessageListTheme extends ThemeExtension { static final light = MessageListTheme._( - 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(), + labelTime: const HSLColor.fromAHSL(0.49, 0, 0, 0).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: @@ -47,20 +51,12 @@ class MessageListTheme extends ThemeExtension { unreadMarker: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor(), unreadMarkerGap: Colors.white.withValues(alpha: 0.6), - - // TODO(design) this seems ad-hoc; is there a better color? - unsubscribedStreamRecipientHeaderBg: const Color(0xfff5f5f5), ); static final dark = MessageListTheme._( - 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(), + labelTime: const HSLColor.fromAHSL(0.5, 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: @@ -71,24 +67,15 @@ class MessageListTheme extends ThemeExtension { unreadMarker: const HSLColor.fromAHSL(0.75, 227, 0.78, 0.59).toColor(), unreadMarkerGap: Colors.transparent, - - // TODO(design) this is ad-hoc and untested; is there a better color? - unsubscribedStreamRecipientHeaderBg: const Color(0xff0a0a0a), ); MessageListTheme._({ - required this.dateSeparator, - required this.dateSeparatorText, required this.dmRecipientHeaderBg, - required this.messageTimestamp, - required this.recipientHeaderText, + required this.labelTime, required this.senderBotIcon, - required this.senderName, - required this.streamMessageBgDefault, required this.streamRecipientHeaderChevronRight, required this.unreadMarker, required this.unreadMarkerGap, - required this.unsubscribedStreamRecipientHeaderBg, }); /// The [MessageListTheme] from the context's active theme. @@ -101,47 +88,29 @@ class MessageListTheme extends ThemeExtension { return extension!; } - final Color dateSeparator; - final Color dateSeparatorText; final Color dmRecipientHeaderBg; - final Color messageTimestamp; - final Color recipientHeaderText; + final Color labelTime; final Color senderBotIcon; - final Color senderName; - final Color streamMessageBgDefault; final Color streamRecipientHeaderChevronRight; final Color unreadMarker; final Color unreadMarkerGap; - final Color unsubscribedStreamRecipientHeaderBg; @override MessageListTheme copyWith({ - Color? dateSeparator, - Color? dateSeparatorText, Color? dmRecipientHeaderBg, - Color? messageTimestamp, - Color? recipientHeaderText, + Color? labelTime, Color? senderBotIcon, - Color? senderName, - Color? streamMessageBgDefault, Color? streamRecipientHeaderChevronRight, Color? unreadMarker, Color? unreadMarkerGap, - Color? unsubscribedStreamRecipientHeaderBg, }) { return MessageListTheme._( - dateSeparator: dateSeparator ?? this.dateSeparator, - dateSeparatorText: dateSeparatorText ?? this.dateSeparatorText, dmRecipientHeaderBg: dmRecipientHeaderBg ?? this.dmRecipientHeaderBg, - messageTimestamp: messageTimestamp ?? this.messageTimestamp, - recipientHeaderText: recipientHeaderText ?? this.recipientHeaderText, + labelTime: labelTime ?? this.labelTime, senderBotIcon: senderBotIcon ?? this.senderBotIcon, - senderName: senderName ?? this.senderName, - streamMessageBgDefault: streamMessageBgDefault ?? this.streamMessageBgDefault, streamRecipientHeaderChevronRight: streamRecipientHeaderChevronRight ?? this.streamRecipientHeaderChevronRight, unreadMarker: unreadMarker ?? this.unreadMarker, unreadMarkerGap: unreadMarkerGap ?? this.unreadMarkerGap, - unsubscribedStreamRecipientHeaderBg: unsubscribedStreamRecipientHeaderBg ?? this.unsubscribedStreamRecipientHeaderBg, ); } @@ -151,18 +120,12 @@ class MessageListTheme extends ThemeExtension { return this; } return MessageListTheme._( - dateSeparator: Color.lerp(dateSeparator, other.dateSeparator, t)!, - dateSeparatorText: Color.lerp(dateSeparatorText, other.dateSeparatorText, t)!, - dmRecipientHeaderBg: Color.lerp(streamMessageBgDefault, other.dmRecipientHeaderBg, t)!, - messageTimestamp: Color.lerp(messageTimestamp, other.messageTimestamp, t)!, - recipientHeaderText: Color.lerp(recipientHeaderText, other.recipientHeaderText, t)!, + dmRecipientHeaderBg: Color.lerp(dmRecipientHeaderBg, other.dmRecipientHeaderBg, t)!, + labelTime: Color.lerp(labelTime, other.labelTime, t)!, senderBotIcon: Color.lerp(senderBotIcon, other.senderBotIcon, t)!, - senderName: Color.lerp(senderName, other.senderName, t)!, - streamMessageBgDefault: Color.lerp(streamMessageBgDefault, other.streamMessageBgDefault, t)!, streamRecipientHeaderChevronRight: Color.lerp(streamRecipientHeaderChevronRight, other.streamRecipientHeaderChevronRight, t)!, unreadMarker: Color.lerp(unreadMarker, other.unreadMarker, t)!, unreadMarkerGap: Color.lerp(unreadMarkerGap, other.unreadMarkerGap, t)!, - unsubscribedStreamRecipientHeaderBg: Color.lerp(unsubscribedStreamRecipientHeaderBg, other.unsubscribedStreamRecipientHeaderBg, t)!, ); } } @@ -170,46 +133,134 @@ class MessageListTheme extends ThemeExtension { /// The interface for the state of a [MessageListPage]. /// /// To obtain one of these, see [MessageListPage.ancestorOf]. -abstract class MessageListPageState { +abstract class MessageListPageState extends State { /// The narrow for this page's message list. Narrow get narrow; - /// The controller for this [MessageListPage]'s compose box, + /// The [ComposeBoxState] for this [MessageListPage]'s compose box, /// if this [MessageListPage] offers a compose box and it has mounted, /// else null. - ComposeBoxController? get composeBoxController; + ComposeBoxState? get composeBoxState; /// The active [MessageListView]. /// /// This is null if [MessageList] has not mounted yet. MessageListView? get model; + + /// This view's decision whether to mark read on scroll, + /// overriding [GlobalSettings.markReadOnScroll]. + /// + /// For example, this is set to false after pressing + /// "Mark as unread from here" in the message action sheet. + bool? get markReadOnScroll; + set markReadOnScroll(bool? value); + + /// For a message from a muted sender, reveal the sender and content, + /// replacing the "Muted user" placeholder. + void revealMutedMessage(int messageId); + + /// For a message from a muted sender, hide the sender and content again + /// with the "Muted user" placeholder. + void unrevealMutedMessage(int messageId); } class MessageListPage extends StatefulWidget { - const MessageListPage({super.key, required this.initNarrow}); + const MessageListPage({ + super.key, + required this.initNarrow, + this.initAnchorMessageId, + }); + + static AccountRoute buildRoute({ + int? accountId, + BuildContext? context, + GlobalKey? key, + required Narrow narrow, + int? initAnchorMessageId, + }) { + return MaterialAccountWidgetRoute( + accountId: accountId, + context: context, + page: MessageListPage( + key: key, + initNarrow: narrow, + initAnchorMessageId: initAnchorMessageId)); + } - static Route buildRoute({int? accountId, BuildContext? context, - required Narrow narrow}) { - return MaterialAccountWidgetRoute(accountId: accountId, context: context, - page: MessageListPage(initNarrow: narrow)); + /// The "revealed" state of a message from a muted sender, + /// if there is a [MessageListPage] ancestor, else null. + /// + /// This is updated via [MessageListPageState.revealMutedMessage] + /// and [MessageListPageState.unrevealMutedMessage]. + /// + /// Uses the efficient [BuildContext.dependOnInheritedWidgetOfExactType], + /// so this is safe to call in a build method. + static RevealedMutedMessagesState? maybeRevealedMutedMessagesOf(BuildContext context) { + final state = + context.dependOnInheritedWidgetOfExactType<_RevealedMutedMessagesProvider>() + ?.state; + return state; } /// The [MessageListPageState] above this context in the tree. /// /// Uses the inefficient [BuildContext.findAncestorStateOfType]; /// don't call this in a build method. - // If we do find ourselves wanting this in a build method, it won't be hard - // to enable that: we'd just need to add an [InheritedWidget] here. + /// + /// See also: + /// * [maybeAncestorOf], which returns null instead of throwing + /// when an ancestor [MessageListPageState] is not found. static MessageListPageState ancestorOf(BuildContext context) { - final state = context.findAncestorStateOfType<_MessageListPageState>(); + final state = maybeAncestorOf(context); assert(state != null, 'No MessageListPage ancestor'); return state!; } + /// The [MessageListPageState] above this context in the tree, if any. + /// + /// Uses the inefficient [BuildContext.findAncestorStateOfType]; + /// don't call this in a build method. + /// + /// See also: + /// * [ancestorOf], which throws instead of returning null + /// when an ancestor [MessageListPageState] is not found. + // If we do find ourselves wanting this in a build method, it won't be hard + // to enable that: we'd just need to add an [InheritedWidget] here. + static MessageListPageState? maybeAncestorOf(BuildContext context) { + return context.findAncestorStateOfType<_MessageListPageState>(); + } + final Narrow initNarrow; + final int? initAnchorMessageId; // TODO(#1564) highlight target upon load @override State createState() => _MessageListPageState(); + + /// In debug mode, controls whether mark-read-on-scroll is enabled, + /// overriding [GlobalSettings.markReadOnScroll] + /// and [MessageListPageState.markReadOnScroll]. + /// + /// Outside of debug mode, this is always true and the setter has no effect. + static bool get debugEnableMarkReadOnScroll { + bool result = true; + assert(() { + result = _debugEnableMarkReadOnScroll; + return true; + }()); + return result; + } + static bool _debugEnableMarkReadOnScroll = true; + static set debugEnableMarkReadOnScroll(bool value) { + assert(() { + _debugEnableMarkReadOnScroll = value; + return true; + }()); + } + + @visibleForTesting + static void debugReset() { + _debugEnableMarkReadOnScroll = true; + } } class _MessageListPageState extends State implements MessageListPageState { @@ -217,13 +268,35 @@ class _MessageListPageState extends State implements MessageLis late Narrow narrow; @override - ComposeBoxController? get composeBoxController => _composeBoxKey.currentState?.controller; + ComposeBoxState? get composeBoxState => _composeBoxKey.currentState; final GlobalKey _composeBoxKey = GlobalKey(); @override MessageListView? get model => _messageListKey.currentState?.model; final GlobalKey<_MessageListState> _messageListKey = GlobalKey(); + @override + bool? get markReadOnScroll => _markReadOnScroll; + bool? _markReadOnScroll; + @override + set markReadOnScroll(bool? value) { + setState(() { + _markReadOnScroll = value; + }); + } + + final _revealedMutedMessages = RevealedMutedMessagesState(); + + @override + void revealMutedMessage(int messageId) { + _revealedMutedMessages._add(messageId); + } + + @override + void unrevealMutedMessage(int messageId) { + _revealedMutedMessages._remove(messageId); + } + @override void initState() { super.initState(); @@ -238,55 +311,19 @@ class _MessageListPageState extends State implements MessageLis @override Widget build(BuildContext context) { - final store = PerAccountStoreWidget.of(context); - final messageListTheme = MessageListTheme.of(context); - final zulipLocalizations = ZulipLocalizations.of(context); - - final Color? appBarBackgroundColor; - bool removeAppBarBottomBorder = false; - switch(narrow) { - case CombinedFeedNarrow(): - case MentionsNarrow(): - case StarredMessagesNarrow(): - appBarBackgroundColor = null; // i.e., inherit - - case ChannelNarrow(:final streamId): - case TopicNarrow(:final streamId): - final subscription = store.subscriptions[streamId]; - appBarBackgroundColor = subscription != null - ? colorSwatchFor(context, subscription).barBackground - : messageListTheme.unsubscribedStreamRecipientHeaderBg; - // All recipient headers will match this color; remove distracting line - // (but are recipient headers even needed for topic narrows?) - removeAppBarBottomBorder = true; - - case DmNarrow(): - appBarBackgroundColor = messageListTheme.dmRecipientHeaderBg; - // All recipient headers will match this color; remove distracting line - // (but are recipient headers even needed?) - removeAppBarBottomBorder = true; - } - - List? actions; - if (narrow case TopicNarrow(:final streamId)) { - (actions ??= []).add(IconButton( - icon: const Icon(ZulipIcons.message_feed), - tooltip: zulipLocalizations.channelFeedButtonTooltip, - onPressed: () => Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: ChannelNarrow(streamId))))); + final Anchor initAnchor; + if (narrow is KeywordSearchNarrow) { + initAnchor = AnchorCode.newest; + } else if (widget.initAnchorMessageId != null) { + initAnchor = NumericAnchor(widget.initAnchorMessageId!); + } else { + final globalSettings = GlobalStoreWidget.settingsOf(context); + final useFirstUnread = globalSettings.shouldVisitFirstUnread(narrow: narrow); + initAnchor = useFirstUnread ? AnchorCode.firstUnread : AnchorCode.newest; } - return Scaffold( - appBar: ZulipAppBar( - buildTitle: (willCenterTitle) => - MessageListAppBarTitle(narrow: narrow, willCenterTitle: willCenterTitle), - actions: actions, - backgroundColor: appBarBackgroundColor, - shape: removeAppBarBottomBorder - ? const Border() - : null, // i.e., inherit - ), + Widget result = Scaffold( + appBar: _MessageListAppBar.build(context, narrow: narrow), // TODO question for Vlad: for a stream view, should we set the Scaffold's // [backgroundColor] based on stream color, as in this frame: // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=132%3A9684&mode=dev @@ -294,7 +331,8 @@ class _MessageListPageState extends State implements MessageLis // we matched to the Figma in 21dbae120. See another frame, which uses that: // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=147%3A9088&mode=dev body: Builder( - builder: (BuildContext context) => Column( + builder: (BuildContext context) { + return Column( // Children are expected to take the full horizontal space // and handle the horizontal device insets. // The bottom inset should be handled by the last child only. @@ -314,11 +352,147 @@ class _MessageListPageState extends State implements MessageLis child: MessageList( key: _messageListKey, narrow: narrow, + initAnchor: initAnchor, onNarrowChanged: _narrowChanged, + markReadOnScroll: markReadOnScroll, ))), if (ComposeBox.hasComposeBox(narrow)) ComposeBox(key: _composeBoxKey, narrow: narrow) - ]))); + ]); + })); + + // Insert a PageRoot here (under MessageListPage), + // to provide a context that can be used for MessageListPage.ancestorOf. + result = PageRoot(child: result); + + result = _RevealedMutedMessagesProvider(state: _revealedMutedMessages, + child: result); + + return result; + } +} + +// Conceptually this should be a widget class. But it needs to be a +// PreferredSizeWidget, with the `preferredSize` that the underlying AppBar +// will have... and there's currently no good way to get that value short of +// constructing the whole AppBar widget with all its properties. +// So this has to be built eagerly by its parent's build method, +// making it a build function rather than a widget. Discussion: +// https://github.com/zulip/zulip-flutter/pull/1662#discussion_r2183471883 +// Still we can organize it on a class, with the name the widget would have. +// TODO(upstream): AppBar should expose a bit more API so that it's possible +// to customize by composition in a reasonable way. +abstract class _MessageListAppBar { + static AppBar build(BuildContext context, {required Narrow narrow}) { + final store = PerAccountStoreWidget.of(context); + final messageListTheme = MessageListTheme.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final Color? appBarBackgroundColor; + bool removeAppBarBottomBorder = false; + switch(narrow) { + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + appBarBackgroundColor = null; // i.e., inherit + + case ChannelNarrow(:final streamId): + case TopicNarrow(:final streamId): + final subscription = store.subscriptions[streamId]; + appBarBackgroundColor = + colorSwatchFor(context, subscription).barBackground; + // All recipient headers will match this color; remove distracting line + // (but are recipient headers even needed for topic narrows?) + removeAppBarBottomBorder = true; + + case DmNarrow(): + appBarBackgroundColor = messageListTheme.dmRecipientHeaderBg; + // All recipient headers will match this color; remove distracting line + // (but are recipient headers even needed?) + removeAppBarBottomBorder = true; + } + + List actions = []; + switch (narrow) { + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + case DmNarrow(): + break; + case ChannelNarrow(:final streamId): + actions.add(_TopicListButton(streamId: streamId)); + case TopicNarrow(:final streamId): + actions.add(IconButton( + icon: const Icon(ZulipIcons.message_feed), + tooltip: zulipLocalizations.channelFeedButtonTooltip, + onPressed: () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: ChannelNarrow(streamId))))); + actions.add(_TopicListButton(streamId: streamId)); + } + + return ZulipAppBar( + centerTitle: switch (narrow) { + CombinedFeedNarrow() || ChannelNarrow() + || TopicNarrow() || DmNarrow() + || MentionsNarrow() || StarredMessagesNarrow() + => null, + KeywordSearchNarrow() + => false, + }, + buildTitle: (willCenterTitle) => + MessageListAppBarTitle(narrow: narrow, willCenterTitle: willCenterTitle), + actions: actions, + backgroundColor: appBarBackgroundColor, + shape: removeAppBarBottomBorder + ? const Border() + : null, // i.e., inherit + ); + } +} + +class RevealedMutedMessagesState extends ChangeNotifier { + final Set _revealedMessages = {}; + + bool isMutedMessageRevealed(int messageId) => + _revealedMessages.contains(messageId); + + void _add(int messageId) { + _revealedMessages.add(messageId); + notifyListeners(); + } + + void _remove(int messageId) { + _revealedMessages.remove(messageId); + notifyListeners(); + } +} + +class _RevealedMutedMessagesProvider extends InheritedNotifier { + const _RevealedMutedMessagesProvider({ + required RevealedMutedMessagesState state, + required super.child, + }) : super(notifier: state); + + RevealedMutedMessagesState get state => notifier!; +} + +class _TopicListButton extends StatelessWidget { + const _TopicListButton({required this.streamId}); + + final int streamId; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return IconButton( + icon: const Icon(ZulipIcons.topics), + tooltip: zulipLocalizations.topicsButtonTooltip, + onPressed: () => Navigator.push(context, + TopicListPage.buildRoute(context: context, + streamId: streamId))); } } @@ -335,9 +509,18 @@ class MessageListAppBarTitle extends StatelessWidget { Widget _buildStreamRow(BuildContext context, { ZulipStream? stream, }) { + final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); + // A null [Icon.icon] makes a blank space. - final icon = stream != null ? iconDataForStream(stream) : null; + IconData? icon; + Color? iconColor; + if (stream != null) { + icon = iconDataForStream(stream); + iconColor = colorSwatchFor(context, store.subscriptions[stream.streamId]) + .iconOnBarBackground; + } + return Row( mainAxisSize: MainAxisSize.min, // TODO(design): The vertical alignment of the stream privacy icon is a bit ad hoc. @@ -345,7 +528,7 @@ class MessageListAppBarTitle extends StatelessWidget { // https://github.com/zulip/zulip-flutter/pull/219#discussion_r1281024746 crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(size: 16, icon), + Icon(size: 16, color: iconColor, icon), const SizedBox(width: 4), Flexible(child: Text( stream?.name ?? zulipLocalizations.unknownChannelName)), @@ -364,15 +547,15 @@ class MessageListAppBarTitle extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - Flexible(child: Text(topic.displayName, style: const TextStyle( + Flexible(child: Text(topic.displayName ?? store.realmEmptyTopicDisplayName, style: TextStyle( fontSize: 13, + fontStyle: topic.displayName == null ? FontStyle.italic : null, ).merge(weightVariableTextStyle(context)))), if (icon != null) Padding( padding: const EdgeInsetsDirectional.only(start: 4), child: Icon(icon, - // TODO(design) copies the recipient header in web; is there a better color? - color: designVariables.colorMessageHeaderIconInteractive, size: 14)), + color: designVariables.title.withFadedAlpha(0.5), size: 14)), ]); } @@ -393,40 +576,161 @@ class MessageListAppBarTitle extends StatelessWidget { case ChannelNarrow(:var streamId): final store = PerAccountStoreWidget.of(context); final stream = store.streams[streamId]; - return _buildStreamRow(context, stream: stream); - - case TopicNarrow(:var streamId, :var topic): - final store = PerAccountStoreWidget.of(context); - final stream = store.streams[streamId]; + final alignment = willCenterTitle + ? Alignment.center + : AlignmentDirectional.centerStart; return SizedBox( width: double.infinity, child: GestureDetector( behavior: HitTestBehavior.translucent, - onLongPress: () => showTopicActionSheet(context, - channelId: streamId, topic: topic), - child: Column( - crossAxisAlignment: willCenterTitle ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ - _buildStreamRow(context, stream: stream), - _buildTopicRow(context, stream: stream, topic: topic), - ]))); + onLongPress: () { + showChannelActionSheet(context, channelId: streamId); + }, + child: Align(alignment: alignment, + child: _buildStreamRow(context, stream: stream)))); + + case TopicNarrow(:var streamId, :var topic): + final store = PerAccountStoreWidget.of(context); + final stream = store.streams[streamId]; + final alignment = willCenterTitle + ? Alignment.center + : AlignmentDirectional.centerStart; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: () { + showChannelActionSheet(context, channelId: streamId); + }, + child: Align(alignment: alignment, + child: _buildStreamRow(context, stream: stream))), + GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: () { + final someMessage = MessageListPage.ancestorOf(context) + .model?.messages.lastOrNull; + // 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: Align(alignment: alignment, + child: _buildTopicRow(context, stream: stream, topic: topic))), + ]); case DmNarrow(:var otherRecipientIds): final store = PerAccountStoreWidget.of(context); if (otherRecipientIds.isEmpty) { return Text(zulipLocalizations.dmsWithYourselfPageTitle); } else { - final names = otherRecipientIds.map( - (id) => store.users[id]?.fullName ?? zulipLocalizations.unknownUserName); + final names = otherRecipientIds.map(store.userDisplayName); // TODO show avatars return Text( zulipLocalizations.dmsWithOthersPageTitle(names.join(', '))); } + + case KeywordSearchNarrow(): + assert(!willCenterTitle); + return _SearchBar(onSubmitted: (narrow) { + MessageListPage.ancestorOf(context).model!.renarrowAndFetch(narrow); + }); } } } +class _SearchBar extends StatefulWidget { + const _SearchBar({required this.onSubmitted}); + + final void Function(KeywordSearchNarrow) onSubmitted; + + @override + State<_SearchBar> createState() => _SearchBarState(); +} + +class _SearchBarState extends State<_SearchBar> { + late TextEditingController _controller; + + static KeywordSearchNarrow _valueToNarrow(String value) => + KeywordSearchNarrow(value.trim()); + + @override + void initState() { + _controller = TextEditingController(); + super.initState(); + } + + void _handleSubmitted(String value) { + widget.onSubmitted(_valueToNarrow(value)); + } + + void _clearInput() { + _controller.clear(); + _handleSubmitted(''); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return TextField( + controller: _controller, + autocorrect: false, + + // Servers as of 2025-07 seem to require straight quotes for the + // "exact match"- style query. (N.B. the doc says this param is iOS-only.) + smartQuotesType: SmartQuotesType.disabled, + + autofocus: true, + onSubmitted: _handleSubmitted, + cursorColor: designVariables.textInput, + style: TextStyle( + color: designVariables.textInput, + fontSize: 19, + height: 28 / 19, + ), + textInputAction: TextInputAction.search, + decoration: InputDecoration( + isDense: true, + hintText: zulipLocalizations.searchMessagesHintText, + hintStyle: TextStyle(color: designVariables.labelSearchPrompt), + prefixIcon: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(8, 8, 0, 8), + child: Icon(size: 24, ZulipIcons.search)), + prefixIconColor: designVariables.labelSearchPrompt, + prefixIconConstraints: BoxConstraints(), + suffixIcon: IconButton( + tooltip: zulipLocalizations.searchMessagesClearButtonTooltip, + onPressed: _clearInput, + // This and `suffixIconConstraints` allow 42px square touch target. + visualDensity: VisualDensity.compact, + highlightColor: Colors.transparent, + style: ButtonStyle( + padding: WidgetStatePropertyAll(EdgeInsets.zero), + splashFactory: NoSplash.splashFactory, + ), + iconSize: 24, + icon: Icon(ZulipIcons.remove)), + suffixIconColor: designVariables.textMessageMuted, + suffixIconConstraints: BoxConstraints(minWidth: 42, minHeight: 42), + contentPadding: const EdgeInsetsDirectional.symmetric(vertical: 7), + filled: true, + fillColor: designVariables.bgSearchInput, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none), + )); + } +} + + /// The approximate height of a short message in the message list. const _kShortMessageHeight = 80; @@ -447,19 +751,32 @@ const kFetchMessagesBufferPixels = (kMessageListFetchBatchSize / 2) * _kShortMes /// When there is no [ComposeBox], also takes responsibility /// for dealing with the bottom inset. class MessageList extends StatefulWidget { - const MessageList({super.key, required this.narrow, required this.onNarrowChanged}); + const MessageList({ + super.key, + required this.narrow, + required this.initAnchor, + required this.onNarrowChanged, + required this.markReadOnScroll, + }); final Narrow narrow; + final Anchor initAnchor; final void Function(Narrow newNarrow) onNarrowChanged; + final bool? markReadOnScroll; @override State createState() => _MessageListState(); } class _MessageListState extends State with PerAccountStoreAwareStateMixin { - MessageListView? model; - final ScrollController scrollController = ScrollController(); - final ValueNotifier _scrollToBottomVisibleValue = ValueNotifier(false); + final GlobalKey _scrollViewKey = GlobalKey(); + + MessageListView get model => _model!; + MessageListView? _model; + + final MessageListScrollController scrollController = MessageListScrollController(); + + final ValueNotifier _scrollToBottomVisible = ValueNotifier(false); @override void initState() { @@ -469,40 +786,194 @@ class _MessageListState extends State with PerAccountStoreAwareStat @override void onNewStore() { // TODO(#464) try to keep using old model until new one gets messages - _initModel(PerAccountStoreWidget.of(context)); + final anchor = _model == null ? widget.initAnchor : _model!.anchor; + _model?.dispose(); + _initModel(PerAccountStoreWidget.of(context), anchor); } @override void dispose() { - model?.dispose(); + _model?.dispose(); scrollController.dispose(); - _scrollToBottomVisibleValue.dispose(); + _scrollToBottomVisible.dispose(); super.dispose(); } - void _initModel(PerAccountStore store) { - model = MessageListView.init(store: store, narrow: widget.narrow); - model!.addListener(_modelChanged); - model!.fetchInitial(); + void _initModel(PerAccountStore store, Anchor anchor) { + var narrow = widget.narrow; + if (narrow is TopicNarrow) { + // Normalize topic name. See #1717. + narrow = TopicNarrow(narrow.streamId, + store.processTopicLikeServer(narrow.topic), + with_: narrow.with_); + if (narrow != widget.narrow) { + SchedulerBinding.instance.scheduleFrameCallback((_) { + widget.onNarrowChanged(narrow); + }); + } + } + _model = MessageListView.init(store: store, + narrow: narrow, anchor: anchor); + model.addListener(_modelChanged); + model.fetchInitial(); } + bool _prevFetched = false; + void _modelChanged() { - if (model!.narrow != widget.narrow) { - // A message move event occurred, where propagate mode is - // [PropagateMode.changeAll] or [PropagateMode.changeLater]. - widget.onNarrowChanged(model!.narrow); + // When you're scrolling quickly, our mark-as-read requests include the + // messages *between* _messagesRecentlyInViewport and the messages currently + // in view, so that messages don't get left out because you were scrolling + // so fast that they never rendered onscreen. + // + // Here, the onscreen messages might be totally different, + // and not because of scrolling; e.g. because the narrow changed. + // Avoid "filling in" a mark-as-read request with totally wrong messages, + // by forgetting the old range. + _messagesRecentlyInViewport = null; + + if (model.narrow != widget.narrow) { + // Either: + // - A message move event occurred, where propagate mode is + // [PropagateMode.changeAll] or [PropagateMode.changeLater]. Or: + // - We fetched a "with" / topic-permalink narrow, and the response + // redirected us to the new location of the operand message ID. + widget.onNarrowChanged(model.narrow); } + // TODO when model reset, reset scroll setState(() { // The actual state lives in the [MessageListView] model. // This method was called because that just changed. }); + + if (!_prevFetched && model.fetched && model.messages.isEmpty) { + // If the fetch came up empty, there's nothing to read, + // so opening the keyboard won't be bothersome and could be helpful. + // It's definitely helpful if we got here from the new-DM page. + MessageListPage.ancestorOf(context) + .composeBoxState?.controller.requestFocusIfUnfocused(); + } + _prevFetched = model.fetched; + } + + /// Find the range of message IDs on screen, as a (first, last) tuple, + /// or null if no messages are onscreen. + /// + /// A message is considered onscreen if its bottom edge is in the viewport. + /// + /// Ignores outbox messages. + (int, int)? _findMessagesInViewport() { + final scrollViewElement = _scrollViewKey.currentContext as Element; + final scrollViewRenderObject = scrollViewElement.renderObject as RenderBox; + + int? first; + int? last; + void visit(Element element) { + final widget = element.widget; + switch (widget) { + case RecipientHeader(): + case DateSeparator(): + case MarkAsReadWidget(): + // MessageItems won't be descendants of these + return; + + case MessageItem(item: MessageListOutboxMessageItem()): + return; // ignore outbox + + case MessageItem(item: MessageListMessageItem(:final message)): + final isInViewport = _isMessageItemInViewport( + element, scrollViewRenderObject: scrollViewRenderObject); + if (isInViewport) { + if (first == null) { + assert(last == null); + first = message.id; + last = message.id; + return; + } + if (message.id < first!) { + first = message.id; + } + if (last! < message.id) { + last = message.id; + } + } + return; // no need to look for more MessageItems inside this one + + default: + element.visitChildElements(visit); + } + } + scrollViewElement.visitChildElements(visit); + + if (first == null) { + assert(last == null); + return null; + } + return (first!, last!); + } + + bool _isMessageItemInViewport( + Element element, { + required RenderBox scrollViewRenderObject, + }) { + assert(element.widget is MessageItem + && (element.widget as MessageItem).item is MessageListMessageItem); + final viewportHeight = scrollViewRenderObject.size.height; + + final messageRenderObject = element.renderObject as RenderBox; + + final messageBottom = messageRenderObject.localToGlobal( + Offset(0, messageRenderObject.size.height), + ancestor: scrollViewRenderObject).dy; + + return 0 < messageBottom && messageBottom <= viewportHeight; + } + + (int, int)? _messagesRecentlyInViewport; + + void _markReadFromScroll() { + final currentRange = _findMessagesInViewport(); + if (currentRange == null) return; + + final (currentFirst, currentLast) = currentRange; + final (prevFirst, prevLast) = _messagesRecentlyInViewport ?? (null, null); + + // ("Hull" as in the "convex hull" around the old and new ranges.) + final firstOfHull = switch ((prevFirst, currentFirst)) { + (int previous, int current) => previous < current ? previous : current, + ( _, int current) => current, + }; + + final lastOfHull = switch ((prevLast, currentLast)) { + (int previous, int current) => previous > current ? previous : current, + ( _, int current) => current, + }; + + final sublist = model.getMessagesRange(firstOfHull, lastOfHull); + if (sublist == null) { + _messagesRecentlyInViewport = null; + return; + } + model.store.markReadFromScroll(sublist.map((message) => message.id)); + + _messagesRecentlyInViewport = currentRange; + } + + bool _effectiveMarkReadOnScroll() { + if (!MessageListPage.debugEnableMarkReadOnScroll) return false; + return widget.markReadOnScroll + ?? GlobalStoreWidget.settingsOf(context).markReadOnScrollForNarrow(widget.narrow); } void _handleScrollMetrics(ScrollMetrics scrollMetrics) { + if (_effectiveMarkReadOnScroll()) { + _markReadFromScroll(); + } + if (scrollMetrics.extentAfter == 0) { - _scrollToBottomVisibleValue.value = false; + _scrollToBottomVisible.value = false; } else { - _scrollToBottomVisibleValue.value = true; + _scrollToBottomVisible.value = true; } if (scrollMetrics.extentBefore < kFetchMessagesBufferPixels) { @@ -512,7 +983,10 @@ class _MessageListState extends State with PerAccountStoreAwareStat // but makes things a bit more complicated to reason about. // The cause seems to be that this gets called again with maxScrollExtent // still not yet updated to account for the newly-added messages. - model?.fetchOlder(); + model.fetchOlder(); + } + if (scrollMetrics.extentAfter < kFetchMessagesBufferPixels) { + model.fetchNewer(); } } @@ -533,8 +1007,20 @@ class _MessageListState extends State with PerAccountStoreAwareStat @override Widget build(BuildContext context) { - assert(model != null); - if (!model!.fetched) return const Center(child: CircularProgressIndicator()); + final zulipLocalizations = ZulipLocalizations.of(context); + + if (!model.fetched) return const Center(child: CircularProgressIndicator()); + + if (model.items.isEmpty && model.haveNewest && model.haveOldest) { + final String message; + if (widget.narrow is KeywordSearchNarrow) { + message = zulipLocalizations.emptyMessageListSearch; + } else { + message = zulipLocalizations.emptyMessageList; + } + + return PageBodyEmptyContentPlaceholder(message: message); + } // Pad the left and right insets, for small devices in landscape. return SafeArea( @@ -565,17 +1051,25 @@ class _MessageListState extends State with PerAccountStoreAwareStat // MessageList's dartdoc. child: SafeArea( child: ScrollToBottomButton( + model: model, scrollController: scrollController, - visibleValue: _scrollToBottomVisibleValue))), + visible: _scrollToBottomVisible))), ]))))); } Widget _buildListView(BuildContext context) { - final length = model!.items.length; const centerSliverKey = ValueKey('center sliver'); - final zulipLocalizations = ZulipLocalizations.of(context); - Widget sliver = SliverStickyHeaderList( + // The list has two slivers: a top sliver growing upward, + // and a bottom sliver growing downward. + // Each sliver has some of the items from `model.items`. + final totalItems = model.items.length; + final topItems = model.middleItem; + final bottomItems = totalItems - topItems; + + // The top sliver has its child 0 as the item just before the + // sliver boundary, child 1 as the item before that, and so on. + Widget topSliver = SliverStickyHeaderList( headerPlacement: HeaderPlacement.scrollingStart, delegate: SliverChildBuilderDelegate( // To preserve state across rebuilds for individual [MessageItem] @@ -594,32 +1088,80 @@ class _MessageListState extends State with PerAccountStoreAwareStat // have state that needs to be preserved have not been given keys // and will not trigger this callback. findChildIndexCallback: (Key key) { - final valueKey = key as ValueKey; - final index = model!.findItemWithMessageId(valueKey.value); - if (index == -1) return null; - return length - 1 - (index - 3); + final messageId = (key as ValueKey).value; + final itemIndex = model.findItemWithMessageId(messageId); + if (itemIndex == -1) return null; + final childIndex = totalItems - 1 - (itemIndex + bottomItems); + if (childIndex < 0) return null; + return childIndex; }, - childCount: length + 3, - (context, i) { - // To reinforce that the end of the feed has been reached: - // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603 - if (i == 0) return const SizedBox(height: 36); - - if (i == 1) return MarkAsReadWidget(narrow: widget.narrow); + childCount: topItems + 1, + (context, childIndex) { + if (childIndex == topItems) return _buildStartCap(); + + final itemIndex = totalItems - 1 - (childIndex + bottomItems); + final data = model.items[itemIndex]; + final item = _buildItem(data, isLastInFeed: itemIndex == totalItems - 1); + return item; + })); - if (i == 2) return TypingStatusWidget(narrow: widget.narrow); + // The bottom sliver has its child 0 as the item just after the + // sliver boundary (just after child 0 of the top sliver), + // its child 1 as the next item after that, and so on. + Widget bottomSliver = SliverStickyHeaderList( + key: centerSliverKey, + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildBuilderDelegate( + // To preserve state across rebuilds for individual [MessageItem] + // widgets as the size of [MessageListView.items] changes we need + // to match old widgets by their key to their new position in + // the list. + // + // The keys are of type [ValueKey] with a value of [Message.id] + // and here we use a O(log n) binary search method. This could + // be improved but for now it only triggers for materialized + // widgets. As a simple test, flinging through All Messages in + // CZO on a Pixel 5, this only runs about 10 times per rebuild + // and the timing for each call is <100 microseconds. + // + // Non-message items (e.g., start and end markers) that do not + // have state that needs to be preserved have not been given keys + // and will not trigger this callback. + findChildIndexCallback: (Key key) { + final messageId = (key as ValueKey).value; + final itemIndex = model.findItemWithMessageId(messageId); + if (itemIndex == -1) return null; + final childIndex = itemIndex - topItems; + if (childIndex < 0) return null; + return childIndex; + }, + childCount: bottomItems + 1, + (context, childIndex) { + if (childIndex == bottomItems) return _buildEndCap(); - final data = model!.items[length - 1 - (i - 3)]; - return _buildItem(zulipLocalizations, data, i); + final itemIndex = topItems + childIndex; + final data = model.items[itemIndex]; + return _buildItem(data, isLastInFeed: itemIndex == totalItems - 1); })); if (!ComposeBox.hasComposeBox(widget.narrow)) { // 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); + bottomSliver = SliverSafeArea(key: bottomSliver.key, sliver: bottomSliver); + topSliver = MediaQuery.removePadding(context: context, + // In the top sliver, forget the bottom inset; + // we're having the bottom sliver take care of it. + removeBottom: true, + // (Also forget the left and right insets; the outer SafeArea, above, + // does that, but the `context` we're passing to this `removePadding` + // is from outside that SafeArea, so we need to repeat it.) + removeLeft: true, removeRight: true, + child: topSliver); } - return CustomScrollView( + return MessageListScrollView( + key: _scrollViewKey, + // TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or // similar) if that is ever offered: // https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849 @@ -632,32 +1174,49 @@ class _MessageListState extends State with PerAccountStoreAwareStat }, controller: scrollController, - semanticChildCount: length + 2, - anchor: 1.0, + semanticChildCount: totalItems, // TODO(#537): what's the right value for this? center: centerSliverKey, + paintOrder: SliverPaintOrder.firstIsTop, slivers: [ - sliver, + topSliver, + bottomSliver, + ]); + } + + Widget _buildStartCap() { + // If we're done fetching older messages, show that. + // Else if we're busy with fetching, then show a loading indicator. + // + // This applies even if the fetch is over, but failed, and we're still + // in backoff from it; and even if the fetch is/was for the other direction. + // The loading indicator really means "busy, working on it"; and that's the + // right summary even if the fetch is internally queued behind other work. + return model.haveOldest ? const _MessageListHistoryStart() + : model.busyFetchingMore ? const _MessageListLoadingMore() + : const SizedBox.shrink(); + } - // This is a trivial placeholder that occupies no space. Its purpose is - // to have the key that's passed to [ScrollView.center], and so to cause - // the above [SliverStickyHeaderList] to run from bottom to top. - const SliverToBoxAdapter(key: centerSliverKey), + Widget _buildEndCap() { + if (model.haveNewest) { + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + TypingStatusWidget(narrow: widget.narrow), + // TODO perhaps offer mark-as-read even when not done fetching? + MarkAsReadWidget(narrow: widget.narrow), + // To reinforce that the end of the feed has been reached: + // https://chat.zulip.org/#narrow/channel/48-mobile/topic/space.20at.20end.20of.20thread/near/2203391 + const SizedBox(height: 12), ]); + } else if (model.busyFetchingMore) { + // See [_buildStartCap] for why this condition shows a loading indicator. + return const _MessageListLoadingMore(); + } else { + return SizedBox.shrink(); + } } - Widget _buildItem(ZulipLocalizations zulipLocalizations, MessageListItem data, int i) { + Widget _buildItem(MessageListItem data, {required bool isLastInFeed}) { switch (data) { - case MessageListHistoryStartItem(): - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: Text(zulipLocalizations.noEarlierMessages))); // TODO use an icon - case MessageListLoadingItem(): - return const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - child: CircularProgressIndicator())); // TODO perhaps a different indicator case MessageListRecipientHeaderItem(): final header = RecipientHeader(message: data.message, narrow: widget.narrow); return StickyHeaderItem(allowOverflow: true, @@ -671,34 +1230,88 @@ class _MessageListState extends State with PerAccountStoreAwareStat final header = RecipientHeader(message: data.message, narrow: widget.narrow); return MessageItem( key: ValueKey(data.message.id), + narrow: widget.narrow, header: header, - trailingWhitespace: i == 1 ? 8 : 11, + isLastInFeed: isLastInFeed, + item: data); + case MessageListOutboxMessageItem(): + final header = RecipientHeader(message: data.message, narrow: widget.narrow); + return MessageItem( + narrow: widget.narrow, + header: header, + isLastInFeed: isLastInFeed, item: data); } } } -class ScrollToBottomButton extends StatelessWidget { - const ScrollToBottomButton({super.key, required this.scrollController, required this.visibleValue}); +class _MessageListHistoryStart extends StatelessWidget { + const _MessageListHistoryStart(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Text(zulipLocalizations.noEarlierMessages))); // TODO use an icon + } +} - final ValueNotifier visibleValue; - final ScrollController scrollController; +class _MessageListLoadingMore extends StatelessWidget { + const _MessageListLoadingMore(); - Future _navigateToBottom() { - final distance = scrollController.position.pixels; - final durationMsAtSpeedLimit = (1000 * distance / 8000).ceil(); - final durationMs = max(300, durationMsAtSpeedLimit); - return scrollController.animateTo( - 0, - duration: Duration(milliseconds: durationMs), - curve: Curves.ease); + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: CircularProgressIndicator())); // TODO perhaps a different indicator + } +} + +class ScrollToBottomButton extends StatelessWidget { + const ScrollToBottomButton({ + super.key, + required this.model, + required this.scrollController, + required this.visible, + }); + + final MessageListView model; + final MessageListScrollController scrollController; + final ValueNotifier visible; + + void _scrollToBottom() { + if (model.haveNewest) { + // Scrolling smoothly from here to the bottom won't require any requests + // to the server. + // It also probably isn't *that* far away: the user must have scrolled + // here from there (or from near enough that a fetch reached there), + // so scrolling back there -- at top speed -- shouldn't take too long. + // Go for it. + scrollController.position.scrollToEnd(); + } else { + // This message list doesn't have the messages for the bottom of history. + // There could be quite a lot of history between here and there -- + // for example, at first unread in the combined feed or a busy channel, + // for a user who has some old unreads going back months and years. + // In that case trying to scroll smoothly to the bottom is hopeless. + // + // Given that there were at least 100 messages between this message list's + // initial anchor and the end of history (or else `fetchInitial` would + // have reached the end at the outset), that situation is very likely. + // Even if the end is close by, it's at least one fetch away. + // Instead of scrolling, jump to the end, which is always just one fetch. + model.jumpToEnd(); + } } @override Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); return ValueListenableBuilder( - valueListenable: visibleValue, + valueListenable: visible, builder: (BuildContext context, bool value, Widget? child) { return (value && child != null) ? child : const SizedBox.shrink(); }, @@ -709,7 +1322,7 @@ class ScrollToBottomButton extends StatelessWidget { iconSize: 40, // Web has the same color in light and dark mode. color: const HSLColor.fromAHSL(0.5, 240, 0.96, 0.68).toColor(), - onPressed: _navigateToBottom)); + onPressed: _scrollToBottom)); } } @@ -751,16 +1364,17 @@ class _TypingStatusWidgetState extends State with PerAccount if (narrow is! SendableNarrow) return const SizedBox(); final store = PerAccountStoreWidget.of(context); - final localizations = ZulipLocalizations.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); final typistIds = model!.typistIdsInNarrow(narrow); - if (typistIds.isEmpty) return const SizedBox(); - final text = switch (typistIds.length) { - 1 => localizations.onePersonTyping( - store.users[typistIds.first]?.fullName ?? localizations.unknownUserName), - 2 => localizations.twoPeopleTyping( - store.users[typistIds.first]?.fullName ?? localizations.unknownUserName, - store.users[typistIds.last]?.fullName ?? localizations.unknownUserName), - _ => localizations.manyPeopleTyping, + final filteredTypistIds = typistIds.whereNot(store.isUserMuted); + if (filteredTypistIds.isEmpty) return const SizedBox(); + final text = switch (filteredTypistIds.length) { + 1 => zulipLocalizations.onePersonTyping( + store.userDisplayName(filteredTypistIds.first)), + 2 => zulipLocalizations.twoPeopleTyping( + store.userDisplayName(filteredTypistIds.first), + store.userDisplayName(filteredTypistIds.last)), + _ => zulipLocalizations.manyPeopleTyping, }; return Padding( @@ -788,7 +1402,7 @@ class _MarkAsReadWidgetState extends State { void _handlePress(BuildContext context) async { if (!context.mounted) return; setState(() => _loading = true); - await markNarrowAsRead(context, widget.narrow); + await ZulipAction.markNarrowAsRead(context, widget.narrow); setState(() => _loading = false); } @@ -797,15 +1411,15 @@ class _MarkAsReadWidgetState extends State { final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); final unreadCount = store.unreads.countInNarrow(widget.narrow); - final areMessagesRead = unreadCount == 0; + final shouldHide = unreadCount == 0; final messageListTheme = MessageListTheme.of(context); return IgnorePointer( - ignoring: areMessagesRead, + ignoring: shouldHide, child: MarkAsReadAnimation( loading: _loading, - hidden: areMessagesRead, + hidden: shouldHide, child: SizedBox(width: double.infinity, // Design referenced from: // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=132-9684&mode=design&t=jJwHzloKJ0TMOG4M-0 @@ -837,7 +1451,7 @@ class _MarkAsReadWidgetState extends State { backgroundColor: WidgetStatePropertyAll(messageListTheme.unreadMarker), ), onPressed: _loading ? null : () => _handlePress(context), - icon: const Icon(Icons.playlist_add_check), + icon: const Icon(ZulipIcons.message_checked), label: Text(zulipLocalizations.markAllAsReadLabel)))))); } } @@ -888,15 +1502,19 @@ class _MarkAsReadAnimationState extends State { class RecipientHeader extends StatelessWidget { const RecipientHeader({super.key, required this.message, required this.narrow}); - final Message message; + final MessageBase message; final Narrow narrow; @override Widget build(BuildContext context) { final message = this.message; return switch (message) { - StreamMessage() => StreamMessageRecipientHeader(message: message, narrow: narrow), - DmMessage() => DmRecipientHeader(message: message, narrow: narrow), + MessageBase() => + StreamMessageRecipientHeader(message: message, narrow: narrow), + MessageBase() => + DmRecipientHeader(message: message, narrow: narrow), + MessageBase() => + throw StateError('Bad concrete subclass of MessageBase'), }; } } @@ -904,7 +1522,7 @@ class RecipientHeader extends StatelessWidget { class DateSeparator extends StatelessWidget { const DateSeparator({super.key, required this.message}); - final Message message; + final MessageBase message; @override Widget build(BuildContext context) { @@ -912,12 +1530,12 @@ class DateSeparator extends StatelessWidget { // to align with the vertically centered divider lines. const textBottomPadding = 2.0; - final messageListTheme = MessageListTheme.of(context); + final designVariables = DesignVariables.of(context); - final line = BorderSide(width: 0, color: messageListTheme.dateSeparator); + final line = BorderSide(width: 0, color: designVariables.foreground); // TODO(#681) use different color for DM messages - return ColoredBox(color: messageListTheme.streamMessageBgDefault, + return ColoredBox(color: designVariables.bgMessageRegular, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2), child: Row(children: [ @@ -945,30 +1563,46 @@ class DateSeparator extends StatelessWidget { class MessageItem extends StatelessWidget { const MessageItem({ super.key, + required this.narrow, required this.item, required this.header, - this.trailingWhitespace, + required this.isLastInFeed, }); - final MessageListMessageItem item; + final Narrow narrow; + final MessageListMessageBaseItem item; final Widget header; - final double? trailingWhitespace; + final bool isLastInFeed; @override Widget build(BuildContext context) { - final message = item.message; - final messageListTheme = MessageListTheme.of(context); + final designVariables = DesignVariables.of(context); + + final item = this.item; + Widget child = ColoredBox( + color: designVariables.bgMessageRegular, + child: Column(children: [ + switch (item) { + MessageListMessageItem() => MessageWithPossibleSender( + narrow: narrow, + item: item), + MessageListOutboxMessageItem() => OutboxMessageWithPossibleSender(item: item), + }, + // TODO write tests for this padding logic + if (isLastInFeed) + const SizedBox(height: 5) + else if (item.isLastInBlock) + const SizedBox(height: 11), + ])); + if (item case MessageListMessageItem(:final message)) { + child = _UnreadMarker( + isRead: message.flags.contains(MessageFlag.read), + child: child); + } return StickyHeaderItem( allowOverflow: !item.isLastInBlock, header: header, - child: _UnreadMarker( - isRead: message.flags.contains(MessageFlag.read), - child: ColoredBox( - color: messageListTheme.streamMessageBgDefault, - child: Column(children: [ - MessageWithPossibleSender(item: item), - if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!), - ])))); + child: child); } } @@ -1013,7 +1647,7 @@ class StreamMessageRecipientHeader extends StatelessWidget { required this.narrow, }); - final StreamMessage message; + final MessageBase message; final Narrow narrow; static bool _containsDifferentChannels(Narrow narrow) { @@ -1021,6 +1655,7 @@ class StreamMessageRecipientHeader extends StatelessWidget { case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): + case KeywordSearchNarrow(): return true; case ChannelNarrow(): @@ -1037,37 +1672,30 @@ class StreamMessageRecipientHeader extends StatelessWidget { // https://github.com/zulip/zulip-mobile/issues/5511 final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); + final messageListTheme = MessageListTheme.of(context); final zulipLocalizations = ZulipLocalizations.of(context); - final topic = message.topic; + final streamId = message.conversation.streamId; + final topic = message.conversation.topic; - final messageListTheme = MessageListTheme.of(context); - - final subscription = store.subscriptions[message.streamId]; - final Color backgroundColor; - final Color iconColor; - if (subscription != null) { - final swatch = colorSwatchFor(context, subscription); - backgroundColor = swatch.barBackground; - iconColor = swatch.iconOnBarBackground; - } else { - backgroundColor = messageListTheme.unsubscribedStreamRecipientHeaderBg; - iconColor = messageListTheme.recipientHeaderText; - } + final swatch = colorSwatchFor(context, store.subscriptions[streamId]); + final backgroundColor = swatch.barBackground; + final iconColor = swatch.iconOnBarBackground; final Widget streamWidget; if (!_containsDifferentChannels(narrow)) { streamWidget = const SizedBox(width: 16); } else { - final stream = store.streams[message.streamId]; + final stream = store.streams[streamId]; final streamName = stream?.name - ?? message.displayRecipient + ?? message.conversation.displayRecipient ?? zulipLocalizations.unknownChannelName; // TODO(log) streamWidget = GestureDetector( onTap: () => Navigator.push(context, MessageListPage.buildRoute(context: context, - narrow: ChannelNarrow(message.streamId))), + narrow: ChannelNarrow(streamId))), + onLongPress: () => showChannelActionSheet(context, channelId: streamId), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -1102,17 +1730,18 @@ class StreamMessageRecipientHeader extends StatelessWidget { child: Row( children: [ Flexible( - child: Text(topic.displayName, + child: Text(topic.displayName ?? store.realmEmptyTopicDisplayName, // TODO: Give a way to see the whole topic (maybe a // long-press interaction?) overflow: TextOverflow.ellipsis, - style: recipientHeaderTextStyle(context))), + style: recipientHeaderTextStyle(context, + fontStyle: topic.displayName == null ? FontStyle.italic : null, + ))), const SizedBox(width: 4), - // TODO(design) copies the recipient header in web; is there a better color? - Icon(size: 14, color: designVariables.colorMessageHeaderIconInteractive, + Icon(size: 14, color: designVariables.title.withFadedAlpha(0.5), // A null [Icon.icon] makes a blank space. iconDataForTopicVisibilityPolicy( - store.topicVisibilityPolicy(message.streamId, topic))), + store.topicVisibilityPolicy(streamId, topic))), ])); return GestureDetector( @@ -1125,7 +1754,9 @@ class StreamMessageRecipientHeader extends StatelessWidget { MessageListPage.buildRoute(context: context, narrow: TopicNarrow.ofMessage(message))), onLongPress: () => showTopicActionSheet(context, - channelId: message.streamId, topic: topic), + channelId: streamId, + topic: topic, + someMessageIdInTopic: message.id), child: ColoredBox( color: backgroundColor, child: Row( @@ -1148,7 +1779,7 @@ class DmRecipientHeader extends StatelessWidget { required this.narrow, }); - final DmMessage message; + final MessageBase message; final Narrow narrow; @override @@ -1156,18 +1787,19 @@ class DmRecipientHeader extends StatelessWidget { final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); final String title; - if (message.allRecipientIds.length > 1) { - title = zulipLocalizations.messageListGroupYouAndOthers(message.allRecipientIds - .where((id) => id != store.selfUserId) - .map((id) => store.users[id]?.fullName ?? zulipLocalizations.unknownUserName) - .sorted() - .join(", ")); + if (message.conversation.allRecipientIds.length > 1) { + title = zulipLocalizations.messageListGroupYouAndOthers( + message.conversation.allRecipientIds + .where((id) => id != store.selfUserId) + .map(store.userDisplayName) + .sorted() + .join(", ")); } else { - // TODO pick string; web has glitchy "You and $yourname" title = zulipLocalizations.messageListGroupYouWithYourself; } final messageListTheme = MessageListTheme.of(context); + final designVariables = DesignVariables.of(context); return GestureDetector( // When already in a DM narrow, disable tap interaction that would just @@ -1188,9 +1820,9 @@ class DmRecipientHeader extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: Icon( - color: messageListTheme.recipientHeaderText, + color: designVariables.title, size: 16, - ZulipIcons.user)), + ZulipIcons.two_person)), Expanded( child: Text(title, style: recipientHeaderTextStyle(context), @@ -1200,19 +1832,20 @@ class DmRecipientHeader extends StatelessWidget { } } -TextStyle recipientHeaderTextStyle(BuildContext context) { +TextStyle recipientHeaderTextStyle(BuildContext context, {FontStyle? fontStyle}) { return TextStyle( - color: MessageListTheme.of(context).recipientHeaderText, + color: DesignVariables.of(context).title, fontSize: 16, letterSpacing: proportionalLetterSpacing(context, 0.02, baseFontSize: 16), height: (18 / 16), + fontStyle: fontStyle, ).merge(weightVariableTextStyle(context, wght: 600)); } class RecipientHeaderDate extends StatelessWidget { const RecipientHeaderDate({super.key, required this.message}); - final Message message; + final MessageBase message; @override Widget build(BuildContext context) { @@ -1242,105 +1875,97 @@ class DateText extends StatelessWidget { @override Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); final messageListTheme = MessageListTheme.of(context); final zulipLocalizations = ZulipLocalizations.of(context); + final formattedTimestamp = MessageTimestampStyle.dateOnlyRelative.format( + timestamp, + now: ZulipBinding.instance.utcNow().toLocal(), + twentyFourHourTimeMode: store.userSettings.twentyFourHourTime, + zulipLocalizations: zulipLocalizations)!; return Text( style: TextStyle( - color: messageListTheme.dateSeparatorText, + color: messageListTheme.labelTime, fontSize: fontSize, height: height, // This is equivalent to css `all-small-caps`, see: // https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-caps#all-small-caps fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], ), - formatHeaderDate( - zulipLocalizations, - DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), - now: DateTime.now())); + formattedTimestamp); } } -@visibleForTesting -String formatHeaderDate( - ZulipLocalizations zulipLocalizations, - DateTime dateTime, { - required DateTime now, -}) { - assert(!dateTime.isUtc && !now.isUtc, - '`dateTime` and `now` need to be in local time.'); - - if (dateTime.year == now.year && - dateTime.month == now.month && - dateTime.day == now.day) { - return zulipLocalizations.today; - } - - final yesterday = now - .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0) - .add(const Duration(days: -1)); - if (dateTime.year == yesterday.year && - dateTime.month == yesterday.month && - dateTime.day == yesterday.day) { - return zulipLocalizations.yesterday; - } - - // If it is Dec 1 and you see a label that says `Dec 2` - // it could be misinterpreted as Dec 2 of the previous - // year. For times in the future, those still on the - // current day will show as today (handled above) and - // any dates beyond that show up with the year. - if (dateTime.year == now.year && dateTime.isBefore(now)) { - return DateFormat.MMMd().format(dateTime); - } else { - return DateFormat.yMMMd().format(dateTime); - } -} +class SenderRow extends StatelessWidget { + const SenderRow({super.key, required this.message, required this.timestampStyle}); -/// A Zulip message, showing the sender's name and avatar if specified. -// Design referenced from: -// - https://github.com/zulip/zulip-mobile/issues/5511 -// - https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=538%3A20849&mode=dev -class MessageWithPossibleSender extends StatelessWidget { - const MessageWithPossibleSender({super.key, required this.item}); + final MessageBase message; + final MessageTimestampStyle timestampStyle; - final MessageListMessageItem item; + bool _showAsMuted(BuildContext context, PerAccountStore store) { + final message = this.message; + if (!store.isUserMuted(message.senderId)) return false; + if (message is! Message) return false; // i.e., if an outbox message + final revealedMutedMessagesState = + MessageListPage.maybeRevealedMutedMessagesOf(context); + // The "unrevealed" state only exists in the message list, + // and we show a sender row in at least one place outside the message list + // (the message action sheet). + if (revealedMutedMessagesState == null) return false; + return !revealedMutedMessagesState.isMutedMessageRevealed(message.id); + } @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); final messageListTheme = MessageListTheme.of(context); final designVariables = DesignVariables.of(context); - final message = item.message; - final sender = store.users[message.senderId]; - - Widget? senderRow; - if (item.showSender) { - final time = _kMessageTimestampFormat - .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); - senderRow = Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + final sender = store.getUser(message.senderId); + final timestamp = timestampStyle + .format(message.timestamp, + now: DateTime.now(), + twentyFourHourTimeMode: store.userSettings.twentyFourHourTime, + zulipLocalizations: zulipLocalizations); + + final showAsMuted = _showAsMuted(context, store); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), + child: Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: localizedTextBaseline(context), children: [ Flexible( child: GestureDetector( - onTap: () => Navigator.push(context, + onTap: () => showAsMuted ? null : Navigator.push(context, ProfilePage.buildRoute(context: context, userId: message.senderId)), child: Row( children: [ - Avatar(size: 32, borderRadius: 3, + Avatar( + size: 32, + borderRadius: 3, + showPresence: false, + replaceIfMuted: showAsMuted, userId: message.senderId), const SizedBox(width: 8), Flexible( - child: Text(message.senderFullName, // TODO get from user data + child: Text(message is Message + ? store.senderDisplayName(message as Message, + replaceIfMuted: showAsMuted) + : store.userDisplayName(message.senderId), style: TextStyle( fontSize: 18, height: (22 / 18), - color: messageListTheme.senderName, + color: showAsMuted + ? designVariables.title.withFadedAlpha(0.5) + : designVariables.title, ).merge(weightVariableTextStyle(context, wght: 600)), overflow: TextOverflow.ellipsis)), + UserStatusEmoji(userId: message.senderId, size: 18, + padding: const EdgeInsetsDirectional.only(start: 5.0)), if (sender?.isBot ?? false) ...[ const SizedBox(width: 5), Icon( @@ -1350,65 +1975,461 @@ class MessageWithPossibleSender extends StatelessWidget { ), ], ]))), - const SizedBox(width: 4), - Text(time, - style: TextStyle( - color: messageListTheme.messageTimestamp, - fontSize: 16, - height: (18 / 16), - fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], - ).merge(weightVariableTextStyle(context))), - ]); + if (timestamp != null) ...[ + const SizedBox(width: 4), + Text(timestamp, + style: TextStyle( + color: messageListTheme.labelTime, + fontSize: 16, + height: (18 / 16), + fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], + ).merge(weightVariableTextStyle(context))), + ], + ])); + } +} + +enum MessageTimestampStyle { + none, + dateOnlyRelative, + timeOnly, + + // TODO(#45): E.g. "Yesterday at 4:47 PM"; see details in #45 + lightbox, + + /// The longest format, with full date and time as numbers, not "Today"/etc. + /// + /// For UI contexts focused just on the one message, + /// or as a tooltip on a shorter-formatted timestamp. + /// + /// The detail won't always be needed, but this format makes mental timezone + /// conversions easier, which is helpful when the user is thinking about + /// business hours on a different continent, + /// or traveling and they know their device timezone setting is wrong, etc. + // TODO(design) show "Today"/etc. after all? Discussion: + // https://github.com/zulip/zulip-flutter/pull/1624#issuecomment-3050296488 + full, + ; + + static String _formatDateOnlyRelative( + DateTime dateTime, { + required DateTime now, + required ZulipLocalizations zulipLocalizations, + }) { + assert(!dateTime.isUtc && !now.isUtc, + '`dateTime` and `now` need to be in local time.'); + + if (dateTime.year == now.year && + dateTime.month == now.month && + dateTime.day == now.day) { + return zulipLocalizations.today; + } + + final yesterday = now + .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0) + .add(const Duration(days: -1)); + if (dateTime.year == yesterday.year && + dateTime.month == yesterday.month && + dateTime.day == yesterday.day) { + return zulipLocalizations.yesterday; } - final localizations = ZulipLocalizations.of(context); + // If it is Dec 1 and you see a label that says `Dec 2` + // it could be misinterpreted as Dec 2 of the previous + // year. For times in the future, those still on the + // current day will show as today (handled above) and + // any dates beyond that show up with the year. + if (dateTime.year == now.year && dateTime.isBefore(now)) { + return DateFormat.MMMd().format(dateTime); + } else { + return DateFormat.yMMMd().format(dateTime); + } + } + + static final _timeFormat12 = DateFormat('h:mm aa'); + static final _timeFormat24 = DateFormat('Hm'); + static final _timeFormatLocaleDefault = DateFormat('jm'); + static final _timeFormat12WithSeconds = DateFormat('h:mm:ss aa'); + static final _timeFormat24WithSeconds = DateFormat('Hms'); + static final _timeFormatLocaleDefaultWithSeconds = DateFormat('jms'); + + static DateFormat _resolveTimeFormat(TwentyFourHourTimeMode mode) => switch (mode) { + TwentyFourHourTimeMode.twelveHour => _timeFormat12, + TwentyFourHourTimeMode.twentyFourHour => _timeFormat24, + TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefault, + }; + + static DateFormat _resolveTimeFormatWithSeconds(TwentyFourHourTimeMode mode) => switch (mode) { + TwentyFourHourTimeMode.twelveHour => _timeFormat12WithSeconds, + TwentyFourHourTimeMode.twentyFourHour => _timeFormat24WithSeconds, + TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefaultWithSeconds, + }; + + /// Format a [Message.timestamp] for this mode. + // TODO(i18n): locale-specific formatting (see #45 for a plan with ffi) + String? format( + int messageTimestamp, { + required DateTime now, + required ZulipLocalizations zulipLocalizations, + required TwentyFourHourTimeMode twentyFourHourTimeMode, + }) { + final asDateTime = + DateTime.fromMillisecondsSinceEpoch(1000 * messageTimestamp); + + switch (this) { + case none: return null; + case dateOnlyRelative: + return _formatDateOnlyRelative(asDateTime, + now: now, zulipLocalizations: zulipLocalizations); + case timeOnly: + return _resolveTimeFormat(twentyFourHourTimeMode).format(asDateTime); + case lightbox: + return DateFormat + .yMMMd() + .addPattern(_resolveTimeFormatWithSeconds(twentyFourHourTimeMode).pattern) + .format(asDateTime); + case full: + return DateFormat + .yMMMd() + .addPattern(_resolveTimeFormat(twentyFourHourTimeMode).pattern) + .format(asDateTime); + } + } +} + +/// A Zulip message, showing the sender's name and avatar if specified. +// Design referenced from: +// - https://github.com/zulip/zulip-mobile/issues/5511 +// - https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=538%3A20849&mode=dev +class MessageWithPossibleSender extends StatelessWidget { + const MessageWithPossibleSender({ + super.key, + required this.narrow, + required this.item, + }); + + final Narrow narrow; + final MessageListMessageItem item; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + final message = item.message; + + final zulipLocalizations = ZulipLocalizations.of(context); String? editStateText; switch (message.editState) { case MessageEditState.edited: - editStateText = localizations.messageIsEditedLabel; + editStateText = zulipLocalizations.messageIsEditedLabel; case MessageEditState.moved: - editStateText = localizations.messageIsMovedLabel; + editStateText = zulipLocalizations.messageIsMovedLabel; case MessageEditState.none: } + Widget? star; + if (message.flags.contains(MessageFlag.starred)) { + final starOffset = switch (Directionality.of(context)) { + TextDirection.ltr => -2.0, + TextDirection.rtl => 2.0, + }; + star = Transform.translate( + offset: Offset(starOffset, 0), + child: Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)); + } + + Widget content = MessageContent(message: message, content: item.content); + + final editMessageErrorStatus = store.getEditMessageErrorStatus(message.id); + if (editMessageErrorStatus != null) { + // The Figma also fades the sender row: + // https://github.com/zulip/zulip-flutter/pull/1498#discussion_r2076574000 + // We've decided to just fade the message content because that's the only + // thing that's changing. + content = Opacity(opacity: 0.6, child: content); + if (!editMessageErrorStatus) { + // IgnorePointer neutralizes interactable message content like links; + // this seemed appropriate along with the faded appearance. + content = IgnorePointer(child: content); + } else { + content = _RestoreEditMessageGestureDetector(messageId: message.id, + child: content); + } + } + + final tapOpensConversation = switch (narrow) { + CombinedFeedNarrow() + || ChannelNarrow() + || TopicNarrow() + || DmNarrow() => false, + MentionsNarrow() + || StarredMessagesNarrow() + || KeywordSearchNarrow() => true, + }; + + final showAsMuted = store.isUserMuted(message.senderId) + && !MessageListPage.maybeRevealedMutedMessagesOf(context)! + .isMutedMessageRevealed(message.id); + return GestureDetector( behavior: HitTestBehavior.translucent, - onLongPress: () => showMessageActionSheet(context: context, message: message), + onTap: tapOpensConversation + ? () => unawaited(Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), + // TODO(#1655) "this view does not mark messages as read on scroll" + initAnchorMessageId: message.id))) + : null, + onLongPress: showAsMuted + ? null // TODO write a test for this + : () => showMessageActionSheet(context: context, message: message), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.only(top: 4), child: Column(children: [ - if (senderRow != null) - Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), - child: senderRow), + if (item.showSender) + SenderRow(message: message, + timestampStyle: MessageTimestampStyle.timeOnly), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: localizedTextBaseline(context), children: [ const SizedBox(width: 16), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - MessageContent(message: message, content: item.content), - if ((message.reactions?.total ?? 0) > 0) - ReactionChipsList(messageId: message.id, reactions: message.reactions!), - if (editStateText != null) - Text(editStateText, - textAlign: TextAlign.end, - style: TextStyle( - color: designVariables.labelEdited, - fontSize: 12, - height: (12 / 12), - letterSpacing: proportionalLetterSpacing( - context, 0.05, baseFontSize: 12))), - ])), + Expanded(child: showAsMuted + ? Align( + alignment: AlignmentDirectional.topStart, + child: ZulipWebUiKitButton( + label: zulipLocalizations.revealButtonLabel, + icon: ZulipIcons.eye, + size: ZulipWebUiKitButtonSize.small, + intent: ZulipWebUiKitButtonIntent.neutral, + attention: ZulipWebUiKitButtonAttention.minimal, + onPressed: () { + MessageListPage.ancestorOf(context).revealMutedMessage(message.id); + })) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + if ((message.reactions?.total ?? 0) > 0) + ReactionChipsList(messageId: message.id, reactions: message.reactions!), + if (editMessageErrorStatus != null) + _EditMessageStatusRow(messageId: message.id, status: editMessageErrorStatus) + else if (editStateText != null) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(editStateText, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.labelEdited, + fontSize: 12, + height: (12 / 12), + letterSpacing: proportionalLetterSpacing(context, + 0.05, baseFontSize: 12)))) + else + Padding(padding: const EdgeInsets.only(bottom: 4)) + ])), SizedBox(width: 16, - child: message.flags.contains(MessageFlag.starred) - ? Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star) - : null), + child: star), ]), ]))); } } -// TODO(i18n): web seems to ignore locale in formatting time, but we could do better -final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); +class _EditMessageStatusRow extends StatelessWidget { + const _EditMessageStatusRow({ + required this.messageId, + required this.status, + }); + + final int messageId; + final bool status; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final baseTextStyle = TextStyle( + fontSize: 12, + height: 12 / 12, + letterSpacing: proportionalLetterSpacing(context, + 0.05, baseFontSize: 12)); + + return switch (status) { + // TODO parse markdown and show new content as local echo? + false => Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 1.5, + children: [ + Text( + style: baseTextStyle + .copyWith(color: designVariables.btnLabelAttLowIntInfo), + textAlign: TextAlign.end, + zulipLocalizations.savingMessageEditLabel), + // TODO instead place within bottom outer padding: + // https://github.com/zulip/zulip-flutter/pull/1498#discussion_r2087576108 + LinearProgressIndicator( + minHeight: 2, + color: designVariables.foreground.withValues(alpha: 0.5), + backgroundColor: designVariables.foreground.withValues(alpha: 0.2), + ), + ])), + true => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: _RestoreEditMessageGestureDetector( + messageId: messageId, + child: Text( + style: baseTextStyle + .copyWith(color: designVariables.btnLabelAttLowIntDanger), + textAlign: TextAlign.end, + zulipLocalizations.savingMessageEditFailedLabel))), + }; + } +} + +class _RestoreEditMessageGestureDetector extends StatelessWidget { + const _RestoreEditMessageGestureDetector({ + required this.messageId, + required this.child, + }); + + final int messageId; + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + final composeBoxState = MessageListPage.ancestorOf(context).composeBoxState; + // TODO(#1518) allow restore-edit-message from any message-list page + if (composeBoxState == null) return; + composeBoxState.startEditInteraction(messageId); + }, + child: child); + } +} + +/// A "local echo" placeholder for a Zulip message to be sent by the self-user. +/// +/// See also [OutboxMessage]. +class OutboxMessageWithPossibleSender extends StatelessWidget { + const OutboxMessageWithPossibleSender({super.key, required this.item}); + + final MessageListOutboxMessageItem item; + + @override + Widget build(BuildContext context) { + final message = item.message; + final localMessageId = message.localMessageId; + + // This is adapted from [MessageContent]. + // TODO(#576): Offer InheritedMessage ancestor once we are ready + // to support local echoing images and lightbox. + Widget content = DefaultTextStyle( + style: ContentTheme.of(context).textStylePlainParagraph, + child: BlockContentList(nodes: item.content.nodes)); + + switch (message.state) { + case OutboxMessageState.hidden: + throw StateError('Hidden OutboxMessage messages should not appear in message lists'); + case OutboxMessageState.waiting: + break; + case OutboxMessageState.failed: + case OutboxMessageState.waitPeriodExpired: + // TODO(#576): When we support rendered-content local echo, + // use IgnorePointer along with this faded appearance, + // like we do for the failed-message-edit state + content = _RestoreOutboxMessageGestureDetector( + localMessageId: localMessageId, + child: Opacity(opacity: 0.6, child: content)); + } + + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Column(children: [ + if (item.showSender) + SenderRow(message: message, timestampStyle: MessageTimestampStyle.none), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + _OutboxMessageStatusRow( + localMessageId: localMessageId, outboxMessageState: message.state), + ])), + ])); + } +} + +class _OutboxMessageStatusRow extends StatelessWidget { + const _OutboxMessageStatusRow({ + required this.localMessageId, + required this.outboxMessageState, + }); + + final int localMessageId; + final OutboxMessageState outboxMessageState; + + @override + Widget build(BuildContext context) { + switch (outboxMessageState) { + case OutboxMessageState.hidden: + assert(false, + 'Hidden OutboxMessage messages should not appear in message lists'); + return SizedBox.shrink(); + + case OutboxMessageState.waiting: + final designVariables = DesignVariables.of(context); + return Padding( + padding: const EdgeInsetsGeometry.only(bottom: 2), + child: LinearProgressIndicator( + minHeight: 2, + color: designVariables.foreground.withFadedAlpha(0.5), + backgroundColor: designVariables.foreground.withFadedAlpha(0.2))); + + case OutboxMessageState.failed: + case OutboxMessageState.waitPeriodExpired: + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: _RestoreOutboxMessageGestureDetector( + localMessageId: localMessageId, + child: Text( + zulipLocalizations.messageNotSentLabel, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.btnLabelAttLowIntDanger, + fontSize: 12, + height: 12 / 12, + letterSpacing: proportionalLetterSpacing( + context, 0.05, baseFontSize: 12))))); + } + } +} + +class _RestoreOutboxMessageGestureDetector extends StatelessWidget { + const _RestoreOutboxMessageGestureDetector({ + required this.localMessageId, + required this.child, + }); + + final int localMessageId; + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + final composeBoxState = MessageListPage.ancestorOf(context).composeBoxState; + // TODO(#1518) allow restore-outbox-message from any message-list page + if (composeBoxState == null) return; + composeBoxState.restoreMessageNotSent(localMessageId); + }, + child: child); + } +} diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart new file mode 100644 index 0000000000..0ab0dc41b5 --- /dev/null +++ b/lib/widgets/new_dm_sheet.dart @@ -0,0 +1,435 @@ +import 'package:flutter/material.dart'; +import '../api/model/model.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../model/autocomplete.dart'; +import '../model/narrow.dart'; +import '../model/store.dart'; +import 'color.dart'; +import 'icons.dart'; +import 'page.dart'; +import 'recent_dm_conversations.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; +import 'user.dart'; + +void showNewDmSheet(BuildContext context, OnDmSelectCallback onDmSelect) { + final pageContext = PageRoot.contextOf(context); + final store = PerAccountStoreWidget.of(context); + showModalBottomSheet( + context: pageContext, + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + builder: (BuildContext context) => Padding( + // By default, when software keyboard is opened, the ListView + // expands behind the software keyboard — resulting in some + // list entries being covered by the keyboard. Add explicit + // bottom padding the size of the keyboard, which fixes this. + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: PerAccountStoreWidget( + accountId: store.accountId, + child: NewDmPicker(onDmSelect: onDmSelect)))); +} + +@visibleForTesting +class NewDmPicker extends StatefulWidget { + const NewDmPicker({super.key, required this.onDmSelect}); + + final OnDmSelectCallback onDmSelect; + + @override + State createState() => _NewDmPickerState(); +} + +class _NewDmPickerState extends State with PerAccountStoreAwareStateMixin { + late TextEditingController searchController; + late ScrollController resultsScrollController; + Set selectedUserIds = {}; + List filteredUsers = []; + List sortedUsers = []; + + @override + void initState() { + super.initState(); + searchController = TextEditingController()..addListener(_handleSearchUpdate); + resultsScrollController = ScrollController(); + } + + @override + void onNewStore() { + final store = PerAccountStoreWidget.of(context); + _initSortedUsers(store); + } + + @override + void dispose() { + searchController.dispose(); + resultsScrollController.dispose(); + super.dispose(); + } + + void _initSortedUsers(PerAccountStore store) { + final users = store.allUsers + .where((user) => user.isActive && !store.isUserMuted(user.userId)); + sortedUsers = List.from(users) + ..sort((a, b) => MentionAutocompleteView.compareByDms(a, b, store: store)); + _updateFilteredUsers(store); + } + + void _handleSearchUpdate() { + final store = PerAccountStoreWidget.of(context); + _updateFilteredUsers(store); + } + + // Function to sort users based on recency of DM's + // TODO: switch to using an `AutocompleteView` for users + void _updateFilteredUsers(PerAccountStore store) { + final excludeSelfUser = selectedUserIds.isNotEmpty + && !selectedUserIds.contains(store.selfUserId); + final normalizedQuery = + AutocompleteQuery.lowercaseAndStripDiacritics(searchController.text); + + final result = []; + for (final user in sortedUsers) { + if (excludeSelfUser && user.userId == store.selfUserId) continue; + final normalizedName = AutocompleteQuery.lowercaseAndStripDiacritics(user.fullName); + if (normalizedName.contains(normalizedQuery)) { + result.add(user); + } + } + + setState(() { + filteredUsers = result; + }); + + if (resultsScrollController.hasClients) { + // Jump to the first results for the new query. + resultsScrollController.jumpTo(0); + } + } + + void _selectUser(int userId) { + assert(!selectedUserIds.contains(userId)); + final store = PerAccountStoreWidget.of(context); + selectedUserIds.add(userId); + if (userId != store.selfUserId) { + selectedUserIds.remove(store.selfUserId); + } + _updateFilteredUsers(store); + } + + void _unselectUser(int userId) { + assert(selectedUserIds.contains(userId)); + final store = PerAccountStoreWidget.of(context); + selectedUserIds.remove(userId); + _updateFilteredUsers(store); + } + + void _handleUserTap(int userId) { + selectedUserIds.contains(userId) + ? _unselectUser(userId) + : _selectUser(userId); + searchController.clear(); + } + + @override + Widget build(BuildContext context) { + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _NewDmHeader(selectedUserIds: selectedUserIds, onDmSelect: widget.onDmSelect), + _NewDmSearchBar( + controller: searchController, + selectedUserIds: selectedUserIds, + unselectUser: _unselectUser), + Expanded( + child: _NewDmUserList( + filteredUsers: filteredUsers, + selectedUserIds: selectedUserIds, + scrollController: resultsScrollController, + onUserTapped: (userId) => _handleUserTap(userId))), + ]); + } +} + +class _NewDmHeader extends StatelessWidget { + const _NewDmHeader({required this.selectedUserIds, required this.onDmSelect}); + + final Set selectedUserIds; + final OnDmSelectCallback onDmSelect; + + Widget _buildCancelButton(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return GestureDetector( + onTap: Navigator.of(context).pop, + child: Text(zulipLocalizations.dialogCancel, style: TextStyle( + color: designVariables.icon, + fontSize: 20, + height: 30 / 20))); + } + + Widget _buildComposeButton(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final color = selectedUserIds.isEmpty + ? designVariables.icon.withFadedAlpha(0.5) + : designVariables.icon; + + return GestureDetector( + onTap: selectedUserIds.isEmpty ? null : () { + final store = PerAccountStoreWidget.of(context); + final narrow = DmNarrow.withUsers( + selectedUserIds.toList(), + selfUserId: store.selfUserId); + onDmSelect(narrow); + }, + child: Text(zulipLocalizations.newDmSheetComposeButtonLabel, + style: TextStyle( + color: color, + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 600)))); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB(12, 10, 8, 6), + child: Row(children: [ + _buildCancelButton(context), + SizedBox(width: 8), + Expanded(child: Text(zulipLocalizations.newDmSheetScreenTitle, + style: TextStyle( + color: designVariables.title, + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 600)), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center)), + SizedBox(width: 8), + _buildComposeButton(context), + ])); + } +} + +class _NewDmSearchBar extends StatelessWidget { + const _NewDmSearchBar({ + required this.controller, + required this.selectedUserIds, + required this.unselectUser, + }); + + final TextEditingController controller; + final Set selectedUserIds; + final void Function(int) unselectUser; + + // void _removeUser + + Widget _buildSearchField(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final hintText = selectedUserIds.isEmpty + ? zulipLocalizations.newDmSheetSearchHintEmpty + : zulipLocalizations.newDmSheetSearchHintSomeSelected; + + return TextField( + controller: controller, + autofocus: true, + cursorColor: designVariables.foreground, + style: TextStyle( + color: designVariables.textMessage, + fontSize: 17, + height: 22 / 17), + scrollPadding: EdgeInsets.zero, + decoration: InputDecoration( + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + hintText: hintText, + hintStyle: TextStyle( + color: designVariables.labelSearchPrompt, + fontSize: 17, + height: 22 / 17))); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return Container( + constraints: const BoxConstraints(maxHeight: 124), + decoration: BoxDecoration(color: designVariables.bgSearchInput), + child: SingleChildScrollView( + reverse: true, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + child: Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final userId in selectedUserIds) + _SelectedUserChip(userId: userId, unselectUser: unselectUser), + // The IntrinsicWidth lets the text field participate in the Wrap + // when its content fits on the same line with a user chip, + // by preventing it from expanding to fill the available width. See: + // https://github.com/zulip/zulip-flutter/pull/1322#discussion_r2094112488 + IntrinsicWidth(child: _buildSearchField(context)), + ])))); + } +} + +class _SelectedUserChip extends StatelessWidget { + const _SelectedUserChip({ + required this.userId, + required this.unselectUser, + }); + + final int userId; + final void Function(int) unselectUser; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + final clampedTextScaler = MediaQuery.textScalerOf(context) + .clamp(maxScaleFactor: 1.5); + + return GestureDetector( + onTap: () => unselectUser(userId), + child: DecoratedBox( + decoration: BoxDecoration( + color: designVariables.bgMenuButtonSelected, + borderRadius: BorderRadius.circular(3)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Avatar(userId: userId, size: clampedTextScaler.scale(22), borderRadius: 3), + Flexible( + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(5, 3, 4, 3), + child: Text(store.userDisplayName(userId), + textScaler: clampedTextScaler, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + height: 16 / 16, + color: designVariables.labelMenuButton)))), + UserStatusEmoji(userId: userId, size: 16, + padding: EdgeInsetsDirectional.only(end: 4)), + ]))); + } +} + +class _NewDmUserList extends StatelessWidget { + const _NewDmUserList({ + required this.filteredUsers, + required this.selectedUserIds, + required this.scrollController, + required this.onUserTapped, + }); + + final List filteredUsers; + final Set selectedUserIds; + final ScrollController scrollController; + final void Function(int userId) onUserTapped; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + if (filteredUsers.isEmpty) { + // TODO(design): Missing in Figma. + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + textAlign: TextAlign.center, + zulipLocalizations.newDmSheetNoUsersFound, + style: TextStyle( + color: designVariables.labelMenuButton, + fontSize: 16)))); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: CustomScrollView(controller: scrollController, slivers: [ + SliverPadding( + padding: EdgeInsets.only(top: 8), + sliver: SliverSafeArea( + minimum: EdgeInsets.only(bottom: 8), + sliver: SliverList.builder( + itemCount: filteredUsers.length, + itemBuilder: (context, index) { + final user = filteredUsers[index]; + final isSelected = selectedUserIds.contains(user.userId); + + return _NewDmUserListItem( + userId: user.userId, + isSelected: isSelected, + onTapped: onUserTapped, + ); + }))), + ])); + } +} + +class _NewDmUserListItem extends StatelessWidget { + const _NewDmUserListItem({ + required this.userId, + required this.isSelected, + required this.onTapped, + }); + + final int userId; + final bool isSelected; + final void Function(int userId) onTapped; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + return Material( + clipBehavior: Clip.antiAlias, + borderRadius: BorderRadius.circular(10), + color: isSelected + ? designVariables.bgMenuButtonSelected + : Colors.transparent, + child: InkWell( + highlightColor: designVariables.bgMenuButtonSelected, + splashFactory: NoSplash.splashFactory, + onTap: () => onTapped(userId), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(0, 6, 12, 6), + child: Row(children: [ + SizedBox(width: 8), + isSelected + ? Icon(size: 24, + color: designVariables.radioFillSelected, + ZulipIcons.check_circle_checked) + : Icon(size: 24, + color: designVariables.radioBorder, + ZulipIcons.check_circle_unchecked), + SizedBox(width: 10), + Avatar(userId: userId, size: 32, borderRadius: 3), + SizedBox(width: 8), + Expanded( + child: Text.rich( + TextSpan(text: store.userDisplayName(userId), children: [ + UserStatusEmoji.asWidgetSpan(userId: userId, fontSize: 17, + textScaler: MediaQuery.textScalerOf(context)), + ]), + style: TextStyle( + fontSize: 17, + height: 19 / 17, + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 500)))), + ])))); + } +} diff --git a/lib/widgets/page.dart b/lib/widgets/page.dart index bebb37c22c..9aebca9dfd 100644 --- a/lib/widgets/page.dart +++ b/lib/widgets/page.dart @@ -2,6 +2,32 @@ import 'package:flutter/material.dart'; import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; + +/// An [InheritedWidget] for near the root of a page's widget subtree, +/// providing its [BuildContext]. +/// +/// 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. /// @@ -11,6 +37,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 +64,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 +76,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))); } } @@ -175,3 +212,44 @@ class LoadingPlaceholderPage extends StatelessWidget { ); } } + +/// A "no content here" message for when a page has no content to show. +/// +/// Suitable for the inbox, the message-list page, etc. +/// +/// This handles the horizontal device insets +/// and the bottom inset when needed (in a message list with no compose box). +/// The top inset is handled externally by the app bar. +/// +/// See also: +/// * [BottomSheetEmptyContentPlaceholder], for a similar element to use in +/// a bottom sheet. +// TODO(#311) If the message list gets a bottom nav, the bottom inset will +// always be handled externally too; simplify implementation and dartdoc. +class PageBodyEmptyContentPlaceholder extends StatelessWidget { + const PageBodyEmptyContentPlaceholder({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return SafeArea( + minimum: EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Padding( + padding: EdgeInsets.only(top: 48), + child: Align( + alignment: Alignment.topCenter, + // TODO leading and trailing elements, like in Figma (given as SVGs): + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5957-167736&m=dev + child: Text( + textAlign: TextAlign.center, + style: TextStyle( + color: designVariables.labelSearchPrompt, + fontSize: 17, + height: 23 / 17, + ).merge(weightVariableTextStyle(context, wght: 500)), + message)))); + } +} diff --git a/lib/widgets/poll.dart b/lib/widgets/poll.dart index dc340d08b8..b851c55525 100644 --- a/lib/widgets/poll.dart +++ b/lib/widgets/poll.dart @@ -80,8 +80,7 @@ class _PollWidgetState extends State { // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Zixuan']) // // 'Chris、Greg、Alya、Zixuan' final voterNames = option.voters - .map((userId) => - store.users[userId]?.fullName ?? zulipLocalizations.unknownUserName) + .map(store.userDisplayName) .join(', '); return Row( diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index c4f8970ff0..42521e63bf 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -1,19 +1,28 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; -import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; +import '../api/route/settings.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../log.dart'; +import '../model/binding.dart'; import '../model/content.dart'; import '../model/narrow.dart'; -import '../model/store.dart'; +import '../model/presence.dart'; import 'app_bar.dart'; +import 'button.dart'; import 'content.dart'; +import 'icons.dart'; import 'message_list.dart'; import 'page.dart'; +import 'remote_settings.dart'; +import 'set_status.dart'; import 'store.dart'; import 'text.dart'; +import 'theme.dart'; +import 'user.dart'; class _TextStyles { static const primaryFieldText = TextStyle(fontSize: 20); @@ -30,57 +39,68 @@ 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)); } - /// The given user's real email address, if known, for displaying in the UI. - /// - /// Returns null if self-user isn't able to see [user]'s real email address. - String? _getDisplayEmailFor(User user, {required PerAccountStore store}) { - if (store.account.zulipFeatureLevel >= 163) { // TODO(server-7) - // A non-null value means self-user has access to [user]'s real email, - // while a null value means it doesn't have access to the email. - // Search for "delivery_email" in https://zulip.com/api/register-queue. - return user.deliveryEmail; - } else { - if (user.deliveryEmail != null) { - // A non-null value means self-user has access to [user]'s real email, - // while a null value doesn't necessarily mean it doesn't have access - // to the email, .... - return user.deliveryEmail; - } else if (store.emailAddressVisibility == EmailAddressVisibility.everyone) { - // ... we have to also check for [PerAccountStore.emailAddressVisibility]. - // See: - // * https://github.com/zulip/zulip-mobile/pull/5515#discussion_r997731727 - // * https://chat.zulip.org/#narrow/stream/378-api-design/topic/email.20address.20visibility/near/1296133 - return user.email; - } else { - return null; - } - } - } - @override Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); - final user = store.users[userId]; + final user = store.getUser(userId); if (user == null) { return const _ProfileErrorPage(); } - final displayEmail = _getDisplayEmailFor(user, store: store); + final nameStyle = _TextStyles.primaryFieldText + .merge(weightVariableTextStyle(context, wght: 700)); + + final userStatus = store.getUserStatus(userId); + + final displayEmail = user.deliveryEmail; + final items = [ Center( - child: Avatar(userId: userId, size: 200, borderRadius: 200 / 8)), + child: Avatar( + userId: userId, + size: 200, + borderRadius: 200 / 8, + // Would look odd with this large image; + // we'll show it by the user's name instead. + showPresence: false, + replaceIfMuted: false, + )), const SizedBox(height: 16), - Text(user.fullName, + Text.rich( + TextSpan(children: [ + PresenceCircle.asWidgetSpan( + userId: userId, + fontSize: nameStyle.fontSize!, + textScaler: MediaQuery.textScalerOf(context), + ), + // TODO write a test where the user is muted; check this and avatar + TextSpan(text: store.userDisplayName(userId, replaceIfMuted: false)), + if (userId != store.selfUserId) + UserStatusEmoji.asWidgetSpan( + userId: userId, + fontSize: nameStyle.fontSize!, + textScaler: MediaQuery.textScalerOf(context), + neverAnimate: false, + ), + ]), textAlign: TextAlign.center, - style: _TextStyles.primaryFieldText - .merge(weightVariableTextStyle(context, wght: 700))), + style: nameStyle), + if (userId != store.selfUserId && userStatus.text != null) + Text(userStatus.text!, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, height: 22 / 18, + color: DesignVariables.of(context).userStatusText)), + if (!user.isBot) + _LastActiveTime(userId: userId), + + const SizedBox(height: 8), if (displayEmail != null) Text(displayEmail, textAlign: TextAlign.center, @@ -88,10 +108,19 @@ class ProfilePage extends StatelessWidget { Text(roleToLabel(user.role, zulipLocalizations), textAlign: TextAlign.center, style: _TextStyles.primaryFieldText), - // TODO(#197) render user status // TODO(#196) render active status // TODO(#292) render user local time + if (userId == store.selfUserId) ...[ + const SizedBox(height: 16), + MenuButtonsShape(buttons: [ + _SetStatusButton(), + if (!store.realmPresenceDisabled) + _InvisibleModeToggle(), + ]), + const SizedBox(height: 16), + ], + _ProfileDataTable(profileData: user.profileData), const SizedBox(height: 16), FilledButton.icon( @@ -103,7 +132,9 @@ class ProfilePage extends StatelessWidget { ]; return Scaffold( - appBar: ZulipAppBar(title: Text(user.fullName)), + appBar: ZulipAppBar( + // TODO write a test where the user is muted + title: Text(store.userDisplayName(userId, replaceIfMuted: false))), body: SingleChildScrollView( child: Center( child: ConstrainedBox( @@ -116,6 +147,169 @@ class ProfilePage extends StatelessWidget { } } +class _LastActiveTime extends StatefulWidget { + const _LastActiveTime({required this.userId}); + + final int userId; + + @override + State<_LastActiveTime> createState() => _LastActiveTimeState(); +} + +class _LastActiveTimeState extends State<_LastActiveTime> with PerAccountStoreAwareStateMixin { + Presence? model; + + @override + void onNewStore() { + model?.removeListener(_modelChanged); + model = PerAccountStoreWidget.of(context).presence + ..addListener(_modelChanged); + } + + @override + void dispose() { + model!.removeListener(_modelChanged); + super.dispose(); + } + + void _modelChanged() { + setState(() { + // The actual state lives in [model]. + // This method was called because that just changed. + }); + } + + String _lastActiveText(ZulipLocalizations zulipLocalizations) { + // TODO(#45): revise this relative-time logic in light of a future solution + // for the lightbox, e.g. using ICU/CLDR via FFI. See discussion: + // https://github.com/zulip/zulip-flutter/pull/1793#issuecomment-3169228753 + + // TODO(#293), TODO(#891): auto-rebuild as relative time changes + final nowDate = ZulipBinding.instance.utcNow(); + + final status = model!.presenceStatusForUser(widget.userId, + utcNow: nowDate); + switch (status) { + case PresenceStatus.active: return zulipLocalizations.userActiveNow; + case PresenceStatus.idle: return zulipLocalizations.userIdle; + case null: break; // handle below + } + + final timestamp = model!.userLastActive(widget.userId); + if (timestamp == null) return zulipLocalizations.userNotActiveInYear; + + // Compare web's timerender.last_seen_status_from_date. + final now = nowDate.millisecondsSinceEpoch ~/ 1000; + final ageSeconds = now - timestamp; + if (ageSeconds <= 0) { + // TODO or perhaps show full time, to help user in case of clock skew + return zulipLocalizations.userActiveNow; + } else if (ageSeconds < 60 * 60) { + return zulipLocalizations.userActiveMinutesAgo(ageSeconds ~/ 60); + } else if (ageSeconds < 24 * 60 * 60) { + return zulipLocalizations.userActiveHoursAgo(ageSeconds ~/ (60 * 60)); + } + + final todayNoon = nowDate.toLocal() + .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0); + final presenceNoon = DateTime.fromMillisecondsSinceEpoch( + timestamp * 1000, isUtc: false) + .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0); + final ageCalendarDays = (todayNoon.difference(presenceNoon) + .inSeconds / (24 * 60 * 60)).round(); + if (ageCalendarDays <= 0) { + // The timestamp was at least 24 hours ago. + // If it's somehow the same or a future calendar day, then this must be a + // really messy time zone. Hopefully no real time zone makes this possible. + return zulipLocalizations.userActiveYesterday; + } else if (ageCalendarDays == 1) { + return zulipLocalizations.userActiveYesterday; + } else if (ageCalendarDays < 90) { + return zulipLocalizations.userActiveDaysAgo(ageCalendarDays); + } + + final DateFormat format; + if (presenceNoon.year == todayNoon.year) { + format = DateFormat.MMMd(); + } else { + format = DateFormat.yMMMd(); + } + return zulipLocalizations.userActiveDate(format.format(presenceNoon)); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return Text(_lastActiveText(zulipLocalizations), + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, height: 22 / 18, + color: DesignVariables.of(context).userStatusText)); + } +} + +class _SetStatusButton extends StatelessWidget { + const _SetStatusButton(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(context); + final userStatus = store.getUserStatus(store.selfUserId); + + return ZulipMenuItemButton( + style: ZulipMenuItemButtonStyle.list, + label: userStatus == UserStatus.zero + ? zulipLocalizations.statusButtonLabelStatusUnset + : zulipLocalizations.statusButtonLabelStatusSet, + subLabel: userStatus == UserStatus.zero ? null : TextSpan(children: [ + UserStatusEmoji.asWidgetSpan( + userId: store.selfUserId, + fontSize: 16, + textScaler: MediaQuery.textScalerOf(context), + position: StatusEmojiPosition.before, + neverAnimate: false, + ), + userStatus.text == null + ? TextSpan(text: zulipLocalizations.noStatusText, + style: TextStyle(fontStyle: FontStyle.italic)) + : TextSpan(text: userStatus.text), + ]), + icon: ZulipIcons.chevron_right, + onPressed: () { + Navigator.push(context, SetStatusPage.buildRoute( + context: context, oldStatus: userStatus)); + }, + ); + } +} + +class _InvisibleModeToggle extends StatelessWidget { + const _InvisibleModeToggle(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(context); + + // `value: true` means invisible mode is on, + // i.e., that presenceEnabled is false. + return RemoteSettingBuilder( + findValueInStore: (store) => !store.userSettings.presenceEnabled, + sendValueToServer: (value) => updateSettings(store.connection, + newSettings: {UserSettingName.presenceEnabled: !value}), + // TODO(#741) interpret API errors for user + onError: (e, requestedValue) => reportErrorToUserBriefly( + requestedValue + ? zulipLocalizations.turnOnInvisibleModeErrorTitle + : zulipLocalizations.turnOffInvisibleModeErrorTitle), + builder: (value, handleRequestNewValue) => ZulipMenuItemButton( + style: ZulipMenuItemButtonStyle.list, + label: zulipLocalizations.invisibleMode, + onPressed: () => handleRequestNewValue(!value), + toggle: Toggle(value: value, onChanged: handleRequestNewValue))); + } +} + class _ProfileErrorPage extends StatelessWidget { const _ProfileErrorPage(); @@ -291,9 +485,6 @@ 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 ?? zulipLocalizations.unknownUserName; return InkWell( onTap: () => Navigator.push(context, ProfilePage.buildRoute(context: context, @@ -301,9 +492,12 @@ class _UserWidget extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(8), child: Row(children: [ + // TODO(#196) render active status Avatar(userId: userId, size: 32, borderRadius: 32 / 8), const SizedBox(width: 8), - Expanded(child: Text(fullName, style: _TextStyles.customProfileFieldText)), // TODO(#196) render active status + Expanded( + child: Text(store.userDisplayName(userId), + style: _TextStyles.customProfileFieldText)), ]))); } } diff --git a/lib/widgets/read_receipts.dart b/lib/widgets/read_receipts.dart new file mode 100644 index 0000000000..46c920372a --- /dev/null +++ b/lib/widgets/read_receipts.dart @@ -0,0 +1,185 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../api/route/messages.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import 'action_sheet.dart'; +import 'actions.dart'; +import 'color.dart'; +import 'profile.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; +import 'user.dart'; + +/// Opens a bottom sheet showing who has read the message. +void showReadReceiptsSheet(BuildContext pageContext, {required int messageId}) { + final accountId = PerAccountStoreWidget.accountIdOf(pageContext); + + showModalBottomSheet( + context: pageContext, + // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect + // on my iPhone 13 Pro but is marked as "much slower": + // https://api.flutter.dev/flutter/dart-ui/Clip.html + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + builder: (_) { + return PerAccountStoreWidget( + accountId: accountId, + child: SafeArea( + minimum: const EdgeInsets.only(bottom: 16), + child: ReadReceipts(messageId: messageId))); + }); +} + +/// The read-receipts sheet. +/// +/// Figma link: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=11367-20647&t=lSnHudU6l7NWx0Fa-0 +class ReadReceipts extends StatefulWidget { + const ReadReceipts({super.key, required this.messageId}); + + final int messageId; + + @override + State createState() => _ReadReceiptsState(); +} + +class _ReadReceiptsState extends State with PerAccountStoreAwareStateMixin { + List userIds = []; + FetchStatus status = FetchStatus.loading; + + @override + void onNewStore() { + tryFetchReadReceipts(context); + } + + Future tryFetchReadReceipts(BuildContext context) async { + final store = PerAccountStoreWidget.of(context); + try { + final result = await getReadReceipts(store.connection, messageId: widget.messageId); + + if (!context.mounted) return; + final storeNow = PerAccountStoreWidget.of(context); + if (!identical(store, storeNow)) return; + + // TODO(i18n): add locale-aware sorting + userIds = result.userIds.sortedByCompare( + store.userDisplayName, + (nameA, nameB) => nameA.toLowerCase().compareTo(nameB.toLowerCase()), + ); + status = FetchStatus.success; + } catch (e) { + status = FetchStatus.error; + } finally { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final receiptCount = userIds.length; + + final content = switch (status) { + FetchStatus.loading => SliverToBoxAdapter( + child: BottomSheetEmptyContentPlaceholder(loading: true)), + FetchStatus.error => SliverToBoxAdapter( + child: BottomSheetEmptyContentPlaceholder( + message: zulipLocalizations.actionSheetReadReceiptsErrorReadCount)), + FetchStatus.success => userIds.isEmpty + ? SliverToBoxAdapter( + child: BottomSheetEmptyContentPlaceholder( + message: zulipLocalizations.actionSheetReadReceiptsZeroReadCount)) + : SliverList.builder( + itemCount: receiptCount, + itemBuilder: (_, index) => ReadReceiptsUserItem(userId: userIds[index])), + }; + + return DraggableScrollableModalBottomSheet( + header: _ReadReceiptsHeader(receiptCount: receiptCount, status: status), + contentSliver: content); + } +} + +enum FetchStatus { loading, success, error } + +class _ReadReceiptsHeader extends StatelessWidget { + const _ReadReceiptsHeader({required this.receiptCount, required this.status}); + + final int receiptCount; + final FetchStatus status; + + static const _helpCenterRelativeUrl = '/help/read-receipts'; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + + WidgetBuilderFromTextStyle? headerMessageBuilder; + if (status == FetchStatus.success && receiptCount > 0) { + headerMessageBuilder = (TextStyle style) => TextWithLink( + onTap: () { + PlatformActions.launchUrl(context, PerAccountStoreWidget.of(context) + .tryResolveUrl(_helpCenterRelativeUrl)!); + }, + style: style, + markup: zulipLocalizations.actionSheetReadReceiptsReadCount(receiptCount)); + } + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: BottomSheetHeader( + title: zulipLocalizations.actionSheetReadReceipts, + buildMessage: headerMessageBuilder)); + } +} + +// TODO: deduplicate the code with [ViewReactionsUserItem] +@visibleForTesting +class ReadReceiptsUserItem extends StatelessWidget { + const ReadReceiptsUserItem({super.key, required this.userId}); + + final int userId; + + void _onPressed(BuildContext context) { + // Dismiss the action sheet. + Navigator.pop(context); + + Navigator.push(context, + ProfilePage.buildRoute(context: context, userId: userId)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + return InkWell( + onTap: () => _onPressed(context), + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.20), + }), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row(spacing: 8, children: [ + Avatar( + size: 32, + borderRadius: 3, + backgroundColor: designVariables.bgContextMenu, + userId: userId), + Flexible( + child: Text( + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17, + height: 17 / 17, + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 500)), + store.userDisplayName(userId))), + ]))); + } +} diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index ddc32861a3..f9eb688a10 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -4,15 +4,36 @@ import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; -import 'content.dart'; import 'icons.dart'; import 'message_list.dart'; +import 'new_dm_sheet.dart'; +import 'page.dart'; import 'store.dart'; +import 'text.dart'; import 'theme.dart'; import 'unread_count_badge.dart'; +import 'user.dart'; + +typedef OnDmSelectCallback = void Function(DmNarrow narrow); class RecentDmConversationsPageBody extends StatefulWidget { - const RecentDmConversationsPageBody({super.key}); + const RecentDmConversationsPageBody({ + super.key, + this.hideDmsIfUserCantPost = false, + this.onDmSelect, + }); + + // TODO refactor this widget to avoid reuse of the whole page, + // avoiding the need for these flags, callback, and the below + // handling of safe-area at this level of abstraction. + // See discussion: + // https://github.com/zulip/zulip-flutter/pull/1774#discussion_r2249032503 + final bool hideDmsIfUserCantPost; + + /// Callback to invoke when the user selects a DM conversation from the list. + /// + /// If null, the default behavior is to navigate to the DM conversation. + final OnDmSelectCallback? onDmSelect; @override State createState() => _RecentDmConversationsPageBodyState(); @@ -47,21 +68,89 @@ class _RecentDmConversationsPageBodyState extends State !(store.getUser(id)?.isActive ?? true)); + if (hasDeactivatedUser) { + return SizedBox.shrink(); + } + } + return RecentDmConversationsItem( + narrow: narrow, + unreadCount: unreadsModel!.countInDmNarrow(narrow), + onDmSelect: _handleDmSelect); + })), + Positioned( + bottom: bottomInsets + 21, + child: _NewDmButton(onDmSelect: _handleDmSelectForNewDms)), + ]); } } @@ -70,62 +159,68 @@ class RecentDmConversationsItem extends StatelessWidget { super.key, required this.narrow, required this.unreadCount, + required this.onDmSelect, }); final DmNarrow narrow; final int unreadCount; + final OnDmSelectCallback onDmSelect; static const double _avatarSize = 32; @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); - final selfUser = store.users[store.selfUserId]!; - - final zulipLocalizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); - final String title; + final InlineSpan title; final Widget avatar; + int? userIdForPresence; switch (narrow.otherRecipientIds) { // TODO dedupe with DM items in [InboxPage] case []: - title = selfUser.fullName; - avatar = AvatarImage(userId: selfUser.userId, size: _avatarSize); + title = TextSpan(text: store.selfUser.fullName, children: [ + UserStatusEmoji.asWidgetSpan(userId: store.selfUserId, + fontSize: 17, textScaler: MediaQuery.textScalerOf(context)), + ]); + avatar = AvatarImage(userId: store.selfUserId, size: _avatarSize); case [var otherUserId]: - // TODO(#296) actually don't show this row if the user is muted? - // (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 ?? zulipLocalizations.unknownUserName; + title = TextSpan(text: store.userDisplayName(otherUserId), children: [ + UserStatusEmoji.asWidgetSpan(userId: otherUserId, + fontSize: 17, textScaler: MediaQuery.textScalerOf(context)), + ]); avatar = AvatarImage(userId: otherUserId, size: _avatarSize); + userIdForPresence = otherUserId; 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 ?? zulipLocalizations.unknownUserName - ).join(', '); - avatar = ColoredBox(color: designVariables.groupDmConversationIconBg, + title = TextSpan( + // TODO(i18n): List formatting, like you can do in JavaScript: + // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) + // // 'Chris、Greg、Alya' + text: narrow.otherRecipientIds.map(store.userDisplayName).join(', ')); + avatar = ColoredBox(color: designVariables.avatarPlaceholderBg, child: Center( - child: Icon(color: designVariables.groupDmConversationIcon, + child: Icon(color: designVariables.avatarPlaceholderIcon, ZulipIcons.group_dm))); } + // TODO(design) check if this is the right variable + final backgroundColor = designVariables.background; return Material( - color: designVariables.background, // TODO(design) check if this is the right variable + color: backgroundColor, child: InkWell( - onTap: () { - Navigator.push(context, - MessageListPage.buildRoute(context: context, narrow: narrow)); - }, + onTap: () => onDmSelect(narrow), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 48), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ Padding(padding: const EdgeInsetsDirectional.fromSTEB(12, 8, 0, 8), - child: AvatarShape(size: _avatarSize, borderRadius: 3, child: avatar)), + child: AvatarShape( + size: _avatarSize, + borderRadius: 3, + backgroundColor: userIdForPresence != null ? backgroundColor : null, + userIdForPresence: userIdForPresence, + child: avatar)), const SizedBox(width: 8), Expanded(child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( + child: Text.rich( style: TextStyle( fontSize: 17, height: (20 / 17), @@ -144,3 +239,64 @@ class RecentDmConversationsItem extends StatelessWidget { ])))); } } + +class _NewDmButton extends StatefulWidget { + const _NewDmButton({ + required this.onDmSelect, + }); + + final OnDmSelectCallback onDmSelect; + + @override + State<_NewDmButton> createState() => _NewDmButtonState(); +} + +class _NewDmButtonState extends State<_NewDmButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final fabBgColor = _pressed + ? designVariables.fabBgPressed + : designVariables.fabBg; + final fabLabelColor = _pressed + ? designVariables.fabLabelPressed + : designVariables.fabLabel; + + return GestureDetector( + onTap: () => showNewDmSheet(context, widget.onDmSelect), + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 20, 12), + decoration: BoxDecoration( + color: fabBgColor, + borderRadius: BorderRadius.circular(28), + boxShadow: [BoxShadow( + color: designVariables.fabShadow, + blurRadius: _pressed ? 12 : 16, + offset: _pressed + ? const Offset(0, 2) + : const Offset(0, 4)), + ]), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(ZulipIcons.plus, size: 24, color: fabLabelColor), + const SizedBox(width: 8), + Text( + zulipLocalizations.newDmFabButtonLabel, + style: TextStyle( + fontSize: 20, + height: 24 / 20, + color: fabLabelColor, + ).merge(weightVariableTextStyle(context, wght: 500))), + ]))); + } +} diff --git a/lib/widgets/remote_settings.dart b/lib/widgets/remote_settings.dart new file mode 100644 index 0000000000..f2dfdb3ebe --- /dev/null +++ b/lib/widgets/remote_settings.dart @@ -0,0 +1,207 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../basic.dart'; +import '../model/store.dart'; +import 'store.dart'; + +/// A builder function for [RemoteSettingBuilder.builder] +/// that creates a toggle, or radio buttons, etc. +/// +/// [value] is the value, whether from [RemoteSettingBuilder.findValueInStore] +/// or from local echo. +/// +/// [handleRequestNewValue] calls [RemoteSettingBuilder.sendValueToServer] +/// and starts or extends a local-echo period. +/// If extending a local-echo period, it replaces the old local-echo value. +/// +/// [handleRequestNewValue] may be called any time. +/// API requests are not debounced, +/// and the server may handle them out of order. +/// But the local echo minimizes flickering (see [localEchoMinimum]) +/// while ensuring that the real in-store value is shown soon after the +/// user finishes interacting, whether the request(s) succeeded or failed. +typedef RemoteSettingBuilderFn = + Widget Function(T value, void Function(T) handleRequestNewValue); + +/// A stateful builder widget for toggles/etc. +/// that control per-account settings on the server, +/// with time-bounded local echo. +/// +/// Specify the setting with [findValueInStore] and [sendValueToServer]. +/// +/// [builder] should use its value and change-handler params +/// instead of calling the store and API directly. +/// +/// When called, [builder]'s [RemoteSettingBuilderFn.handleRequestNewValue] +/// starts or extends a local-echo period. +/// During local echo, [builder] is passed the new value +/// instead of the value in the store. +/// Local echo will continue for at least [localEchoMinimum] +/// after the current call. After that, it may end +/// - because the [findValueInStore] value changed after this call +/// (i.e. the event arrived), or +/// - because [sendValueToServer] failed, or +/// - because [localEchoIdleTimeout] elapsed and there wasn't another call. +class RemoteSettingBuilder extends StatefulWidget { + const RemoteSettingBuilder({ + super.key, + required this.findValueInStore, + required this.sendValueToServer, + this.onError, + required this.builder, + }); + + final T Function(PerAccountStore) findValueInStore; + final Future Function(T) sendValueToServer; + final void Function(Object? e, T requestedValue)? onError; + final RemoteSettingBuilderFn builder; + + /// The minimum time to spend in local echo, + /// chosen to minimize flickers that are not caused by user input. + /// + /// The common case is when the API request fails quickly. + /// + /// (Another case is when spam-tapping a toggle switch, + /// if a user wants to do that. + /// The timer resets on [RemoteSettingBuilderFn.handleRequestNewValue], + /// so until the spam-taps are finished, the switch responds only to the taps, + /// not to the event stream. + /// Then when the taps stop, it settles to the value from the latest event.) + static final Duration localEchoMinimum = Duration(seconds: 1); + + static final Duration localEchoIdleTimeout = Duration(seconds: 3); + + @override + State> createState() => _RemoteSettingBuilderState(); +} + +class _RemoteSettingBuilderState extends State> with PerAccountStoreAwareStateMixin> { + final _LocalEchoNotifier _notifier = _LocalEchoNotifier(); + + @override + void initState() { + super.initState(); + _notifier.addListener(_notifierChanged); + } + + late T? _prevValueFromStore; + + @override + void onNewStore() { + _prevValueFromStore = widget.findValueInStore(PerAccountStoreWidget.of(context)); + _notifier.stop(); + } + + @override + void didChangeDependencies() { + // On the first call, this sets _prevValueFromStore, via onNewStore. + super.didChangeDependencies(); + + final value = widget.findValueInStore(PerAccountStoreWidget.of(context)); + if (value != _prevValueFromStore) { + _notifier.stop(); + _prevValueFromStore = value; + } + } + + bool _disposed = false; + + @override + void dispose() { + _notifier.dispose(); + _disposed = true; + super.dispose(); + } + + void _notifierChanged() { + setState(() { + // The actual state lives in _notifier. + }); + } + + void _handleRequestNewValue(T value) async { + _notifier.startOrExtend(value); + + try { + await widget.sendValueToServer(value); + if (_disposed) return; + // Don't call _notifier.stop(). We do that when the event arrives, + // causing the in-store value to change (see didChangeDependencies). + } catch (e) { // TODO(log) + if (_disposed) return; + await _notifier.stop(); + if (_disposed) return; + if (widget.onError != null) { + widget.onError!(e, value); + } + } + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + + final value = _notifier.value.orElse(() => widget.findValueInStore(store)); + return widget.builder(value, _handleRequestNewValue); + } +} + +/// A [ValueNotifier] for whether local echo is active, and with what value. +/// +/// The [ValueNotifier.value] is an [Option]. +/// When it is [OptionSome], local echo is active with [OptionSome.value]. +/// When it is [OptionNone], local echo is not active. +/// +/// Use [startOrExtend] and [stop] to control local echo. +class _LocalEchoNotifier extends ValueNotifier> { + _LocalEchoNotifier() : super(OptionNone()); + + Timer? _lowerBoundTimer; + Completer? _lowerBoundCompleter; + Timer? _upperBoundTimer; + + /// Start a local-echo session or extend the timers of an existing session. + void startOrExtend(T newValue) { + value = OptionSome(newValue); + + _lowerBoundCompleter ??= Completer(); + _lowerBoundTimer?.cancel(); + _lowerBoundTimer = Timer(RemoteSettingBuilder.localEchoMinimum, () { + _lowerBoundCompleter!.complete(); + _lowerBoundCompleter = null; + }); + + _upperBoundTimer?.cancel(); + _upperBoundTimer = Timer(RemoteSettingBuilder.localEchoIdleTimeout, () { + value = OptionNone(); + }); + } + + /// Request that a local-echo session, if any, be stopped as soon as possible. + /// + /// The session will be stopped either immediately or + /// [RemoteSettingBuilder.localEchoMinimum] after the last [startOrExtend] call, + /// whichever is later. + /// + /// The returned [Future] resolves when the session is stopped. + Future stop() async { + if (_lowerBoundCompleter != null) { + await _lowerBoundCompleter!.future; + if (_disposed) return; + } + value = OptionNone(); + } + + bool _disposed = false; + + @override + void dispose() { + _lowerBoundCompleter?.complete(); + _lowerBoundTimer?.cancel(); + _upperBoundTimer?.cancel(); + _disposed = true; + super.dispose(); + } +} diff --git a/lib/widgets/scrolling.dart b/lib/widgets/scrolling.dart new file mode 100644 index 0000000000..aac36bd8d6 --- /dev/null +++ b/lib/widgets/scrolling.dart @@ -0,0 +1,650 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/rendering.dart'; + +/// A [SingleChildScrollView] that always shows a Material [Scrollbar]. +/// +/// This differs from the behavior provided by [MaterialScrollBehavior] in that +/// (a) the scrollbar appears even when [scrollDirection] is [Axis.horizontal], +/// and (b) the scrollbar appears on all platforms, rather than only on +/// desktop platforms. +// TODO(upstream): SingleChildScrollView should have a scrollBehavior field +// and pass it on to Scrollable, just like ScrollView does; then this would +// be covered by using that. +// TODO: Maybe show scrollbar only on mobile platforms, like MaterialScrollBehavior +// and the base ScrollBehavior do? +class SingleChildScrollViewWithScrollbar extends StatefulWidget { + const SingleChildScrollViewWithScrollbar( + {super.key, required this.scrollDirection, required this.child}); + + final Axis scrollDirection; + final Widget child; + + @override + State createState() => + _SingleChildScrollViewWithScrollbarState(); +} + +class _SingleChildScrollViewWithScrollbarState + extends State { + final ScrollController controller = ScrollController(); + + @override + Widget build(BuildContext context) { + return Scrollbar( + controller: controller, + child: SingleChildScrollView( + controller: controller, + scrollDirection: widget.scrollDirection, + child: widget.child)); + } +} + +/// A simulation of motion at a constant velocity. +/// +/// Models a particle that follows Newton's law of inertia, +/// with no forces acting on the particle, and no end to the motion. +/// +/// See also [GravitySimulation], which adds a constant acceleration +/// and a stopping point. +class InertialSimulation extends Simulation { // TODO(upstream) + InertialSimulation(double initialPosition, double velocity) + : _x0 = initialPosition, _v = velocity; + + final double _x0; + final double _v; + + @override + double x(double time) => _x0 + _v * time; + + @override + double dx(double time) => _v; + + @override + bool isDone(double time) => false; + + @override + String toString() => '${objectRuntimeType(this, 'InertialSimulation')}(' + 'x₀: ${_x0.toStringAsFixed(1)}, dx₀: ${_v.toStringAsFixed(1)})'; +} + +/// A simulation of the user impatiently scrolling to the end of a list. +/// +/// The position [x] is in logical pixels, and time is in seconds. +/// +/// The motion is meant to resemble the user scrolling the list down +/// (by dragging up and flinging), and if the list is long then +/// fling-scrolling again and again to keep it moving quickly. +/// +/// In that scenario taken literally, the motion would repeatedly slow down, +/// then speed up again with a fresh drag and fling. But doing that in +/// response to a simulated drag, as opposed to when the user is actually +/// dragging with their own finger, would feel jerky and not a good UX. +/// Instead this takes a smoothed-out approximation of such a trajectory. +class ScrollToEndSimulation extends InertialSimulation { + factory ScrollToEndSimulation(ScrollPosition position) { + final tolerance = position.physics.toleranceFor(position); + final startPosition = position.pixels; + final estimatedEndPosition = position.maxScrollExtent; + final velocityForMinDuration = (estimatedEndPosition - startPosition) + / (minDuration.inMilliseconds / 1000.0); + final velocity = clampDouble(velocityForMinDuration, + // If the starting position is beyond the estimated end + // (i.e. `velocityForMinDuration < 0`), or very close to it, + // then move forward at a small positive velocity. + // Let the overscroll handling bring the position to exactly the end. + 2 * tolerance.velocity, + topSpeed); + return ScrollToEndSimulation._(startPosition, velocity); + } + + ScrollToEndSimulation._(super.initialPosition, super.velocity); + + /// The top speed to move at, in logical pixels per second. + /// + /// This will be the speed whenever the estimated distance to be traveled + /// is long enough to take at least [minDuration] at this speed. + /// + /// This is chosen to equal the top speed that can be produced + /// by a fling gesture in a Flutter [ScrollView], + /// which in turn was chosen to equal the top speed of + /// an (initial) fling gesture in a native Android scroll view. + static const double topSpeed = 8000; + + /// The desired duration of the animation when traveling short distances. + /// + /// The speed will be chosen so that traveling the estimated distance + /// will take this long, whenever that distance is short enough + /// that that means a speed of at most [topSpeed]. + static const minDuration = Duration(milliseconds: 300); +} + +/// An activity that animates a scroll view smoothly to its end. +/// +/// In particular this drives the "scroll to bottom" button +/// in the Zulip message list. +class ScrollToEndActivity extends DrivenScrollActivity { + /// Create an activity that animates a scroll view smoothly to its end. + /// + /// The [delegate] is required to also implement [ScrollPosition]. + ScrollToEndActivity(ScrollActivityDelegate delegate) + : super.simulation(delegate, + vsync: (delegate as ScrollPosition).context.vsync, + ScrollToEndSimulation(delegate as ScrollPosition)); + + ScrollPosition get _position => delegate as ScrollPosition; + + @override + bool applyMoveTo(double value) { + bool done = false; + if (value > _position.maxScrollExtent) { + // The activity has reached the end. + // Stop at exactly the end, rather than causing overscroll. + // Possibly some overscroll would actually be desirable, but: + // TODO(upstream) stretch-overscroll seems busted, inverted: + // Is this formula (from [_StretchController.absorbImpact] really right? + // _stretchSizeTween.end = + // math.min(_stretchIntensity + (_flingFriction / velocity), 1.0); + // Seems to take low velocity to the largest stretch, and high velocity + // to the smallest stretch. + // Specifically, a very slow fling produces a very large stretch, + // while other flings produce small stretches that vary little + // between modest speed (~300 px/s) and top speed (8000 px/s). + value = _position.maxScrollExtent; + done = true; + } + if (!super.applyMoveTo(value)) return false; + return !done; + } +} + +/// A version of [ScrollPosition] adapted for the Zulip message list, +/// used by [MessageListScrollController]. +class MessageListScrollPosition extends ScrollPositionWithSingleContext { + MessageListScrollPosition({ + required super.physics, + required super.context, + super.initialPixels, + super.keepScrollOffset, + super.oldPosition, + super.debugLabel, + }); + + // TODO(upstream): is the lack of [absorb] a bug in [_TabBarScrollPosition]? + @override + void absorb(ScrollPosition other) { + super.absorb(other); + if (other is! MessageListScrollPosition) return; + _hasEverCompletedLayout = other._hasEverCompletedLayout; + } + + /// Like [applyContentDimensions], but called without adjusting + /// the arguments to subtract the viewport dimension. + /// + /// For instance, if there is 100.0 pixels of scrollable content + /// of which 40.0 pixels is in the reverse-growing slivers and + /// 60.0 pixels in the forward-growing slivers, then the arguments + /// will be -40.0 and 60.0, regardless of the viewport dimension. + /// + /// By contrast in a call to [applyContentDimensions], in this example and + /// if the viewport dimension is 80.0, then the arguments might be + /// 0.0 and 60.0, or -10.0 and 10.0, or -40.0 and 0.0, or other values, + /// depending on the value of [Viewport.anchor]. + bool applyContentDimensionsRaw(double wholeMinScrollExtent, double wholeMaxScrollExtent) { + // The origin point of these scroll coordinates, scroll extent 0.0, + // is that the boundary between slivers is the bottom edge of the viewport. + // (That's expressed by setting `anchor` to 1.0, consulted in + // `_attemptLayout` below.) + + // The farthest the list can scroll down (moving the content up) + // is to the point where the bottom end of the list + // touches the bottom edge of the viewport. + final effectiveMax = wholeMaxScrollExtent; + + // The farthest the list can scroll up (moving the content down) + // is either: + // * the same as the farthest it can scroll down, + // * or the point where the top end of the list + // touches the top edge of the viewport, + // whichever is farther up. + final effectiveMin = math.min(effectiveMax, + wholeMinScrollExtent + viewportDimension); + + // The first point comes into effect when the list is short, + // so the whole thing fits into the viewport. In that case, + // the only scroll position allowed is with the bottom end of the list + // at the bottom edge of the viewport. + + // The upstream answer (with no `applyContentDimensionsRaw`) would + // effectively say: + // final effectiveMin = math.min(0.0, + // wholeMinScrollExtent + viewportDimension); + // + // In other words, the farthest the list can scroll up might be farther up + // than the answer here: it could always scroll up to 0.0, meaning that the + // boundary between slivers is at the bottom edge of the viewport. + // Whenever the top sliver is shorter than the viewport (and the bottom + // sliver isn't empty), this would mean one can scroll up past + // the top of the list, even though that scrolls other content offscreen. + + return applyContentDimensions(effectiveMin, effectiveMax); + } + + bool _nearEqual(double a, double b) => + nearEqual(a, b, Tolerance.defaultTolerance.distance); + + bool _hasEverCompletedLayout = false; + + @override + bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) { + // Inspired by _TabBarScrollPosition.applyContentDimensions upstream. + bool changed = false; + + if (!_hasEverCompletedLayout) { + // The list is being laid out for the first time (its first performLayout). + // Start out scrolled down so the bottom sliver (the new messages) + // occupies 75% of the viewport, + // or at the in-range scroll position closest to that. + // This also brings [pixels] within bounds, which + // the initial value of 0.0 might not have been. + final target = clampDouble(0.75 * viewportDimension, + minScrollExtent, maxScrollExtent); + if (!hasPixels || pixels != target) { + correctPixels(target); + changed = true; + } + } else if (_nearEqual(pixels, this.maxScrollExtent) + && !_nearEqual(pixels, maxScrollExtent)) { + // The list was scrolled to the end before this layout round. + // Make sure it stays at the end. + // (For example, show the new message that just arrived.) + correctPixels(maxScrollExtent); + changed = true; + } + + // This step must come after the first-time correction above. + // Otherwise, if the initial [pixels] value of 0.0 was out of bounds + // (which happens if the top slivers are shorter than the viewport), + // then the base implementation of [applyContentDimensions] would + // bring it in bounds via a scrolling animation, which isn't right when + // starting from the meaningless initial 0.0 value. + // + // For the "stays at the end" correction, it's not clear if the order + // matters in practice. But the doc on [applyNewDimensions], called by + // the base [applyContentDimensions], says it should come after any + // calls to [correctPixels]; so OK, do this after the [correctPixels]. + if (!super.applyContentDimensions(minScrollExtent, maxScrollExtent)) { + changed = true; + } + + if (!changed) { + // Because this method is about to return true, + // this will be the last round of this layout. + _hasEverCompletedLayout = true; + } + + return !changed; + } + + /// Scroll the position smoothly to the end of the scrollable content. + /// + /// This is similar to calling [animateTo] with a target of [maxScrollExtent], + /// except that if [maxScrollExtent] changes over the course of the animation + /// (for example due to more content being added at the end, + /// or due to the estimated length of the content changing as + /// different items scroll into the viewport), + /// this animation will carry on until it reaches the updated value + /// of [maxScrollExtent], not the value it had at the start of the animation. + /// + /// The animation is typically handled by a [ScrollToEndActivity]. + void scrollToEnd() { + final tolerance = physics.toleranceFor(this); + if (nearEqual(pixels, maxScrollExtent, tolerance.distance)) { + // Skip the animation; jump right to the target, which is already close. + jumpTo(maxScrollExtent); + return; + } + + if (pixels > maxScrollExtent) { + // The position is already scrolled past the end. Let overscroll handle it. + // (This situation shouldn't even arise; the UI only offers this option + // when `pixels < maxScrollExtent`.) + goBallistic(0.0); + return; + } + + beginActivity(ScrollToEndActivity(this)); + } +} + +/// A version of [ScrollController] adapted for the Zulip message list. +class MessageListScrollController extends ScrollController { + MessageListScrollController({ + super.initialScrollOffset, + super.keepScrollOffset, + super.debugLabel, + super.onAttach, + super.onDetach, + }); + + @override + MessageListScrollPosition get position => super.position as MessageListScrollPosition; + + @override + MessageListScrollPosition createScrollPosition(ScrollPhysics physics, + ScrollContext context, ScrollPosition? oldPosition) { + return MessageListScrollPosition( + physics: physics, + context: context, + initialPixels: initialScrollOffset, + keepScrollOffset: keepScrollOffset, + oldPosition: oldPosition, + debugLabel: debugLabel, + ); + } +} + +/// A version of [CustomScrollView] adapted for the Zulip message list. +/// +/// This lets us customize behavior in ways that aren't currently supported +/// by the fields of [CustomScrollView] itself. +class MessageListScrollView extends CustomScrollView { + const MessageListScrollView({ + super.key, + super.scrollDirection, + super.reverse, + super.controller, + super.primary, + super.physics, + super.scrollBehavior, + // super.shrinkWrap, // omitted, always false + super.center, + super.cacheExtent, + super.slivers, + super.semanticChildCount, + super.dragStartBehavior, + super.keyboardDismissBehavior, + super.restorationId, + super.clipBehavior, + super.hitTestBehavior, + super.paintOrder, + }); + + @override + Widget buildViewport(BuildContext context, ViewportOffset offset, + AxisDirection axisDirection, List slivers) { + return MessageListViewport( + axisDirection: axisDirection, + offset: offset, + slivers: slivers, + cacheExtent: cacheExtent, + center: center, + clipBehavior: clipBehavior, + paintOrder: paintOrder, + ); + } +} + +/// The version of [Viewport] that underlies [MessageListScrollView]. +class MessageListViewport extends Viewport { + MessageListViewport({ + super.key, + super.axisDirection, + super.crossAxisDirection, + required super.offset, + super.center, + super.cacheExtent, + super.cacheExtentStyle, + super.slivers, + super.clipBehavior, + required super.paintOrder, + }); + + @override + RenderViewport createRenderObject(BuildContext context) { + return RenderMessageListViewport( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection + ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection), + offset: offset, + cacheExtent: cacheExtent, + cacheExtentStyle: cacheExtentStyle, + clipBehavior: clipBehavior, + paintOrder: paintOrder, + ); + } +} + +/// The version of [RenderViewport] that underlies [MessageListViewport] +/// and [MessageListScrollView]. +// TODO(upstream): Devise upstream APIs to obviate the duplicated code here; +// use `git log -L` to see what edits we've made locally. +class RenderMessageListViewport extends RenderViewport { + RenderMessageListViewport({ + super.axisDirection, + required super.crossAxisDirection, + required super.offset, + super.children, + super.center, + super.cacheExtent, + super.cacheExtentStyle, + super.clipBehavior, + required super.paintOrder, + }); + + @override + double get anchor => 1.0; + + double? _calculatedCacheExtent; + + @override + Rect describeSemanticsClip(RenderSliver? child) { + if (_calculatedCacheExtent == null) { + return semanticBounds; + } + + switch (axis) { + case Axis.vertical: + return Rect.fromLTRB( + semanticBounds.left, + semanticBounds.top - _calculatedCacheExtent!, + semanticBounds.right, + semanticBounds.bottom + _calculatedCacheExtent!, + ); + case Axis.horizontal: + return Rect.fromLTRB( + semanticBounds.left - _calculatedCacheExtent!, + semanticBounds.top, + semanticBounds.right + _calculatedCacheExtent!, + semanticBounds.bottom, + ); + } + } + + static const int _maxLayoutCyclesPerChild = 10; + + // Out-of-band data computed during layout. + late double _minScrollExtent; + late double _maxScrollExtent; + bool _hasVisualOverflow = false; + + @override + void performLayout() { + // Ignore the return value of applyViewportDimension because we are + // doing a layout regardless. + switch (axis) { + case Axis.vertical: + offset.applyViewportDimension(size.height); + case Axis.horizontal: + offset.applyViewportDimension(size.width); + } + + if (center == null) { + assert(firstChild == null); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + offset.applyContentDimensions(0.0, 0.0); + return; + } + assert(center!.parent == this); + + final (double mainAxisExtent, double crossAxisExtent) = switch (axis) { + Axis.vertical => (size.height, size.width), + Axis.horizontal => (size.width, size.height), + }; + + final double centerOffsetAdjustment = center!.centerOffsetAdjustment; + final int maxLayoutCycles = _maxLayoutCyclesPerChild * childCount; + + double correction; + int count = 0; + do { + correction = _attemptLayout( + mainAxisExtent, + crossAxisExtent, + offset.pixels + centerOffsetAdjustment, + ); + if (correction != 0.0) { + offset.correctBy(correction); + } else { + // TODO(upstream): Move applyContentDimensionsRaw to ViewportOffset + // (possibly with an API change to tell it [anchor]?); + // give it a default implementation calling applyContentDimensions; + // have RenderViewport.performLayout call it. + if ((offset as MessageListScrollPosition) + .applyContentDimensionsRaw(_minScrollExtent, _maxScrollExtent)) { + break; + } + } + count += 1; + } while (count < maxLayoutCycles); + assert(() { + if (count >= maxLayoutCycles) { + assert(count != 1); + throw FlutterError( + 'A RenderViewport exceeded its maximum number of layout cycles.\n' + 'RenderViewport render objects, during layout, can retry if either their ' + 'slivers or their ViewportOffset decide that the offset should be corrected ' + 'to take into account information collected during that layout.\n' + 'In the case of this RenderViewport object, however, this happened $count ' + 'times and still there was no consensus on the scroll offset. This usually ' + 'indicates a bug. Specifically, it means that one of the following three ' + 'problems is being experienced by the RenderViewport object:\n' + ' * One of the RenderSliver children or the ViewportOffset have a bug such' + ' that they always think that they need to correct the offset regardless.\n' + ' * Some combination of the RenderSliver children and the ViewportOffset' + ' have a bad interaction such that one applies a correction then another' + ' applies a reverse correction, leading to an infinite loop of corrections.\n' + ' * There is a pathological case that would eventually resolve, but it is' + ' so complicated that it cannot be resolved in any reasonable number of' + ' layout passes.', + ); + } + return true; + }()); + } + + double _attemptLayout(double mainAxisExtent, double crossAxisExtent, double correctedOffset) { + assert(!mainAxisExtent.isNaN); + assert(mainAxisExtent >= 0.0); + assert(crossAxisExtent.isFinite); + assert(crossAxisExtent >= 0.0); + assert(correctedOffset.isFinite); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + + // centerOffset is the offset from the leading edge of the RenderViewport + // to the zero scroll offset (the line between the forward slivers and the + // reverse slivers). + assert(anchor == 1.0); + final double centerOffset = mainAxisExtent * anchor - correctedOffset; + final double reverseDirectionRemainingPaintExtent = clampDouble( + centerOffset, + 0.0, + mainAxisExtent, + ); + final double forwardDirectionRemainingPaintExtent = clampDouble( + mainAxisExtent - centerOffset, + 0.0, + mainAxisExtent, + ); + + _calculatedCacheExtent = switch (cacheExtentStyle) { + CacheExtentStyle.pixel => cacheExtent, + CacheExtentStyle.viewport => mainAxisExtent * cacheExtent!, + }; + + final double fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!; + final double centerCacheOffset = centerOffset + _calculatedCacheExtent!; + final double reverseDirectionRemainingCacheExtent = clampDouble( + centerCacheOffset, + 0.0, + fullCacheExtent, + ); + final double forwardDirectionRemainingCacheExtent = clampDouble( + fullCacheExtent - centerCacheOffset, + 0.0, + fullCacheExtent, + ); + + final RenderSliver? leadingNegativeChild = childBefore(center!); + + if (leadingNegativeChild != null) { + // negative scroll offsets + final double result = layoutChildSequence( + child: leadingNegativeChild, + scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent, + overlap: 0.0, + layoutOffset: forwardDirectionRemainingPaintExtent, + remainingPaintExtent: reverseDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.reverse, + advance: childBefore, + remainingCacheExtent: reverseDirectionRemainingCacheExtent, + cacheOrigin: clampDouble(mainAxisExtent - centerOffset, -_calculatedCacheExtent!, 0.0), + ); + if (result != 0.0) { + return -result; + } + } + + // positive scroll offsets + return layoutChildSequence( + child: center, + scrollOffset: math.max(0.0, -centerOffset), + overlap: leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0, + layoutOffset: + centerOffset >= mainAxisExtent ? centerOffset : reverseDirectionRemainingPaintExtent, + remainingPaintExtent: forwardDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.forward, + advance: childAfter, + remainingCacheExtent: forwardDirectionRemainingCacheExtent, + cacheOrigin: clampDouble(centerOffset, -_calculatedCacheExtent!, 0.0), + ); + } + + @override + bool get hasVisualOverflow => _hasVisualOverflow; + + @override + void updateOutOfBandData(GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) { + switch (growthDirection) { + case GrowthDirection.forward: + _maxScrollExtent += childLayoutGeometry.scrollExtent; + case GrowthDirection.reverse: + _minScrollExtent -= childLayoutGeometry.scrollExtent; + } + if (childLayoutGeometry.hasVisualOverflow) { + _hasVisualOverflow = true; + } + } + +} diff --git a/lib/widgets/set_status.dart b/lib/widgets/set_status.dart new file mode 100644 index 0000000000..4e13c13cab --- /dev/null +++ b/lib/widgets/set_status.dart @@ -0,0 +1,332 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../api/route/users.dart'; +import '../basic.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../log.dart'; +import 'app_bar.dart'; +import 'emoji_reaction.dart'; +import 'icons.dart'; +import 'inset_shadow.dart'; +import 'page.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; +import 'user.dart'; + +class SetStatusPage extends StatefulWidget { + const SetStatusPage({super.key, required this.oldStatus}); + + final UserStatus oldStatus; + + static AccountRoute buildRoute({ + required BuildContext context, + required UserStatus oldStatus, + }) { + return MaterialAccountWidgetRoute(context: context, + page: SetStatusPage(oldStatus: oldStatus)); + } + + @override + State createState() => _SetStatusPageState(); +} + +class _SetStatusPageState extends State { + late final TextEditingController statusTextController; + late final ValueNotifier statusChange; + + UserStatus get oldStatus => widget.oldStatus; + UserStatus get newStatus => statusChange.value.apply(widget.oldStatus); + + @override + void initState() { + super.initState(); + statusTextController = TextEditingController(text: oldStatus.text) + ..addListener(() { + final trimmedValue = statusTextController.text.trim(); + final text = trimmedValue.isNotEmpty ? trimmedValue : null; + + // Ignore updating [statusChange] for the additional updates with the + // same value from TextField. For example, when there is a change in + // selection or in composing range. + if (text == newStatus.text) return; + + statusChange.value = statusChange.value.copyWith( + text: asChange(text, old: oldStatus.text)); + }); + statusChange = + ValueNotifier(UserStatusChange(text: OptionNone(), emoji: OptionNone())) + ..addListener(() { + final text = statusChange.value.text.or(oldStatus.text) ?? ''; + + // Ignore updating the status text field if it already has the same + // text. It can happen in the following cases: + // 1. Only the emoji is changed. + // 2. The same status is chosen consecutively from the suggested + // statuses list. + // 3. This listener is called as a result of the change in status + // text field. + if (text == statusTextController.text) return; + + statusTextController.text = text; + }); + } + + @override + void dispose() { + statusTextController.dispose(); + statusChange.dispose(); + super.dispose(); + } + + List statusSuggestions(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final values = [ + ('1f6e0', zulipLocalizations.userStatusBusy), + ('1f4c5', zulipLocalizations.userStatusInAMeeting), + ('1f68c', zulipLocalizations.userStatusCommuting), + ('1f912', zulipLocalizations.userStatusOutSick), + ('1f334', zulipLocalizations.userStatusVacationing), + ('1f3e0', zulipLocalizations.userStatusWorkingRemotely), + ('1f3e2', zulipLocalizations.userStatusAtTheOffice), + ]; + return [ + for (final (emojiCode, statusText) in values) + if (store.getUnicodeEmojiNameByCode(emojiCode) case final emojiName?) + UserStatus( + text: statusText, + emoji: StatusEmoji(emojiName: emojiName, emojiCode: emojiCode, + reactionType: ReactionType.unicodeEmoji)), + ]; + } + + void handleStatusClear() { + statusChange.value = UserStatusChange( + text: asChange(null, old: oldStatus.text), + emoji: asChange(null, old: oldStatus.emoji), + ); + } + + Future handleStatusSave() async { + final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + Navigator.pop(context); + if (newStatus == oldStatus) return; + + try { + await updateStatus(store.connection, change: statusChange.value); + } catch (e) { + reportErrorToUserBriefly(zulipLocalizations.updateStatusErrorTitle); + } + } + + void chooseStatusEmoji() async { + final emojiCandidate = await showEmojiPickerSheet(pageContext: context); + if (emojiCandidate == null) return; + + final emoji = StatusEmoji( + emojiName: emojiCandidate.emojiName, + emojiCode: emojiCandidate.emojiCode, + reactionType: emojiCandidate.emojiType, + ); + statusChange.value = statusChange.value.copyWith( + emoji: asChange(emoji, old: oldStatus.emoji)); + } + + void chooseStatusSuggestion(UserStatus status) { + statusChange.value = UserStatusChange( + text: asChange(status.text, old: oldStatus.text), + emoji: asChange(status.emoji, old: oldStatus.emoji)); + } + + Option asChange(T new_, {required T old}) => + new_ == old ? OptionNone() : OptionSome(new_); + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final suggestions = statusSuggestions(context); + + return Scaffold( + appBar: ZulipAppBar(title: Text(zulipLocalizations.setStatusPageTitle), + actions: [ + ValueListenableBuilder( + valueListenable: statusChange, + builder: (_, _, _) { + return _ActionButton( + label: zulipLocalizations.statusClearButtonLabel, + icon: ZulipIcons.remove, + onPressed: newStatus == UserStatus.zero + ? null + : handleStatusClear, + ); + }), + ValueListenableBuilder( + valueListenable: statusChange, + builder: (_, change, _) { + return _ActionButton( + label: zulipLocalizations.statusSaveButtonLabel, + icon: ZulipIcons.check, + onPressed: switch ((change.text, change.emoji)) { + (OptionNone(), OptionNone()) => null, + _ => handleStatusSave, + }); + }), + ], + ), + body: SafeArea( + bottom: false, + minimum: EdgeInsets.symmetric(horizontal: 8), + child: Column(children: [ + Padding( + padding: const EdgeInsetsDirectional.only( + top: 8, + // In Figma design, this is 4px, be we compensate for that in + // [SingleChildScrollView.padding] below. + bottom: 0), + child: Row( + spacing: 4, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + onPressed: chooseStatusEmoji, + style: IconButton.styleFrom( + splashFactory: NoSplash.splashFactory, + foregroundColor: designVariables.icon, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.symmetric( + vertical: 8, + // In Figma design, there is no horizontal padding, but we + // provide it in order to create a proper tap target size. + horizontal: 8)), + icon: Row(spacing: 4, children: [ + ValueListenableBuilder( + valueListenable: statusChange, + builder: (_, change, _) { + final emoji = change.emoji.or(oldStatus.emoji); + return emoji == null + ? const Icon(ZulipIcons.smile, size: 24) + : UserStatusEmoji(emoji: emoji, size: 24, neverAnimate: false); + }), + Icon(ZulipIcons.chevron_down, size: 16), + ]), + ), + Expanded(child: TextField( + controller: statusTextController, + minLines: 1, + maxLines: 2, + // The limit on the size of the status text is 60 characters. + // See: https://zulip.com/api/update-status#parameter-status_text + maxLength: 60, + cursorColor: designVariables.textInput, + textCapitalization: TextCapitalization.sentences, + style: TextStyle(fontSize: 19, height: 24 / 19), + decoration: InputDecoration( + // TODO: display a counter as suggested in CZO discussion: + // https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/Set.20user.20status/near/2224549 + counterText: '', + hintText: zulipLocalizations.statusTextHint, + hintStyle: TextStyle(color: designVariables.labelSearchPrompt), + isDense: true, + contentPadding: EdgeInsets.symmetric( + vertical: 8, + // Subtracting 4 pixels to account for the internal + // 4-pixel horizontal padding. + horizontal: 10 - 4, + ), + filled: true, + fillColor: designVariables.bgSearchInput, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + )))), + ]), + ), + Expanded(child: InsetShadowBox( + top: 6, + color: designVariables.mainBackground, + child: SingleChildScrollView( + padding: EdgeInsets.only(top: 6), + child: Column(children: [ + for (final status in suggestions) + StatusSuggestionsListEntry( + status: status, + onTap: () => chooseStatusSuggestion(status)), + ])))), + ])), + ); + } +} + +class _ActionButton extends StatelessWidget { + const _ActionButton({ + required this.label, + required this.icon, + required this.onPressed, + }); + + final String label; + final IconData icon; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return TextButton( + onPressed: onPressed, + style: IconButton.styleFrom( + splashFactory: NoSplash.splashFactory, + foregroundColor: designVariables.icon, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.symmetric(vertical: 6, horizontal: 8)), + child: Row( + spacing: 4, + children: [ + Icon(icon, size: 24), + Text(label, + style: TextStyle( + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 600))), + ])); + } +} + +@visibleForTesting +class StatusSuggestionsListEntry extends StatelessWidget { + const StatusSuggestionsListEntry({ + super.key, + required this.status, + required this.onTap, + }); + + final UserStatus status; + final GestureTapCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 7, horizontal: 8), + child: Row( + spacing: 8, + children: [ + UserStatusEmoji(emoji: status.emoji!, size: 24), + Flexible(child: Text(status.text!, + style: TextStyle(fontSize: 19, height: 24 / 19), + maxLines: 1, + overflow: TextOverflow.ellipsis)), + ])), + ); + } +} diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart new file mode 100644 index 0000000000..5995cdcbfe --- /dev/null +++ b/lib/widgets/settings.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; + +import '../generated/l10n/zulip_localizations.dart'; +import '../model/settings.dart'; +import 'app_bar.dart'; +import 'page.dart'; +import 'store.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + static AccountRoute buildRoute({required BuildContext context}) { + return MaterialAccountWidgetRoute( + context: context, page: const SettingsPage()); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return Scaffold( + appBar: ZulipAppBar( + title: Text(zulipLocalizations.settingsPageTitle)), + body: Column(children: [ + const _ThemeSetting(), + const _BrowserPreferenceSetting(), + const _VisitFirstUnreadSetting(), + const _MarkReadOnScrollSetting(), + if (GlobalSettingsStore.experimentalFeatureFlags.isNotEmpty) + ListTile( + title: Text(zulipLocalizations.experimentalFeatureSettingsPageTitle), + onTap: () => Navigator.push(context, + ExperimentalFeaturesPage.buildRoute())) + ])); + } +} + +class _ThemeSetting extends StatelessWidget { + const _ThemeSetting(); + + void _handleChange(BuildContext context, ThemeSetting? newThemeSetting) { + final globalSettings = GlobalStoreWidget.settingsOf(context); + globalSettings.setThemeSetting(newThemeSetting); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + return RadioGroup( + groupValue: globalSettings.themeSetting, + onChanged: (newValue) => _handleChange(context, newValue), + child: Column( + children: [ + ListTile(title: Text(zulipLocalizations.themeSettingTitle)), + for (final themeSettingOption in [null, ...ThemeSetting.values]) + RadioListTile.adaptive( + title: Text(ThemeSetting.displayName( + themeSetting: themeSettingOption, + zulipLocalizations: zulipLocalizations)), + value: themeSettingOption), + ])); + } +} + +class _BrowserPreferenceSetting extends StatelessWidget { + const _BrowserPreferenceSetting(); + + void _handleChange(BuildContext context, bool newOpenLinksWithInAppBrowser) { + final globalSettings = GlobalStoreWidget.settingsOf(context); + globalSettings.setBrowserPreference( + newOpenLinksWithInAppBrowser ? BrowserPreference.inApp + : BrowserPreference.external); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + final openLinksWithInAppBrowser = + globalSettings.effectiveBrowserPreference == BrowserPreference.inApp; + return SwitchListTile.adaptive( + title: Text(zulipLocalizations.openLinksWithInAppBrowser), + value: openLinksWithInAppBrowser, + onChanged: (newValue) => _handleChange(context, newValue)); + } +} + +class _VisitFirstUnreadSetting extends StatelessWidget { + const _VisitFirstUnreadSetting(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + return ListTile( + title: Text(zulipLocalizations.initialAnchorSettingTitle), + subtitle: Text(VisitFirstUnreadSettingPage._valueDisplayName( + globalSettings.visitFirstUnread, zulipLocalizations: zulipLocalizations)), + onTap: () => Navigator.push(context, + VisitFirstUnreadSettingPage.buildRoute())); + } +} + +class VisitFirstUnreadSettingPage extends StatelessWidget { + const VisitFirstUnreadSettingPage({super.key}); + + static WidgetRoute buildRoute() { + return MaterialWidgetRoute(page: const VisitFirstUnreadSettingPage()); + } + + static String _valueDisplayName(VisitFirstUnreadSetting value, { + required ZulipLocalizations zulipLocalizations, + }) { + return switch (value) { + VisitFirstUnreadSetting.always => + zulipLocalizations.initialAnchorSettingFirstUnreadAlways, + VisitFirstUnreadSetting.conversations => + zulipLocalizations.initialAnchorSettingFirstUnreadConversations, + VisitFirstUnreadSetting.never => + zulipLocalizations.initialAnchorSettingNewestAlways, + }; + } + + void _handleChange(BuildContext context, VisitFirstUnreadSetting? value) { + if (value == null) return; // TODO(log); can this actually happen? how? + final globalSettings = GlobalStoreWidget.settingsOf(context); + globalSettings.setVisitFirstUnread(value); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + return Scaffold( + appBar: AppBar(title: Text(zulipLocalizations.initialAnchorSettingTitle)), + body: RadioGroup( + groupValue: globalSettings.visitFirstUnread, + onChanged: (newValue) => _handleChange(context, newValue), + child: Column(children: [ + ListTile(title: Text(zulipLocalizations.initialAnchorSettingDescription)), + for (final value in VisitFirstUnreadSetting.values) + RadioListTile.adaptive( + title: Text(_valueDisplayName(value, + zulipLocalizations: zulipLocalizations)), + value: value), + ]))); + } +} + +class _MarkReadOnScrollSetting extends StatelessWidget { + const _MarkReadOnScrollSetting(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + return ListTile( + title: Text(zulipLocalizations.markReadOnScrollSettingTitle), + subtitle: Text(MarkReadOnScrollSettingPage._valueDisplayName( + globalSettings.markReadOnScroll, zulipLocalizations: zulipLocalizations)), + onTap: () => Navigator.push(context, + MarkReadOnScrollSettingPage.buildRoute())); + } +} + +class MarkReadOnScrollSettingPage extends StatelessWidget { + const MarkReadOnScrollSettingPage({super.key}); + + static WidgetRoute buildRoute() { + return MaterialWidgetRoute(page: const MarkReadOnScrollSettingPage()); + } + + static String _valueDisplayName(MarkReadOnScrollSetting value, { + required ZulipLocalizations zulipLocalizations, + }) { + return switch (value) { + MarkReadOnScrollSetting.always => + zulipLocalizations.markReadOnScrollSettingAlways, + MarkReadOnScrollSetting.conversations => + zulipLocalizations.markReadOnScrollSettingConversations, + MarkReadOnScrollSetting.never => + zulipLocalizations.markReadOnScrollSettingNever, + }; + } + + static String? _valueDescription(MarkReadOnScrollSetting value, { + required ZulipLocalizations zulipLocalizations, + }) { + return switch (value) { + MarkReadOnScrollSetting.always => null, + MarkReadOnScrollSetting.conversations => + zulipLocalizations.markReadOnScrollSettingConversationsDescription, + MarkReadOnScrollSetting.never => null, + }; + } + + void _handleChange(BuildContext context, MarkReadOnScrollSetting? value) { + if (value == null) return; // TODO(log); can this actually happen? how? + final globalSettings = GlobalStoreWidget.settingsOf(context); + globalSettings.setMarkReadOnScroll(value); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + return Scaffold( + appBar: AppBar(title: Text(zulipLocalizations.markReadOnScrollSettingTitle)), + body: RadioGroup( + groupValue: globalSettings.markReadOnScroll, + onChanged: (newValue) => _handleChange(context, newValue), + child: Column(children: [ + ListTile(title: Text(zulipLocalizations.markReadOnScrollSettingDescription)), + for (final value in MarkReadOnScrollSetting.values) + RadioListTile.adaptive( + title: Text(_valueDisplayName(value, + zulipLocalizations: zulipLocalizations)), + subtitle: () { + final result = _valueDescription(value, + zulipLocalizations: zulipLocalizations); + return result == null ? null : Text(result); + }(), + value: value), + ]))); + } +} + +class ExperimentalFeaturesPage extends StatelessWidget { + const ExperimentalFeaturesPage({super.key}); + + static WidgetRoute buildRoute() { + return MaterialWidgetRoute(page: const ExperimentalFeaturesPage()); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + final flags = GlobalSettingsStore.experimentalFeatureFlags; + assert(flags.isNotEmpty); + return Scaffold( + appBar: AppBar( + title: Text(zulipLocalizations.experimentalFeatureSettingsPageTitle)), + body: Column(children: [ + ListTile( + title: Text(zulipLocalizations.experimentalFeatureSettingsWarning)), + for (final flag in flags) + SwitchListTile.adaptive( + title: Text(flag.name), // no i18n; these are developer-facing settings + value: globalSettings.getBool(flag), + onChanged: (value) => globalSettings.setBool(flag, value)), + ])); + } +} diff --git a/lib/widgets/share.dart b/lib/widgets/share.dart new file mode 100644 index 0000000000..e8762a7d53 --- /dev/null +++ b/lib/widgets/share.dart @@ -0,0 +1,208 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:mime/mime.dart'; + +import '../generated/l10n/zulip_localizations.dart'; +import '../host/android_intents.dart'; +import '../log.dart'; +import '../model/binding.dart'; +import '../model/narrow.dart'; +import 'app.dart'; +import 'color.dart'; +import 'compose_box.dart'; +import 'dialog.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'recent_dm_conversations.dart'; +import 'store.dart'; +import 'subscription_list.dart'; +import 'theme.dart'; + +// Responds to receiving shared content from other apps. +class ShareService { + const ShareService._(); + + static Future start() async { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + ZulipBinding.instance.androidIntentEvents.listen((event) { + switch (event) { + case AndroidIntentSendEvent(): + _handleSend(event); + } + }); + + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + // Do nothing; we don't support receiving shared content from + // other apps on these platforms. + break; + } + } + + static Future _handleSend(AndroidIntentSendEvent intentSendEvent) async { + assert(defaultTargetPlatform == TargetPlatform.android); + + assert(debugLog('intentSendEvent.action: ${intentSendEvent.action}')); + assert(debugLog('intentSendEvent.extraText: ${intentSendEvent.extraText}')); + assert(debugLog('intentSendEvent.extraStream?.length: ${intentSendEvent.extraStream?.length}')); + + final navigator = await ZulipApp.navigator; + final context = navigator.context; + assert(context.mounted); + if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that + + final globalStore = GlobalStoreWidget.of(context); + + // TODO(#1779) allow selecting account, if there are multiple + final accountId = globalStore.lastVisitedAccount?.id + ?? globalStore.accountIds.firstOrNull; + + if (accountId == null) { + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog( + context: context, + title: zulipLocalizations.errorSharingTitle, + message: zulipLocalizations.errorSharingAccountNotLoggedIn); + return; + } + + final sharedFiles = intentSendEvent.extraStream?.map((sharedFile) { + var mimeType = sharedFile.mimeType; + + // Try to guess the mimeType from file header magic-number. + mimeType ??= lookupMimeType( + // Seems like the path shouldn't be required; we still want to look for + // matches on `headerBytes` when we don't have a path/filename. + // Thankfully we can still do that, by calling lookupMimeType with the + // empty string as the path. That's a value that doesn't map to any + // particular type, so the path will be effectively ignored, as desired. + // Upstream comment: + // https://github.com/dart-lang/mime/issues/11#issuecomment-2246824452 + sharedFile.name ?? '', + headerBytes: List.unmodifiable( + sharedFile.bytes.take(defaultMagicNumbersMaxLength))); + + final filename = + sharedFile.name ?? 'unknown.${mimeType?.split('/').last ?? 'bin'}'; + + return FileToUpload( + content: Stream.value(sharedFile.bytes), + length: sharedFile.bytes.length, + filename: filename, + mimeType: mimeType); + }); + + unawaited(navigator.push( + SharePage.buildRoute( + accountId: accountId, + sharedFiles: sharedFiles, + sharedText: intentSendEvent.extraText))); + } +} + +class SharePage extends StatelessWidget { + const SharePage({ + super.key, + required this.sharedFiles, + required this.sharedText, + }); + + final Iterable? sharedFiles; + final String? sharedText; + + static AccountRoute buildRoute({ + required int accountId, + required Iterable? sharedFiles, + required String? sharedText, + }) { + return MaterialAccountWidgetRoute( + accountId: accountId, + page: SharePage( + sharedFiles: sharedFiles, + sharedText: sharedText)); + } + + void _handleNarrowSelect(BuildContext context, Narrow narrow) { + final messageListPageKey = GlobalKey(); + + // Push the message list page, replacing the share page. + unawaited(Navigator.pushReplacement(context, + MessageListPage.buildRoute( + context: context, + key: messageListPageKey, + narrow: narrow))); + + // Wait for the message list page to appear in the widget tree. + SchedulerBinding.instance.addPostFrameCallback((_) async { + final messageListPageState = messageListPageKey.currentState; + if (messageListPageState == null) return; // TODO(log) + final composeBoxState = messageListPageState.composeBoxState; + if (composeBoxState == null) return; // TODO(log) + + final composeBoxController = composeBoxState.controller; + + // Focus on the topic input if there is one, else focus on content + // input, if not already focused. + composeBoxController.requestFocusIfUnfocused(); + + // We can receive both: the file/s and an accompanying text, + // so first populate the compose box with the text, if there is any. + if (sharedText case var text?) { + if (!text.endsWith('\n')) text += '\n'; + composeBoxController.content.insertPadded(text); + } + // Then upload the files and populate the content input with their links. + if (sharedFiles != null) { + await composeBoxController.uploadFiles( + context: composeBoxState.context, + files: sharedFiles!, + // We handle requesting focus ourselves above. + shouldRequestFocus: false); + } + }); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: Text(zulipLocalizations.sharePageTitle), + bottom: TabBar( + indicatorColor: designVariables.icon, + labelColor: designVariables.foreground, + unselectedLabelColor: designVariables.foreground.withFadedAlpha(0.7), + splashFactory: NoSplash.splashFactory, + tabs: [ + Tab(text: zulipLocalizations.channelsPageTitle), + Tab(text: zulipLocalizations.recentDmConversationsPageTitle), + ])), + body: TabBarView(children: [ + SubscriptionListPageBody( + showTopicListButtonInActionSheet: false, + hideChannelsIfUserCantPost: true, + onChannelSelect: (narrow) => _handleNarrowSelect(context, narrow), + // TODO(#412) add onTopicSelect, Currently when user lands on the + // channel feed page from subscription list page and they tap + // on the topic recipient header, the user is brought to the + // topic message list, but without the share content. So, we + // might want to force the user to choose a topic or start a + // new topic from the subscription list page. + ), + RecentDmConversationsPageBody( + hideDmsIfUserCantPost: true, + onDmSelect: (narrow) => _handleNarrowSelect(context, narrow)), + ]))); + } +} diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 356a056094..fcf3fdb4b3 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -99,6 +99,18 @@ class RenderStickyHeaderItem extends RenderProxyBox { /// or if [scrollDirection] is horizontal then to the start in the /// reading direction of the ambient [Directionality]. /// It can be controlled with [reverseHeader]. +/// +/// Much like [ListView], a [StickyHeaderListView] is basically +/// a [CustomScrollView] with a single sliver in its [CustomScrollView.slivers] +/// property. +/// For a [StickyHeaderListView], that sliver is a [SliverStickyHeaderList]. +/// +/// If more than one sliver is needed, any code using [StickyHeaderListView] +/// can be ported to use [CustomScrollView] directly, in much the same way +/// as for code using [ListView]. See [ListView] for details. +/// +/// See also: +/// * [SliverStickyHeaderList], which provides the sticky-header behavior. class StickyHeaderListView extends BoxScrollView { // Like ListView, but with sticky headers. StickyHeaderListView({ @@ -296,6 +308,31 @@ enum _HeaderGrowthPlacement { growthEnd } +/// A list sliver with sticky headers. +/// +/// This widget takes most of its behavior from [SliverList], +/// but adds sticky headers as described at [StickyHeaderListView]. +/// +/// ## Overflow across slivers +/// +/// When the list item that controls the sticky header has +/// [StickyHeaderItem.allowOverflow] true, the header will be permitted +/// to overflow not only the item but this whole sliver. +/// (This provides seamless behavior if, for example, two back-to-back slivers +/// are used for implementing a double-ended scrollable list.) +/// +/// The caller is responsible for arranging the paint order between slivers +/// so that this works correctly: a sliver that might overflow must be painted +/// after any sliver it might overflow onto. +/// For example if [headerPlacement] puts headers at the left of the viewport +/// (and any items with [StickyHeaderItem.allowOverflow] true are present), +/// then this [SliverStickyHeaderList] must paint after any slivers that appear +/// to the right of this sliver. +/// +/// To control the viewport's paint order, use [ScrollView.paintOrder]. +/// There [SliverPaintOrder.firstIsTop] for [HeaderPlacement.scrollingStart], +/// or [SliverPaintOrder.lastIsTop] for [HeaderPlacement.scrollingEnd], +/// suffices for meeting the needs above. class SliverStickyHeaderList extends RenderObjectWidget { SliverStickyHeaderList({ super.key, @@ -306,7 +343,16 @@ class SliverStickyHeaderList extends RenderObjectWidget { delegate: delegate, ); + /// Whether the sticky header appears at the start or the end + /// in the scrolling direction. + /// + /// For example, if the enclosing [Viewport] has [Viewport.axisDirection] + /// of [AxisDirection.down], then + /// [HeaderPlacement.scrollingStart] means the header appears at + /// the top of the viewport, and + /// [HeaderPlacement.scrollingEnd] means it appears at the bottom. final HeaderPlacement headerPlacement; + final _SliverStickyHeaderListInner _child; @override @@ -489,6 +535,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 +603,104 @@ 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); - - 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), - maxPaintExtent: math.max(geometry.maxPaintExtent, headerExtent), - hitTestExtent: math.max(geometry.hitTestExtent, paintedHeaderSize), - hasVisualOverflow: geometry.hasVisualOverflow - || headerExtent > constraints.remainingPaintExtent, - ); - - headerOffset = _headerAtCoordinateEnd() - ? geometry.layoutExtent - headerExtent - : 0.0; + // 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. + + if (headerExtent <= childExtent) { + // The header fits within the child sliver. + // So it doesn't affect this sliver's overall geometry. + + headerOffset = _headerAtCoordinateEnd() + ? childExtent - headerExtent + : 0.0; + } else { + // The header will overflow the child sliver. + // That makes this sliver's geometry a bit more complicated. + + // This sliver's paint region consists entirely of the header. + final paintExtent = headerExtent; + headerOffset = 0.0; + + // Its layout region (affecting where the next sliver begins layout) + // is that given by the child sliver. + final layoutExtent = childExtent; + + // The paint origin places this sliver's paint region relative to its + // layout region so that they share the edge the header appears at + // (which should be the edge of the viewport). + final headerGrowthPlacement = + _widget.headerPlacement._byGrowth(constraints.growthDirection); + final paintOrigin = switch (headerGrowthPlacement) { + _HeaderGrowthPlacement.growthStart => 0.0, + _HeaderGrowthPlacement.growthEnd => layoutExtent - paintExtent, + }; + // TODO the child sliver should be painted at offset -paintOrigin + // (This bug doesn't matter so long as the header is opaque, + // because the header covers the child in that case. + // For that reason the Zulip message list isn't affected.) + + geometry = SliverGeometry( // TODO review interaction with other slivers + scrollExtent: geometry.scrollExtent, + layoutExtent: layoutExtent, + paintExtent: paintExtent, + paintOrigin: paintOrigin, + maxPaintExtent: math.max(geometry.maxPaintExtent, paintExtent), + hasVisualOverflow: geometry.hasVisualOverflow + || paintExtent > 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, + ); + } } 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); } @@ -631,16 +742,21 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper double childMainAxisPosition(RenderObject child) { if (child == this.child) return 0.0; assert(child == header); + + final headerParentData = (header!.parentData as SliverPhysicalParentData); + final paintOffset = headerParentData.paintOffset; + // We use Sliver*Physical*ParentData, so the header's position is stored in // physical coordinates. To meet the spec of `childMainAxisPosition`, we // need to convert to the sliver's coordinate system. - final headerParentData = (header!.parentData as SliverPhysicalParentData); - final paintOffset = headerParentData.paintOffset; + // This is all a bit silly because callers like [hitTestBoxChild] are just + // going to do the same things in reverse to get physical coordinates. + // Ah well; that's the API. return switch (constraints.growthAxisDirection) { AxisDirection.right => paintOffset.dx, - AxisDirection.left => geometry!.layoutExtent - header!.size.width - paintOffset.dx, + AxisDirection.left => geometry!.paintExtent - header!.size.width - paintOffset.dx, AxisDirection.down => paintOffset.dy, - AxisDirection.up => geometry!.layoutExtent - header!.size.height - paintOffset.dy, + AxisDirection.up => geometry!.paintExtent - header!.size.height - paintOffset.dy, }; } @@ -706,7 +822,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 +855,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/store.dart b/lib/widgets/store.dart index 13c27b9165..7278305b29 100644 --- a/lib/widgets/store.dart +++ b/lib/widgets/store.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import '../model/binding.dart'; +import '../model/database.dart'; +import '../model/settings.dart'; import '../model/store.dart'; import 'page.dart'; @@ -16,10 +18,18 @@ import 'page.dart'; class GlobalStoreWidget extends StatefulWidget { const GlobalStoreWidget({ super.key, - this.placeholder = const LoadingPlaceholder(), + this.blockingFuture, + this.placeholder = const BlankLoadingPlaceholder(), required this.child, }); + /// An additional future to await before showing the child. + /// + /// If [blockingFuture] is non-null, then this widget will build [child] + /// only after the future completes. This widget's behavior is not affected + /// by whether the future's completion is with a value or with an error. + final Future? blockingFuture; + final Widget placeholder; final Widget child; @@ -51,6 +61,28 @@ class GlobalStoreWidget extends StatefulWidget { return widget!.store; } + /// The user's [GlobalSettings] data within the app's global data store. + /// + /// The given build context will be registered as a dependency and + /// subscribed to changes in the returned [GlobalSettingsStore]. + /// This means that when the setting values in the store change, + /// the element at that build context will be rebuilt. + /// + /// This method is typically called near the top of a build method or a + /// [State.didChangeDependencies] method, like so: + /// ``` + /// @override + /// Widget build(BuildContext context) { + /// final globalSettings = GlobalStoreWidget.settingsOf(context); + /// ``` + /// + /// See [of] for further discussion of how to use this kind of method. + static GlobalSettingsStore settingsOf(BuildContext context) { + final widget = context.dependOnInheritedWidgetOfExactType<_GlobalSettingsStoreInheritedWidget>(); + assert(widget != null, 'No GlobalStoreWidget ancestor'); + return widget!.store; + } + @override State createState() => _GlobalStoreWidgetState(); } @@ -63,6 +95,9 @@ class _GlobalStoreWidgetState extends State { super.initState(); (() async { final store = await ZulipBinding.instance.getGlobalStoreUniquely(); + if (widget.blockingFuture != null) { + await widget.blockingFuture!.catchError((_) {}); + } setState(() { this.store = store; }); @@ -81,16 +116,26 @@ class _GlobalStoreWidgetState extends State { // a [StatefulWidget] to get hold of the store, and an [InheritedWidget] to // provide it to descendants, and one widget can't be both of those. class _GlobalStoreInheritedWidget extends InheritedNotifier { - const _GlobalStoreInheritedWidget({ + _GlobalStoreInheritedWidget({ required GlobalStore store, - required super.child, - }) : super(notifier: store); + required Widget child, + }) : super(notifier: store, + child: _GlobalSettingsStoreInheritedWidget( + store: store.settings, child: child)); GlobalStore get store => notifier!; +} - @override - bool updateShouldNotify(covariant _GlobalStoreInheritedWidget oldWidget) => - store != oldWidget.store; +// This is like [_GlobalStoreInheritedWidget] except it subscribes to the +// [GlobalSettingsStore] instead of the overall [GlobalStore]. +// That enables [settingsOf] to do the same. +class _GlobalSettingsStoreInheritedWidget extends InheritedNotifier { + const _GlobalSettingsStoreInheritedWidget({ + required GlobalSettingsStore store, + required super.child, + }) : super(notifier: store); + + GlobalSettingsStore get store => notifier!; } /// Provides access to the user's data for a particular Zulip account. @@ -296,6 +341,15 @@ class _PerAccountStoreInheritedWidget extends InheritedNotifier store != oldWidget.store; } +class BlankLoadingPlaceholder extends StatelessWidget { + const BlankLoadingPlaceholder({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} + class LoadingPlaceholder extends StatelessWidget { const LoadingPlaceholder({super.key}); diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index a51e722b51..d2b14b5426 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -4,16 +4,40 @@ import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; import '../model/unreads.dart'; +import 'action_sheet.dart'; import 'icons.dart'; import 'message_list.dart'; +import 'page.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; import 'unread_count_badge.dart'; +typedef OnChannelSelectCallback = void Function(ChannelNarrow narrow); + /// Scrollable listing of subscribed streams. class SubscriptionListPageBody extends StatefulWidget { - const SubscriptionListPageBody({super.key}); + const SubscriptionListPageBody({ + super.key, + this.showTopicListButtonInActionSheet = true, + this.hideChannelsIfUserCantPost = false, + this.onChannelSelect, + }); + + // TODO refactor this widget to avoid reuse of the whole page, + // avoiding the need for these flags, callback(s), and the below + // handling of safe-area at this level of abstraction. + // See discussion: + // https://github.com/zulip/zulip-flutter/pull/1774#discussion_r2249032503 + final bool showTopicListButtonInActionSheet; + final bool hideChannelsIfUserCantPost; + + /// Callback to invoke when the user selects a channel from the list. + /// + /// If null, the default behavior is to navigate to the channel feed. + final OnChannelSelectCallback? onChannelSelect; + + // TODO(#412) add onTopicSelect @override State createState() => _SubscriptionListPageBodyState(); @@ -42,6 +66,9 @@ class _SubscriptionListPageBodyState extends State wit }); } + // TODO(linter): The linter incorrectly flags the following regexp string + // as invalid. See: https://github.com/dart-lang/sdk/issues/61246 + // ignore: valid_regexps static final _startsWithEmojiRegex = RegExp(r'^\p{Emoji}', unicode: true); void _sortSubs(List list) { @@ -63,6 +90,16 @@ class _SubscriptionListPageBodyState extends State wit }); } + void _handleChannelSelect(ChannelNarrow narrow) { + if (widget.onChannelSelect case final onChannelSelect?) { + onChannelSelect(narrow); + } else { + Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: narrow)); + } + } + @override Widget build(BuildContext context) { // Design referenced from: @@ -83,7 +120,14 @@ class _SubscriptionListPageBodyState extends State wit final List pinned = []; final List unpinned = []; + final now = DateTime.now(); for (final subscription in store.subscriptions.values) { + if (widget.hideChannelsIfUserCantPost) { + if (!store.hasPostingPermission(inChannel: subscription, + user: store.selfUser, byDate: now)) { + continue; + } + } if (subscription.pinToTop) { pinned.add(subscription); } else { @@ -93,51 +137,53 @@ class _SubscriptionListPageBodyState extends State wit _sortSubs(pinned); _sortSubs(unpinned); + if (pinned.isEmpty && unpinned.isEmpty) { + return PageBodyEmptyContentPlaceholder( + // TODO(#188) add e.g. "Go to 'All channels' and join some of them." + message: zulipLocalizations.channelsEmptyPlaceholder); + } + return SafeArea( // Don't pad the bottom here; we want the list content to do that. + // + // When this page is used in the context of the home page, this + // param and the below use of `SliverSafeArea` would be noop, because + // `Scaffold.bottomNavigationBar` in the home page handles that for us. + // But this page is also used for share-to-zulip page, so we need this + // to be handled here. + // + // Other *PageBody widgets don't handle this because they aren't + // (re-)used outside the context of the home page. bottom: false, child: CustomScrollView( slivers: [ - if (pinned.isEmpty && unpinned.isEmpty) - const _NoSubscriptionsItem(), if (pinned.isNotEmpty) ...[ _SubscriptionListHeader(label: zulipLocalizations.pinnedSubscriptionsLabel), - _SubscriptionList(unreadsModel: unreadsModel, subscriptions: pinned), + _SubscriptionList( + unreadsModel: unreadsModel, + subscriptions: pinned, + showTopicListButtonInActionSheet: widget.showTopicListButtonInActionSheet, + onChannelSelect: _handleChannelSelect), ], if (unpinned.isNotEmpty) ...[ _SubscriptionListHeader(label: zulipLocalizations.unpinnedSubscriptionsLabel), - _SubscriptionList(unreadsModel: unreadsModel, subscriptions: unpinned), + _SubscriptionList( + unreadsModel: unreadsModel, + subscriptions: unpinned, + showTopicListButtonInActionSheet: widget.showTopicListButtonInActionSheet, + onChannelSelect: _handleChannelSelect), ], // TODO(#188): add button leading to "All Streams" page with ability to subscribe // This ensures last item in scrollable can settle in an unobstructed area. + // (Noop in the home-page case; see comment on `bottom: false` arg in + // use of `SafeArea` above.) const SliverSafeArea(sliver: SliverToBoxAdapter(child: SizedBox.shrink())), ])); } } -class _NoSubscriptionsItem extends StatelessWidget { - const _NoSubscriptionsItem(); - - @override - Widget build(BuildContext context) { - final designVariables = DesignVariables.of(context); - final zulipLocalizations = ZulipLocalizations.of(context); - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(10), - child: Text(zulipLocalizations.subscriptionListNoChannels, - textAlign: TextAlign.center, - style: TextStyle( - color: designVariables.subscriptionListHeaderText, - fontSize: 18, - height: (20 / 18), - )))); - } -} - class _SubscriptionListHeader extends StatelessWidget { const _SubscriptionListHeader({required this.label}); @@ -180,10 +226,14 @@ class _SubscriptionList extends StatelessWidget { const _SubscriptionList({ required this.unreadsModel, required this.subscriptions, + required this.showTopicListButtonInActionSheet, + required this.onChannelSelect, }); final Unreads? unreadsModel; final List subscriptions; + final bool showTopicListButtonInActionSheet; + final OnChannelSelectCallback onChannelSelect; @override Widget build(BuildContext context) { @@ -196,7 +246,9 @@ class _SubscriptionList extends StatelessWidget { && unreadsModel!.countInChannelNarrow(subscription.streamId) > 0; return SubscriptionItem(subscription: subscription, unreadCount: unreadCount, - showMutedUnreadBadge: showMutedUnreadBadge); + showMutedUnreadBadge: showMutedUnreadBadge, + showTopicListButtonInActionSheet: showTopicListButtonInActionSheet, + onChannelSelect: onChannelSelect); }); } } @@ -208,11 +260,15 @@ class SubscriptionItem extends StatelessWidget { required this.subscription, required this.unreadCount, required this.showMutedUnreadBadge, + required this.showTopicListButtonInActionSheet, + required this.onChannelSelect, }); final Subscription subscription; final int unreadCount; final bool showMutedUnreadBadge; + final bool showTopicListButtonInActionSheet; + final OnChannelSelectCallback onChannelSelect; @override Widget build(BuildContext context) { @@ -225,11 +281,10 @@ class SubscriptionItem extends StatelessWidget { // TODO(design) check if this is the right variable color: designVariables.background, child: InkWell( - onTap: () { - Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: ChannelNarrow(subscription.streamId))); - }, + onTap: () => onChannelSelect(ChannelNarrow(subscription.streamId)), + onLongPress: () => showChannelActionSheet(context, + channelId: subscription.streamId, + showTopicListButton: showTopicListButtonInActionSheet), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ const SizedBox(width: 16), Padding( diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart index 03fa0f32bd..f2f0edd968 100644 --- a/lib/widgets/text.dart +++ b/lib/widgets/text.dart @@ -1,8 +1,11 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'theme.dart'; + /// An app-wide [Typography] for Zulip, customized from the Material default. /// /// Include this in the app-wide [MaterialApp.theme]. @@ -285,8 +288,11 @@ double bolderWght(double baseWght, {double by = 300}) { return clampDouble(baseWght + by, kWghtMin, kWghtMax); } -/// A [TextStyle] whose [FontVariation] "wght" and [TextStyle.fontWeight] -/// have been raised using [bolderWght]. +/// A [TextStyle] with [FontVariation] "wght" and [TextStyle.fontWeight] +/// that have been raised from the input using [bolderWght]. +/// +/// Non-weight attributes in [style] are ignored +/// and will not appear in the result. /// /// [style] must have already been processed with [weightVariableTextStyle], /// and [by] must be positive. @@ -308,12 +314,9 @@ TextStyle bolderWghtTextStyle(TextStyle style, {double by = 300}) { final newWght = bolderWght(wghtFromTextStyle(style)!, by: by); - TextStyle result = style.copyWith( - fontVariations: style.fontVariations!.map((v) => v.axis == 'wght' - ? FontVariation('wght', newWght) - : v).toList(), - fontWeight: clampVariableFontWeight(newWght), - ); + TextStyle result = TextStyle( + fontVariations: [FontVariation('wght', newWght)], + fontWeight: clampVariableFontWeight(newWght)); assert(() { result = result.copyWith(debugLabel: 'bolderWghtTextStyle(by: $by)'); @@ -415,3 +418,108 @@ TextBaseline localizedTextBaseline(BuildContext context) { ScriptCategory.tall => TextBaseline.alphabetic, }; } + +/// A text widget with an embedded link. +/// +/// The text and link are given in [markup], in a simple HTML-like markup. +/// The markup string must not contain arbitrary user-controlled text. +/// +/// The portion of the text that is the link will be styled as a link, +/// and will respond to taps by calling the [onTap] callback. +/// +/// If the entire text is meant to be a link, there's no need for this widget; +/// instead, use [Text] inside a [GestureDetector], with [GestureDetector.onTap] +/// invoking [PlatformActions.launchUrl]. +/// +/// TODO(#1285): Integrate this with l10n so that the markup can be parsed +/// from the constant translated string, with placeholders for any variables, +/// rather than the string that results from interpolating variables. +/// That way it'll be fine to interpolate variables with arbitrary text. +/// TODO(#1285): Generalize this to other styling, like code font and italics. +/// TODO(#1553): Generalize this to multiple links in one string. +class TextWithLink extends StatefulWidget { + const TextWithLink({super.key, this.style, required this.onTap, required this.markup}); + + final TextStyle? style; + + /// A callback to be called when the user taps the link. + /// + /// Consider using [PlatformActions.launchUrl] to open a web page, + /// or [Navigator.push] to open a page of the app. + final VoidCallback onTap; + + /// The text to display, in a simple HTML-like markup. + /// + /// This string must contain the tags `` and `` as substrings, + /// in that order, and must contain no other `<` characters. + /// + /// In particular this means the string must not contain any arbitrary + /// user-controlled text, which might have '<' characters. + /// + /// The contents other than the two tags will be shown as text. + /// The portion between the tags will be the link. + // + // (Why the name ``? Well, it matches Zulip web's practice; + // and here's the reasoning for that name there: + // https://github.com/zulip/zulip/pull/18075#discussion_r611067127 + // ) + final String markup; + + @override + State createState() => _TextWithLinkState(); +} + +class _TextWithLinkState extends State { + late final GestureRecognizer _recognizer; + + @override + void initState() { + super.initState(); + _recognizer = TapGestureRecognizer() + ..onTap = widget.onTap; + } + + @override + void dispose() { + _recognizer.dispose(); + super.dispose(); + } + + static final _markupPattern = RegExp(r'^([^<]*)([^<]*)([^<]*)$'); + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + final match = _markupPattern.firstMatch(widget.markup); + final InlineSpan span; + if (match == null) { + // TODO(log): The markup text was invalid. + // Probably a translation (used by this widget's caller) didn't carry the + // syntax through correctly. + // This can also happen if the markup string contains user-controlled + // text (which is a bug) and that introduced a '<' character. + // Fall back to showing plain text. + // (It's important not to try to interpret any markup here, in case it + // comes buggily from user-controlled text.) + span = TextSpan(text: widget.markup); + } else { + span = TextSpan(children: [ + TextSpan(text: match.group(1)), + TextSpan(text: match.group(2), recognizer: _recognizer, + style: TextStyle( + decoration: TextDecoration.underline, + // TODO(design): work out what decorationThickness to use; + // the Figma design calls for 4% of the font size, but Flutter + // expects it as a ratio of the font's default stroke thickness. + // decorationThickness: 1, // (the default) + // decorationOffset: // TODO(upstream): https://github.com/flutter/flutter/issues/30541 + color: designVariables.link, + decorationColor: designVariables.link)), + TextSpan(text: match.group(3)), + ]); + } + + return Text.rich(span, style: widget.style); + } +} diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index ec8ad8aecc..8680640f6b 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -1,17 +1,24 @@ import 'package:flutter/material.dart'; import '../api/model/model.dart'; +import '../model/settings.dart'; import 'compose_box.dart'; import 'content.dart'; import 'emoji_reaction.dart'; import 'message_list.dart'; import 'channel_colors.dart'; +import 'store.dart'; import 'text.dart'; ThemeData zulipThemeData(BuildContext context) { final DesignVariables designVariables; final List themeExtensions; - Brightness brightness = MediaQuery.platformBrightnessOf(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + Brightness brightness = switch (globalSettings.themeSetting) { + null => MediaQuery.platformBrightnessOf(context), + ThemeSetting.light => Brightness.light, + ThemeSetting.dark => Brightness.dark, + }; // This applies Material 3's color system to produce a palette of // appropriately matching and contrasting colors for use in a UI. @@ -52,6 +59,13 @@ ThemeData zulipThemeData(BuildContext context) { brightness: brightness, typography: zulipTypography(context), extensions: themeExtensions, + + // Use "standard" visual density (the default for mobile platforms) + // on all platforms. That helps the desktop builds of the app be faithful + // previews of how the app behaves on mobile -- which is the only purpose + // we use the desktop builds for. + visualDensity: VisualDensity.standard, + iconButtonTheme: IconButtonThemeData(style: IconButton.styleFrom( foregroundColor: designVariables.icon, )), @@ -123,39 +137,80 @@ class DesignVariables extends ThemeExtension { static final light = DesignVariables._( background: const Color(0xffffffff), bannerBgIntDanger: const Color(0xfff2e4e4), + bannerBgIntInfo: const Color(0xffddecf6), + bannerTextIntInfo: const Color(0xff06037c), bgBotBar: const Color(0xfff6f6f6), bgContextMenu: const Color(0xfff2f2f2), bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.15), bgMenuButtonActive: Colors.black.withValues(alpha: 0.05), bgMenuButtonSelected: Colors.white, + bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(), bgTopBar: const Color(0xfff5f5f5), borderBar: Colors.black.withValues(alpha: 0.2), borderMenuButtonSelected: Colors.black.withValues(alpha: 0.2), + btnBgAttHighIntInfoActive: const Color(0xff1e41d3), + btnBgAttHighIntInfoNormal: const Color(0xff3c6bff), + btnBgAttMediumIntInfoActive: const Color(0xff3c6bff).withValues(alpha: 0.22), + btnBgAttMediumIntInfoNormal: const Color(0xff3c6bff).withValues(alpha: 0.12), + btnLabelAttHigh: const Color(0xffffffff), btnLabelAttLowIntDanger: const Color(0xffc0070a), + btnLabelAttLowIntInfo: const Color(0xff2347c6), btnLabelAttMediumIntDanger: const Color(0xffac0508), + btnLabelAttMediumIntInfo: const Color(0xff1027a6), + btnShadowAttMed: const Color(0xff000000).withValues(alpha: 0.20), composeBoxBg: const Color(0xffffffff), contextMenuCancelText: const Color(0xff222222), contextMenuItemBg: const Color(0xff6159e1), + contextMenuItemIcon: const Color(0xff4f42c9), + contextMenuItemLabel: const Color(0xff242631), + contextMenuItemMeta: const Color(0xff626573), contextMenuItemText: const Color(0xff381da7), editorButtonPressedBg: Colors.black.withValues(alpha: 0.06), + fabBg: const Color(0xff6e69f3), + fabBgPressed: const Color(0xff6159e1), + fabLabel: const Color(0xfff1f3fe), + fabLabelPressed: const Color(0xffeceefc), + fabShadow: const Color(0xff2b0e8a).withValues(alpha: 0.4), foreground: const Color(0xff000000), icon: const Color(0xff6159e1), iconSelected: const Color(0xff222222), labelCounterUnread: const Color(0xff222222), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(), labelMenuButton: const Color(0xff222222), + labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5), + labelTime: const Color(0x00000000).withValues(alpha: 0.49), + link: const Color(0xff066bd0), // from "Zulip Web UI kit" + listMenuItemBg: const Color(0xffcbcdd6), + listMenuItemIcon: const Color(0xff9194a3), + listMenuItemText: const Color(0xff2d303c), + + // Keep the color here and the corresponding non-dark mode entry in + // ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json + // in sync. mainBackground: const Color(0xfff0f0f0), + + neutralButtonBg: const Color(0xff8c84ae), + neutralButtonLabel: const Color(0xff433d5c), + radioBorder: Color(0xffbbbdc8), + radioFillSelected: Color(0xff4370f0), + statusAway: Color(0xff73788c).withValues(alpha: 0.25), + + // Following Web because it uses a gradient, to distinguish it by shape from + // the "active" dot, and the Figma doesn't; Figma just has solid #d5bb6c. + statusIdle: Color(0xfff5b266), + + statusOnline: Color(0xff46aa62), textInput: const Color(0xff000000), title: const Color(0xff1a1a1a), bgSearchInput: const Color(0xffe3e3e3), textMessage: const Color(0xff262626), + textMessageMuted: const Color(0xff262626).withValues(alpha: 0.6), channelColorSwatches: ChannelColorSwatches.light, - colorMessageHeaderIconInteractive: Colors.black.withValues(alpha: 0.2), + avatarPlaceholderBg: const Color(0x33808080), + avatarPlaceholderIcon: Colors.black.withValues(alpha: 0.5), contextMenuCancelBg: const Color(0xff797986).withValues(alpha: 0.15), contextMenuCancelPressedBg: const Color(0xff797986).withValues(alpha: 0.20), dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), - groupDmConversationIcon: Colors.black.withValues(alpha: 0.5), - groupDmConversationIconBg: const Color(0x33808080), inboxItemIconMarker: const HSLColor.fromAHSL(0.5, 0, 0, 0.2).toColor(), loginOrDivider: const Color(0xffdedede), loginOrDividerText: const Color(0xff575757), @@ -167,47 +222,89 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor(), subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(), unreadCountBadgeTextForChannel: Colors.black.withValues(alpha: 0.9), + userStatusText: const Color(0xff808080), ); static final dark = DesignVariables._( background: const Color(0xff000000), bannerBgIntDanger: const Color(0xff461616), + bannerBgIntInfo: const Color(0xff00253d), + bannerTextIntInfo: const Color(0xffcbdbfd), bgBotBar: const Color(0xff222222), bgContextMenu: const Color(0xff262626), bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.37), bgMenuButtonActive: Colors.black.withValues(alpha: 0.2), bgMenuButtonSelected: Colors.black.withValues(alpha: 0.25), + bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 0.11).toColor(), bgTopBar: const Color(0xff242424), borderBar: const Color(0xffffffff).withValues(alpha: 0.1), borderMenuButtonSelected: Colors.white.withValues(alpha: 0.1), + btnBgAttHighIntInfoActive: const Color(0xff1e41d3), + btnBgAttHighIntInfoNormal: const Color(0xff1e41d3), + btnBgAttMediumIntInfoActive: const Color(0xff97b6fe).withValues(alpha: 0.12), + btnBgAttMediumIntInfoNormal: const Color(0xff97b6fe).withValues(alpha: 0.12), + btnLabelAttHigh: const Color(0xffffffff).withValues(alpha: 0.85), btnLabelAttLowIntDanger: const Color(0xffff8b7c), + btnLabelAttLowIntInfo: const Color(0xff84a8fd), btnLabelAttMediumIntDanger: const Color(0xffff8b7c), + btnLabelAttMediumIntInfo: const Color(0xff97b6fe), + btnShadowAttMed: const Color(0xffffffff).withValues(alpha: 0.21), composeBoxBg: const Color(0xff0f0f0f), contextMenuCancelText: const Color(0xffffffff).withValues(alpha: 0.75), contextMenuItemBg: const Color(0xff7977fe), + contextMenuItemIcon: const Color(0xff9398fd), + contextMenuItemLabel: const Color(0xffdfe1e8), + contextMenuItemMeta: const Color(0xff9194a3), contextMenuItemText: const Color(0xff9398fd), editorButtonPressedBg: Colors.white.withValues(alpha: 0.06), + fabBg: const Color(0xff4f42c9), + fabBgPressed: const Color(0xff4331b8), + fabLabel: const Color(0xffeceefc), + fabLabelPressed: const Color(0xffeceefc), + fabShadow: const Color(0xff18171c), foreground: const Color(0xffffffff), icon: const Color(0xff7977fe), iconSelected: Colors.white.withValues(alpha: 0.8), labelCounterUnread: const Color(0xffffffff).withValues(alpha: 0.7), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(), labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), + labelSearchPrompt: const Color(0xffffffff).withValues(alpha: 0.5), + labelTime: const Color(0xffffffff).withValues(alpha: 0.50), + link: const Color(0xff00aaff), // from "Zulip Web UI kit" + listMenuItemBg: const Color(0xff2d303c), + listMenuItemIcon: const Color(0xff767988), + listMenuItemText: const Color(0xffcbcdd6), + + // Keep the color here and the corresponding dark mode entry in + // ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json + // in sync. mainBackground: const Color(0xff1d1d1d), + + neutralButtonBg: const Color(0xffd4d1e0), + neutralButtonLabel: const Color(0xffa9a3c2), + radioBorder: Color(0xff626573), + radioFillSelected: Color(0xff4e7cfa), + statusAway: Color(0xffabaeba).withValues(alpha: 0.30), + + // Following Web because it uses a gradient, to distinguish it by shape from + // the "active" dot, and the Figma doesn't; Figma just has solid #8c853b. + statusIdle: Color(0xffae640a), + + statusOnline: Color(0xff44bb66), textInput: const Color(0xffffffff).withValues(alpha: 0.9), - title: const Color(0xffffffff), + title: const Color(0xffffffff).withValues(alpha: 0.9), bgSearchInput: const Color(0xff313131), textMessage: const Color(0xffffffff).withValues(alpha: 0.8), + textMessageMuted: const Color(0xffffffff).withValues(alpha: 0.5), channelColorSwatches: ChannelColorSwatches.dark, + // TODO(design-dark) need proper dark-theme color (this is ad hoc) + avatarPlaceholderBg: const Color(0x33cccccc), + // TODO(design-dark) need proper dark-theme color (this is ad hoc) + avatarPlaceholderIcon: Colors.white.withValues(alpha: 0.5), contextMenuCancelBg: const Color(0xff797986).withValues(alpha: 0.15), // the same as the light mode in Figma contextMenuCancelPressedBg: const Color(0xff797986).withValues(alpha: 0.20), // the same as the light mode in Figma // TODO(design-dark) need proper dark-theme color (this is ad hoc) - colorMessageHeaderIconInteractive: Colors.white.withValues(alpha: 0.2), dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), - // TODO(design-dark) need proper dark-theme color (this is ad hoc) - groupDmConversationIcon: Colors.white.withValues(alpha: 0.5), - // TODO(design-dark) need proper dark-theme color (this is ad hoc) - groupDmConversationIconBg: const Color(0x33cccccc), inboxItemIconMarker: const HSLColor.fromAHSL(0.4, 0, 0, 1).toColor(), loginOrDivider: const Color(0xff424242), loginOrDividerText: const Color(0xffa8a8a8), @@ -224,44 +321,78 @@ class DesignVariables extends ThemeExtension { // TODO(design-dark) need proper dark-theme color (this is ad hoc) subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.75).toColor(), unreadCountBadgeTextForChannel: Colors.white.withValues(alpha: 0.9), + // TODO(design-dark) unchanged in dark theme? + userStatusText: const Color(0xff808080), ); DesignVariables._({ required this.background, required this.bannerBgIntDanger, + required this.bannerBgIntInfo, + required this.bannerTextIntInfo, required this.bgBotBar, required this.bgContextMenu, required this.bgCounterUnread, required this.bgMenuButtonActive, required this.bgMenuButtonSelected, + required this.bgMessageRegular, required this.bgTopBar, required this.borderBar, required this.borderMenuButtonSelected, + required this.btnBgAttHighIntInfoActive, + required this.btnBgAttHighIntInfoNormal, + required this.btnBgAttMediumIntInfoActive, + required this.btnBgAttMediumIntInfoNormal, + required this.btnLabelAttHigh, required this.btnLabelAttLowIntDanger, + required this.btnLabelAttLowIntInfo, required this.btnLabelAttMediumIntDanger, + required this.btnLabelAttMediumIntInfo, + required this.btnShadowAttMed, required this.composeBoxBg, required this.contextMenuCancelText, required this.contextMenuItemBg, + required this.contextMenuItemIcon, + required this.contextMenuItemLabel, + required this.contextMenuItemMeta, required this.contextMenuItemText, required this.editorButtonPressedBg, required this.foreground, + required this.fabBg, + required this.fabBgPressed, + required this.fabLabel, + required this.fabLabelPressed, + required this.fabShadow, required this.icon, required this.iconSelected, required this.labelCounterUnread, required this.labelEdited, required this.labelMenuButton, + required this.labelSearchPrompt, + required this.labelTime, + required this.link, + required this.listMenuItemBg, + required this.listMenuItemIcon, + required this.listMenuItemText, required this.mainBackground, + required this.neutralButtonBg, + required this.neutralButtonLabel, + required this.radioBorder, + required this.radioFillSelected, + required this.statusAway, + required this.statusIdle, + required this.statusOnline, required this.textInput, required this.title, required this.bgSearchInput, required this.textMessage, + required this.textMessageMuted, required this.channelColorSwatches, - required this.colorMessageHeaderIconInteractive, + required this.avatarPlaceholderBg, + required this.avatarPlaceholderIcon, required this.contextMenuCancelBg, required this.contextMenuCancelPressedBg, required this.dmHeaderBg, - required this.groupDmConversationIcon, - required this.groupDmConversationIconBg, required this.inboxItemIconMarker, required this.loginOrDivider, required this.loginOrDividerText, @@ -273,6 +404,7 @@ class DesignVariables extends ThemeExtension { required this.subscriptionListHeaderLine, required this.subscriptionListHeaderText, required this.unreadCountBadgeTextForChannel, + required this.userStatusText, }); /// The [DesignVariables] from the context's active theme. @@ -287,43 +419,75 @@ class DesignVariables extends ThemeExtension { final Color background; final Color bannerBgIntDanger; + final Color bannerBgIntInfo; + final Color bannerTextIntInfo; final Color bgBotBar; final Color bgContextMenu; final Color bgCounterUnread; final Color bgMenuButtonActive; final Color bgMenuButtonSelected; + final Color bgMessageRegular; final Color bgTopBar; final Color borderBar; final Color borderMenuButtonSelected; + final Color btnBgAttHighIntInfoActive; + final Color btnBgAttHighIntInfoNormal; + final Color btnBgAttMediumIntInfoActive; + final Color btnBgAttMediumIntInfoNormal; + final Color btnLabelAttHigh; final Color btnLabelAttLowIntDanger; + final Color btnLabelAttLowIntInfo; final Color btnLabelAttMediumIntDanger; + final Color btnLabelAttMediumIntInfo; + final Color btnShadowAttMed; final Color composeBoxBg; final Color contextMenuCancelText; final Color contextMenuItemBg; + final Color contextMenuItemIcon; + final Color contextMenuItemLabel; + final Color contextMenuItemMeta; final Color contextMenuItemText; final Color editorButtonPressedBg; + final Color fabBg; + final Color fabBgPressed; + final Color fabLabel; + final Color fabLabelPressed; + final Color fabShadow; final Color foreground; final Color icon; final Color iconSelected; final Color labelCounterUnread; final Color labelEdited; final Color labelMenuButton; + final Color labelSearchPrompt; + final Color labelTime; + final Color link; + final Color listMenuItemBg; + final Color listMenuItemIcon; + final Color listMenuItemText; final Color mainBackground; + final Color neutralButtonBg; + final Color neutralButtonLabel; + final Color radioBorder; + final Color radioFillSelected; + final Color statusAway; + final Color statusIdle; + final Color statusOnline; final Color textInput; final Color title; final Color bgSearchInput; final Color textMessage; + final Color textMessageMuted; // Not exactly from the Figma design, but from Vlad anyway. final ChannelColorSwatches channelColorSwatches; // Not named variables in Figma; taken from older Figma drafts, or elsewhere. - final Color colorMessageHeaderIconInteractive; + final Color avatarPlaceholderBg; + final Color avatarPlaceholderIcon; final Color contextMenuCancelBg; // In Figma, but unnamed. final Color contextMenuCancelPressedBg; // In Figma, but unnamed. final Color dmHeaderBg; - final Color groupDmConversationIcon; - final Color groupDmConversationIconBg; final Color inboxItemIconMarker; final Color loginOrDivider; // TODO(design-dark) need proper dark-theme color (this is ad hoc) final Color loginOrDividerText; // TODO(design-dark) need proper dark-theme color (this is ad hoc) @@ -335,44 +499,77 @@ class DesignVariables extends ThemeExtension { final Color subscriptionListHeaderLine; final Color subscriptionListHeaderText; final Color unreadCountBadgeTextForChannel; + final Color userStatusText; // In Figma, but unnamed. @override DesignVariables copyWith({ Color? background, Color? bannerBgIntDanger, + Color? bannerBgIntInfo, + Color? bannerTextIntInfo, Color? bgBotBar, Color? bgContextMenu, Color? bgCounterUnread, Color? bgMenuButtonActive, Color? bgMenuButtonSelected, + Color? bgMessageRegular, Color? bgTopBar, Color? borderBar, Color? borderMenuButtonSelected, + Color? btnBgAttHighIntInfoActive, + Color? btnBgAttHighIntInfoNormal, + Color? btnBgAttMediumIntInfoActive, + Color? btnBgAttMediumIntInfoNormal, + Color? btnLabelAttHigh, Color? btnLabelAttLowIntDanger, + Color? btnLabelAttLowIntInfo, Color? btnLabelAttMediumIntDanger, + Color? btnLabelAttMediumIntInfo, + Color? btnShadowAttMed, Color? composeBoxBg, Color? contextMenuCancelText, Color? contextMenuItemBg, + Color? contextMenuItemIcon, + Color? contextMenuItemLabel, + Color? contextMenuItemMeta, Color? contextMenuItemText, Color? editorButtonPressedBg, + Color? fabBg, + Color? fabBgPressed, + Color? fabLabel, + Color? fabLabelPressed, + Color? fabShadow, Color? foreground, Color? icon, Color? iconSelected, Color? labelCounterUnread, Color? labelEdited, Color? labelMenuButton, + Color? labelSearchPrompt, + Color? labelTime, + Color? link, + Color? listMenuItemBg, + Color? listMenuItemIcon, + Color? listMenuItemText, Color? mainBackground, + Color? neutralButtonBg, + Color? neutralButtonLabel, + Color? radioBorder, + Color? radioFillSelected, + Color? statusAway, + Color? statusIdle, + Color? statusOnline, Color? textInput, Color? title, Color? bgSearchInput, Color? textMessage, + Color? textMessageMuted, ChannelColorSwatches? channelColorSwatches, - Color? colorMessageHeaderIconInteractive, + Color? avatarPlaceholderBg, + Color? avatarPlaceholderIcon, Color? contextMenuCancelBg, Color? contextMenuCancelPressedBg, Color? dmHeaderBg, - Color? groupDmConversationIcon, - Color? groupDmConversationIconBg, Color? inboxItemIconMarker, Color? loginOrDivider, Color? loginOrDividerText, @@ -384,43 +581,76 @@ class DesignVariables extends ThemeExtension { Color? subscriptionListHeaderLine, Color? subscriptionListHeaderText, Color? unreadCountBadgeTextForChannel, + Color? userStatusText, }) { return DesignVariables._( background: background ?? this.background, bannerBgIntDanger: bannerBgIntDanger ?? this.bannerBgIntDanger, + bannerBgIntInfo: bannerBgIntInfo ?? this.bannerBgIntInfo, + bannerTextIntInfo: bannerTextIntInfo ?? this.bannerTextIntInfo, bgBotBar: bgBotBar ?? this.bgBotBar, bgContextMenu: bgContextMenu ?? this.bgContextMenu, bgCounterUnread: bgCounterUnread ?? this.bgCounterUnread, bgMenuButtonActive: bgMenuButtonActive ?? this.bgMenuButtonActive, bgMenuButtonSelected: bgMenuButtonSelected ?? this.bgMenuButtonSelected, + bgMessageRegular: bgMessageRegular ?? this.bgMessageRegular, bgTopBar: bgTopBar ?? this.bgTopBar, borderBar: borderBar ?? this.borderBar, borderMenuButtonSelected: borderMenuButtonSelected ?? this.borderMenuButtonSelected, + btnBgAttHighIntInfoActive: btnBgAttHighIntInfoActive ?? this.btnBgAttHighIntInfoActive, + btnBgAttHighIntInfoNormal: btnBgAttHighIntInfoNormal ?? this.btnBgAttHighIntInfoNormal, + btnBgAttMediumIntInfoActive: btnBgAttMediumIntInfoActive ?? this.btnBgAttMediumIntInfoActive, + btnBgAttMediumIntInfoNormal: btnBgAttMediumIntInfoNormal ?? this.btnBgAttMediumIntInfoNormal, + btnLabelAttHigh: btnLabelAttHigh ?? this.btnLabelAttHigh, btnLabelAttLowIntDanger: btnLabelAttLowIntDanger ?? this.btnLabelAttLowIntDanger, + btnLabelAttLowIntInfo: btnLabelAttLowIntInfo ?? this.btnLabelAttLowIntInfo, btnLabelAttMediumIntDanger: btnLabelAttMediumIntDanger ?? this.btnLabelAttMediumIntDanger, + btnLabelAttMediumIntInfo: btnLabelAttMediumIntInfo ?? this.btnLabelAttMediumIntInfo, + btnShadowAttMed: btnShadowAttMed ?? this.btnShadowAttMed, composeBoxBg: composeBoxBg ?? this.composeBoxBg, contextMenuCancelText: contextMenuCancelText ?? this.contextMenuCancelText, contextMenuItemBg: contextMenuItemBg ?? this.contextMenuItemBg, - contextMenuItemText: contextMenuItemText ?? this.contextMenuItemBg, + contextMenuItemIcon: contextMenuItemIcon ?? this.contextMenuItemIcon, + contextMenuItemLabel: contextMenuItemLabel ?? this.contextMenuItemLabel, + contextMenuItemMeta: contextMenuItemMeta ?? this.contextMenuItemMeta, + contextMenuItemText: contextMenuItemText ?? this.contextMenuItemText, editorButtonPressedBg: editorButtonPressedBg ?? this.editorButtonPressedBg, foreground: foreground ?? this.foreground, + fabBg: fabBg ?? this.fabBg, + fabBgPressed: fabBgPressed ?? this.fabBgPressed, + fabLabel: fabLabel ?? this.fabLabel, + fabLabelPressed: fabLabelPressed ?? this.fabLabelPressed, + fabShadow: fabShadow ?? this.fabShadow, icon: icon ?? this.icon, iconSelected: iconSelected ?? this.iconSelected, labelCounterUnread: labelCounterUnread ?? this.labelCounterUnread, labelEdited: labelEdited ?? this.labelEdited, labelMenuButton: labelMenuButton ?? this.labelMenuButton, + labelSearchPrompt: labelSearchPrompt ?? this.labelSearchPrompt, + labelTime: labelTime ?? this.labelTime, + link: link ?? this.link, + listMenuItemBg: listMenuItemBg ?? this.listMenuItemBg, + listMenuItemIcon: listMenuItemIcon ?? this.listMenuItemIcon, + listMenuItemText: listMenuItemText ?? this.listMenuItemText, mainBackground: mainBackground ?? this.mainBackground, + neutralButtonBg: neutralButtonBg ?? this.neutralButtonBg, + neutralButtonLabel: neutralButtonLabel ?? this.neutralButtonLabel, + radioBorder: radioBorder ?? this.radioBorder, + radioFillSelected: radioFillSelected ?? this.radioFillSelected, + statusAway: statusAway ?? this.statusAway, + statusIdle: statusIdle ?? this.statusIdle, + statusOnline: statusOnline ?? this.statusOnline, textInput: textInput ?? this.textInput, title: title ?? this.title, bgSearchInput: bgSearchInput ?? this.bgSearchInput, textMessage: textMessage ?? this.textMessage, + textMessageMuted: textMessageMuted ?? this.textMessageMuted, channelColorSwatches: channelColorSwatches ?? this.channelColorSwatches, - colorMessageHeaderIconInteractive: colorMessageHeaderIconInteractive ?? this.colorMessageHeaderIconInteractive, + avatarPlaceholderBg: avatarPlaceholderBg ?? this.avatarPlaceholderBg, + avatarPlaceholderIcon: avatarPlaceholderIcon ?? this.avatarPlaceholderIcon, contextMenuCancelBg: contextMenuCancelBg ?? this.contextMenuCancelBg, contextMenuCancelPressedBg: contextMenuCancelPressedBg ?? this.contextMenuCancelPressedBg, dmHeaderBg: dmHeaderBg ?? this.dmHeaderBg, - groupDmConversationIcon: groupDmConversationIcon ?? this.groupDmConversationIcon, - groupDmConversationIconBg: groupDmConversationIconBg ?? this.groupDmConversationIconBg, inboxItemIconMarker: inboxItemIconMarker ?? this.inboxItemIconMarker, loginOrDivider: loginOrDivider ?? this.loginOrDivider, loginOrDividerText: loginOrDividerText ?? this.loginOrDividerText, @@ -432,6 +662,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: subscriptionListHeaderLine ?? this.subscriptionListHeaderLine, subscriptionListHeaderText: subscriptionListHeaderText ?? this.subscriptionListHeaderText, unreadCountBadgeTextForChannel: unreadCountBadgeTextForChannel ?? this.unreadCountBadgeTextForChannel, + userStatusText: userStatusText ?? this.userStatusText, ); } @@ -443,39 +674,71 @@ class DesignVariables extends ThemeExtension { return DesignVariables._( background: Color.lerp(background, other.background, t)!, bannerBgIntDanger: Color.lerp(bannerBgIntDanger, other.bannerBgIntDanger, t)!, + bannerBgIntInfo: Color.lerp(bannerBgIntInfo, other.bannerBgIntInfo, t)!, + bannerTextIntInfo: Color.lerp(bannerTextIntInfo, other.bannerTextIntInfo, t)!, bgBotBar: Color.lerp(bgBotBar, other.bgBotBar, t)!, bgContextMenu: Color.lerp(bgContextMenu, other.bgContextMenu, t)!, bgCounterUnread: Color.lerp(bgCounterUnread, other.bgCounterUnread, t)!, bgMenuButtonActive: Color.lerp(bgMenuButtonActive, other.bgMenuButtonActive, t)!, bgMenuButtonSelected: Color.lerp(bgMenuButtonSelected, other.bgMenuButtonSelected, t)!, + bgMessageRegular: Color.lerp(bgMessageRegular, other.bgMessageRegular, t)!, bgTopBar: Color.lerp(bgTopBar, other.bgTopBar, t)!, borderBar: Color.lerp(borderBar, other.borderBar, t)!, borderMenuButtonSelected: Color.lerp(borderMenuButtonSelected, other.borderMenuButtonSelected, t)!, + btnBgAttHighIntInfoActive: Color.lerp(btnBgAttHighIntInfoActive, other.btnBgAttHighIntInfoActive, t)!, + btnBgAttHighIntInfoNormal: Color.lerp(btnBgAttHighIntInfoNormal, other.btnBgAttHighIntInfoNormal, t)!, + btnBgAttMediumIntInfoActive: Color.lerp(btnBgAttMediumIntInfoActive, other.btnBgAttMediumIntInfoActive, t)!, + btnBgAttMediumIntInfoNormal: Color.lerp(btnBgAttMediumIntInfoNormal, other.btnBgAttMediumIntInfoNormal, t)!, + btnLabelAttHigh: Color.lerp(btnLabelAttHigh, other.btnLabelAttHigh, t)!, btnLabelAttLowIntDanger: Color.lerp(btnLabelAttLowIntDanger, other.btnLabelAttLowIntDanger, t)!, + btnLabelAttLowIntInfo: Color.lerp(btnLabelAttLowIntInfo, other.btnLabelAttLowIntInfo, t)!, btnLabelAttMediumIntDanger: Color.lerp(btnLabelAttMediumIntDanger, other.btnLabelAttMediumIntDanger, t)!, + btnLabelAttMediumIntInfo: Color.lerp(btnLabelAttMediumIntInfo, other.btnLabelAttMediumIntInfo, t)!, + btnShadowAttMed: Color.lerp(btnShadowAttMed, other.btnShadowAttMed, t)!, composeBoxBg: Color.lerp(composeBoxBg, other.composeBoxBg, t)!, contextMenuCancelText: Color.lerp(contextMenuCancelText, other.contextMenuCancelText, t)!, contextMenuItemBg: Color.lerp(contextMenuItemBg, other.contextMenuItemBg, t)!, - contextMenuItemText: Color.lerp(contextMenuItemText, other.contextMenuItemBg, t)!, + contextMenuItemIcon: Color.lerp(contextMenuItemIcon, other.contextMenuItemIcon, t)!, + contextMenuItemLabel: Color.lerp(contextMenuItemLabel, other.contextMenuItemLabel, t)!, + contextMenuItemMeta: Color.lerp(contextMenuItemMeta, other.contextMenuItemMeta, t)!, + contextMenuItemText: Color.lerp(contextMenuItemText, other.contextMenuItemText, t)!, editorButtonPressedBg: Color.lerp(editorButtonPressedBg, other.editorButtonPressedBg, t)!, foreground: Color.lerp(foreground, other.foreground, t)!, + fabBg: Color.lerp(fabBg, other.fabBg, t)!, + fabBgPressed: Color.lerp(fabBgPressed, other.fabBgPressed, t)!, + fabLabel: Color.lerp(fabLabel, other.fabLabel, t)!, + fabLabelPressed: Color.lerp(fabLabelPressed, other.fabLabelPressed, t)!, + fabShadow: Color.lerp(fabShadow, other.fabShadow, t)!, icon: Color.lerp(icon, other.icon, t)!, iconSelected: Color.lerp(iconSelected, other.iconSelected, t)!, labelCounterUnread: Color.lerp(labelCounterUnread, other.labelCounterUnread, t)!, labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!, labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, + labelSearchPrompt: Color.lerp(labelSearchPrompt, other.labelSearchPrompt, t)!, + labelTime: Color.lerp(labelTime, other.labelTime, t)!, + link: Color.lerp(link, other.link, t)!, + listMenuItemBg: Color.lerp(listMenuItemBg, other.listMenuItemBg, t)!, + listMenuItemIcon: Color.lerp(listMenuItemIcon, other.listMenuItemIcon, t)!, + listMenuItemText: Color.lerp(listMenuItemText, other.listMenuItemText, t)!, mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!, + neutralButtonBg: Color.lerp(neutralButtonBg, other.neutralButtonBg, t)!, + neutralButtonLabel: Color.lerp(neutralButtonLabel, other.neutralButtonLabel, t)!, + radioBorder: Color.lerp(radioBorder, other.radioBorder, t)!, + radioFillSelected: Color.lerp(radioFillSelected, other.radioFillSelected, t)!, + statusAway: Color.lerp(statusAway, other.statusAway, t)!, + statusIdle: Color.lerp(statusIdle, other.statusIdle, t)!, + statusOnline: Color.lerp(statusOnline, other.statusOnline, t)!, textInput: Color.lerp(textInput, other.textInput, t)!, title: Color.lerp(title, other.title, t)!, bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!, textMessage: Color.lerp(textMessage, other.textMessage, t)!, + textMessageMuted: Color.lerp(textMessageMuted, other.textMessageMuted, t)!, channelColorSwatches: ChannelColorSwatches.lerp(channelColorSwatches, other.channelColorSwatches, t), - colorMessageHeaderIconInteractive: Color.lerp(colorMessageHeaderIconInteractive, other.colorMessageHeaderIconInteractive, t)!, + avatarPlaceholderBg: Color.lerp(avatarPlaceholderBg, other.avatarPlaceholderBg, t)!, + avatarPlaceholderIcon: Color.lerp(avatarPlaceholderIcon, other.avatarPlaceholderIcon, t)!, contextMenuCancelBg: Color.lerp(contextMenuCancelBg, other.contextMenuCancelBg, t)!, contextMenuCancelPressedBg: Color.lerp(contextMenuCancelPressedBg, other.contextMenuCancelPressedBg, t)!, dmHeaderBg: Color.lerp(dmHeaderBg, other.dmHeaderBg, t)!, - groupDmConversationIcon: Color.lerp(groupDmConversationIcon, other.groupDmConversationIcon, t)!, - groupDmConversationIconBg: Color.lerp(groupDmConversationIconBg, other.groupDmConversationIconBg, t)!, inboxItemIconMarker: Color.lerp(inboxItemIconMarker, other.inboxItemIconMarker, t)!, loginOrDivider: Color.lerp(loginOrDivider, other.loginOrDivider, t)!, loginOrDividerText: Color.lerp(loginOrDividerText, other.loginOrDividerText, t)!, @@ -487,14 +750,24 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: Color.lerp(subscriptionListHeaderLine, other.subscriptionListHeaderLine, t)!, subscriptionListHeaderText: Color.lerp(subscriptionListHeaderText, other.subscriptionListHeaderText, t)!, unreadCountBadgeTextForChannel: Color.lerp(unreadCountBadgeTextForChannel, other.unreadCountBadgeTextForChannel, t)!, + userStatusText: Color.lerp(userStatusText, other.userStatusText, t)!, ); } } +// This is taken from: +// https://github.com/zulip/zulip/blob/b248e2d93/web/src/stream_data.ts#L40 +const kDefaultChannelColorSwatchBaseColor = 0xffc2c2c2; + /// The theme-appropriate [ChannelColorSwatch] based on [subscription.color]. /// +/// If [subscription] is null, [ChannelColorSwatch] will be based on +/// [kDefaultChannelColorSwatchBaseColor]. +/// /// For how this value is cached, see [ChannelColorSwatches.forBaseColor]. -ChannelColorSwatch colorSwatchFor(BuildContext context, Subscription subscription) { +// TODO(#188) pick different colors for unsubscribed channels +ChannelColorSwatch colorSwatchFor(BuildContext context, Subscription? subscription) { return DesignVariables.of(context) - .channelColorSwatches.forBaseColor(subscription.color); + .channelColorSwatches.forBaseColor( + subscription?.color ?? kDefaultChannelColorSwatchBaseColor); } diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart new file mode 100644 index 0000000000..16111fc678 --- /dev/null +++ b/lib/widgets/topic_list.dart @@ -0,0 +1,350 @@ +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../api/route/channels.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../model/narrow.dart'; +import '../model/unreads.dart'; +import 'action_sheet.dart'; +import 'app_bar.dart'; +import 'color.dart'; +import 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; + +class TopicListPage extends StatelessWidget { + const TopicListPage({super.key, required this.streamId}); + + final int streamId; + + static AccountRoute buildRoute({ + required BuildContext context, + required int streamId, + }) { + return MaterialAccountWidgetRoute( + context: context, + page: TopicListPage(streamId: streamId)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final appBarBackgroundColor = colorSwatchFor( + context, store.subscriptions[streamId]).barBackground; + + return PageRoot(child: Scaffold( + appBar: ZulipAppBar( + backgroundColor: appBarBackgroundColor, + buildTitle: (willCenterTitle) => + _TopicListAppBarTitle(streamId: streamId, willCenterTitle: willCenterTitle), + actions: [ + IconButton( + icon: const Icon(ZulipIcons.message_feed), + tooltip: zulipLocalizations.channelFeedButtonTooltip, + onPressed: () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: ChannelNarrow(streamId)))), + ]), + body: _TopicList(streamId: streamId))); + } +} + +// This is adapted from [MessageListAppBarTitle]. +class _TopicListAppBarTitle extends StatelessWidget { + const _TopicListAppBarTitle({ + required this.streamId, + required this.willCenterTitle, + }); + + final int streamId; + final bool willCenterTitle; + + Widget _buildStreamRow(BuildContext context) { + // TODO(#1039) implement a consistent app bar design here + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + final stream = store.streams[streamId]; + final channelIconColor = colorSwatchFor(context, + store.subscriptions[streamId]).iconOnBarBackground; + + // A null [Icon.icon] makes a blank space. + final icon = stream != null ? iconDataForStream(stream) : null; + return Row( + mainAxisSize: MainAxisSize.min, + // TODO(design): The vertical alignment of the stream privacy icon is a bit ad hoc. + // For screenshots of some experiments, see: + // https://github.com/zulip/zulip-flutter/pull/219#discussion_r1281024746 + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding(padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6), + child: Icon(size: 18, icon, color: channelIconColor)), + Flexible(child: Text( + stream?.name ?? zulipLocalizations.unknownChannelName, + style: TextStyle( + fontSize: 20, + height: 30 / 20, + color: designVariables.title, + ).merge(weightVariableTextStyle(context, wght: 600)))), + ]); + } + + @override + Widget build(BuildContext context) { + final alignment = willCenterTitle + ? Alignment.center + : AlignmentDirectional.centerStart; + return SizedBox( + width: double.infinity, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: () { + showChannelActionSheet(context, + channelId: streamId, + // We're already on the topic list. + showTopicListButton: false); + }, + child: Align(alignment: alignment, + child: _buildStreamRow(context)))); + } +} + +class _TopicList extends StatefulWidget { + const _TopicList({required this.streamId}); + + final int streamId; + + @override + State<_TopicList> createState() => _TopicListState(); +} + +class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMixin { + Unreads? unreadsModel; + // TODO(#1499): store the results on [ChannelStore], and keep them + // up-to-date by handling events + List? lastFetchedTopics; + + @override + void onNewStore() { + unreadsModel?.removeListener(_modelChanged); + final store = PerAccountStoreWidget.of(context); + unreadsModel = store.unreads..addListener(_modelChanged); + _fetchTopics(); + } + + @override + void dispose() { + unreadsModel?.removeListener(_modelChanged); + super.dispose(); + } + + void _modelChanged() { + setState(() { + // The actual state lives in `unreadsModel`. + }); + } + + void _fetchTopics() async { + // Do nothing when the fetch fails; the topic-list will stay on + // the loading screen, until the user navigates away and back. + // TODO(design) show a nice error message on screen when this fails + final store = PerAccountStoreWidget.of(context); + final result = await getStreamTopics(store.connection, + streamId: widget.streamId, + allowEmptyTopicName: true); + if (!mounted) return; + setState(() { + lastFetchedTopics = result.topics; + }); + } + + @override + Widget build(BuildContext context) { + if (lastFetchedTopics == null) { + return const Center(child: CircularProgressIndicator()); + } + + // TODO(design) handle the rare case when `lastFetchedTopics` is empty + + // This is adapted from parts of the build method on [_InboxPageState]. + final topicItems = <_TopicItemData>[]; + for (final GetStreamTopicsEntry(:maxId, name: topic) in lastFetchedTopics!) { + final unreadMessageIds = + unreadsModel!.streams[widget.streamId]?[topic] ?? []; + final countInTopic = unreadMessageIds.length; + final hasMention = unreadMessageIds.any((messageId) => + unreadsModel!.mentions.contains(messageId)); + topicItems.add(_TopicItemData( + topic: topic, + unreadCount: countInTopic, + hasMention: hasMention, + // `lastFetchedTopics.maxId` can become outdated when a new message + // arrives or when there are message moves, until we re-fetch. + // TODO(#1499): track changes to this + maxId: maxId, + )); + } + topicItems.sort((a, b) { + final aMaxId = a.maxId; + final bMaxId = b.maxId; + return bMaxId.compareTo(aMaxId); + }); + + return SafeArea( + // Don't pad the bottom here; we want the list content to do that. + bottom: false, + child: ListView.builder( + itemCount: topicItems.length, + itemBuilder: (context, index) => + _TopicItem(streamId: widget.streamId, data: topicItems[index])), + ); + } +} + +class _TopicItemData { + final TopicName topic; + final int unreadCount; + final bool hasMention; + final int maxId; + + const _TopicItemData({ + required this.topic, + required this.unreadCount, + required this.hasMention, + required this.maxId, + }); +} + +// This is adapted from `_TopicItem` in lib/widgets/inbox.dart. +// TODO(#1527) see if we can reuse this in redesign +class _TopicItem extends StatelessWidget { + const _TopicItem({required this.streamId, required this.data}); + + final int streamId; + final _TopicItemData data; + + @override + Widget build(BuildContext context) { + final _TopicItemData( + :topic, :unreadCount, :hasMention, :maxId) = data; + + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + final visibilityPolicy = store.topicVisibilityPolicy(streamId, topic); + final double opacity; + switch (visibilityPolicy) { + case UserTopicVisibilityPolicy.muted: + opacity = 0.5; + case UserTopicVisibilityPolicy.none: + case UserTopicVisibilityPolicy.unmuted: + case UserTopicVisibilityPolicy.followed: + opacity = 1; + case UserTopicVisibilityPolicy.unknown: + assert(false); + opacity = 1; + } + + final visibilityIcon = iconDataForTopicVisibilityPolicy(visibilityPolicy); + + return Material( + color: designVariables.bgMessageRegular, + child: InkWell( + onTap: () { + final narrow = TopicNarrow(streamId, topic); + Navigator.push(context, + MessageListPage.buildRoute(context: context, narrow: narrow)); + }, + onLongPress: () => showTopicActionSheet(context, + channelId: streamId, + topic: topic, + someMessageIdInTopic: maxId), + splashFactory: NoSplash.splashFactory, + child: Padding(padding: EdgeInsetsDirectional.fromSTEB(6, 8, 12, 8), + child: Row( + spacing: 8, + // In the Figma design, the text and icons on the topic item row + // are aligned to the start on the cross axis + // (i.e., `align-items: flex-start`). The icons are padded down + // 2px relative to the start, to visibly sit on the baseline. + // To account for scaled text, we align everything on the row + // to [CrossAxisAlignment.center] instead ([Row]'s default), + // like we do for the topic items on the inbox page. + // TODO(#1528): align to baseline (and therefore to first line of + // topic name), but with adjustment for icons + // CZO discussion: + // https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/topic.20list.20item.20alignment/near/2173252 + children: [ + // A null [Icon.icon] makes a blank space. + _IconMarker(icon: topic.isResolved ? ZulipIcons.check : null), + Expanded(child: Opacity( + opacity: opacity, + child: Text( + style: TextStyle( + fontSize: 17, + height: 20 / 17, + fontStyle: topic.displayName == null ? FontStyle.italic : null, + color: designVariables.textMessage, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + topic.unresolve().displayName ?? store.realmEmptyTopicDisplayName))), + Opacity(opacity: opacity, child: Row( + spacing: 4, + children: [ + if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), + if (visibilityIcon != null) _IconMarker(icon: visibilityIcon), + if (unreadCount > 0) _UnreadCountBadge(count: unreadCount), + ])), + ])))); + } +} + +class _IconMarker extends StatelessWidget { + const _IconMarker({required this.icon}); + + final IconData? icon; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final textScaler = MediaQuery.textScalerOf(context); + // Since we align the icons to [CrossAxisAlignment.center], the top padding + // from the Figma design is omitted. + return Icon(icon, + size: textScaler.clamp(maxScaleFactor: 1.5).scale(16), + color: designVariables.textMessage.withFadedAlpha(0.4)); + } +} + +// This is adapted from [UnreadCountBadge]. +// TODO(#1406) see if we can reuse this in redesign +// TODO(#1527) see if we can reuse this in redesign +class _UnreadCountBadge extends StatelessWidget { + const _UnreadCountBadge({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: designVariables.bgCounterUnread, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Text(count.toString(), + style: TextStyle( + fontSize: 15, + height: 16 / 15, + color: designVariables.labelCounterUnread, + ).merge(weightVariableTextStyle(context, wght: 500))))); + } +} diff --git a/lib/widgets/user.dart b/lib/widgets/user.dart new file mode 100644 index 0000000000..182a073d30 --- /dev/null +++ b/lib/widgets/user.dart @@ -0,0 +1,383 @@ +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../model/avatar_url.dart'; +import '../model/binding.dart'; +import '../model/emoji.dart'; +import '../model/presence.dart'; +import 'content.dart'; +import 'emoji.dart'; +import 'icons.dart'; +import 'store.dart'; +import 'theme.dart'; + +/// A rounded square with size [size] showing a user's avatar. +class Avatar extends StatelessWidget { + const Avatar({ + super.key, + required this.userId, + required this.size, + required this.borderRadius, + this.backgroundColor, + this.showPresence = true, + this.replaceIfMuted = true, + }); + + final int userId; + final double size; + final double borderRadius; + final Color? backgroundColor; + final bool showPresence; + final bool replaceIfMuted; + + @override + Widget build(BuildContext context) { + // (The backgroundColor is only meaningful if presence will be shown; + // see [PresenceCircle.backgroundColor].) + assert(backgroundColor == null || showPresence); + return AvatarShape( + size: size, + borderRadius: borderRadius, + backgroundColor: backgroundColor, + userIdForPresence: showPresence ? userId : null, + child: AvatarImage(userId: userId, size: size, replaceIfMuted: replaceIfMuted)); + } +} + +/// The appropriate avatar image for a user ID. +/// +/// If the user isn't found, gives a [SizedBox.shrink]. +/// +/// Wrap this with [AvatarShape]. +class AvatarImage extends StatelessWidget { + const AvatarImage({ + super.key, + required this.userId, + required this.size, + this.replaceIfMuted = true, + }); + + final int userId; + final double size; + final bool replaceIfMuted; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final user = store.getUser(userId); + + if (user == null) { // TODO(log) + return const SizedBox.shrink(); + } + + if (replaceIfMuted && store.isUserMuted(userId)) { + return _AvatarPlaceholder(size: size); + } + + final resolvedUrl = switch (user.avatarUrl) { + null => null, // TODO(#255): handle computing gravatars + var avatarUrl => store.tryResolveUrl(avatarUrl), + }; + + if (resolvedUrl == null) { + return const SizedBox.shrink(); + } + + final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: resolvedUrl); + final physicalSize = (MediaQuery.devicePixelRatioOf(context) * size).ceil(); + + return RealmContentNetworkImage( + avatarUrl.get(physicalSize), + filterQuality: FilterQuality.medium, + fit: BoxFit.cover, + ); + } +} + +/// A placeholder avatar for muted users. +/// +/// Wrap this with [AvatarShape]. +// TODO(#1558) use this as a fallback in more places (?) and update dartdoc. +class _AvatarPlaceholder extends StatelessWidget { + const _AvatarPlaceholder({required this.size}); + + /// The size of the placeholder box. + /// + /// This should match the `size` passed to the wrapping [AvatarShape]. + /// The placeholder's icon will be scaled proportionally to this. + final double size; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return DecoratedBox( + decoration: BoxDecoration(color: designVariables.avatarPlaceholderBg), + child: Icon(ZulipIcons.person, + // Where the avatar placeholder appears in the Figma, + // this is how the icon is sized proportionally to its box. + size: size * 20 / 32, + color: designVariables.avatarPlaceholderIcon)); + } +} + +/// A rounded square shape, to wrap an [AvatarImage] or similar. +/// +/// If [userIdForPresence] is provided, this will paint a [PresenceCircle] +/// on the shape. +class AvatarShape extends StatelessWidget { + const AvatarShape({ + super.key, + required this.size, + required this.borderRadius, + this.backgroundColor, + this.userIdForPresence, + required this.child, + }); + + final double size; + final double borderRadius; + final Color? backgroundColor; + final int? userIdForPresence; + final Widget child; + + @override + Widget build(BuildContext context) { + // (The backgroundColor is only meaningful if presence will be shown; + // see [PresenceCircle.backgroundColor].) + assert(backgroundColor == null || userIdForPresence != null); + + Widget result = SizedBox.square( + dimension: size, + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(borderRadius)), + clipBehavior: Clip.antiAlias, + child: child)); + + if (userIdForPresence != null) { + final presenceCircleSize = size / 4; // TODO(design) is this right? + result = Stack(children: [ + result, + Positioned.directional(textDirection: Directionality.of(context), + end: 0, + bottom: 0, + child: PresenceCircle( + userId: userIdForPresence!, + size: presenceCircleSize, + backgroundColor: backgroundColor)), + ]); + } + + return result; + } +} + +/// The green or orange-gradient circle representing [PresenceStatus]. +/// +/// [backgroundColor] must not be [Colors.transparent]. +/// It exists to match the background on which the avatar image is painted. +/// If [backgroundColor] is not passed, [DesignVariables.mainBackground] is used. +/// +/// By default, nothing paints for a user in the "offline" status +/// (i.e. a user without a [PresenceStatus]). +/// Pass true for [explicitOffline] to paint a gray circle. +class PresenceCircle extends StatefulWidget { + const PresenceCircle({ + super.key, + required this.userId, + required this.size, + this.backgroundColor, + this.explicitOffline = false, + }); + + final int userId; + final double size; + final Color? backgroundColor; + final bool explicitOffline; + + /// Creates a [WidgetSpan] with a [PresenceCircle], for use in rich text + /// before a user's name. + /// + /// The [PresenceCircle] will have `explicitOffline: true`. + static InlineSpan asWidgetSpan({ + required int userId, + required double fontSize, + required TextScaler textScaler, + Color? backgroundColor, + }) { + final size = textScaler.scale(fontSize) / 2; + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: PresenceCircle( + userId: userId, + size: size, + backgroundColor: backgroundColor, + explicitOffline: true))); + } + + @override + State createState() => _PresenceCircleState(); +} + +class _PresenceCircleState extends State with PerAccountStoreAwareStateMixin { + Presence? model; + + @override + void onNewStore() { + model?.removeListener(_modelChanged); + model = PerAccountStoreWidget.of(context).presence + ..addListener(_modelChanged); + } + + @override + void dispose() { + model!.removeListener(_modelChanged); + super.dispose(); + } + + void _modelChanged() { + setState(() { + // The actual state lives in [model]. + // This method was called because that just changed. + }); + } + + @override + Widget build(BuildContext context) { + final status = model!.presenceStatusForUser( + widget.userId, utcNow: ZulipBinding.instance.utcNow()); + final designVariables = DesignVariables.of(context); + final effectiveBackgroundColor = widget.backgroundColor ?? designVariables.mainBackground; + assert(effectiveBackgroundColor != Colors.transparent); + + Color? color; + LinearGradient? gradient; + switch (status) { + case null: + if (widget.explicitOffline) { + // TODO(a11y) this should be an open circle, like on web, + // to differentiate by shape (vs. the "active" status which is also + // a solid circle) + color = designVariables.statusAway; + } else { + return SizedBox.square(dimension: widget.size); + } + case PresenceStatus.active: + color = designVariables.statusOnline; + case PresenceStatus.idle: + gradient = LinearGradient( + begin: AlignmentDirectional.centerStart, + end: AlignmentDirectional.centerEnd, + colors: [designVariables.statusIdle, effectiveBackgroundColor], + stops: [0.05, 1.00], + ); + } + + return SizedBox.square(dimension: widget.size, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: effectiveBackgroundColor, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside), + color: color, + gradient: gradient, + shape: BoxShape.circle))); + } +} + +/// A user status emoji to be displayed in different parts of the app. +/// +/// Use [userId] to show status emoji for that user. +/// Use [emoji] to show the specific emoji passed. +/// +/// Only one of [userId] or [emoji] should be passed. +/// +/// Use [padding] to control the padding of status emoji from neighboring +/// widgets. +/// When there is no status emoji to be shown, the padding will be omitted too. +/// +/// Use [neverAnimate] to forcefully disable the animation for animated emojis. +/// Defaults to true. +class UserStatusEmoji extends StatelessWidget { + const UserStatusEmoji({ + super.key, + this.userId, + this.emoji, + required this.size, + this.padding = EdgeInsets.zero, + this.neverAnimate = true, + }) : assert((userId == null) != (emoji == null), + 'Only one of the userId or emoji should be provided.'); + + final int? userId; + final StatusEmoji? emoji; + final double size; + final EdgeInsetsGeometry padding; + final bool neverAnimate; + + static const double _spanPadding = 4; + + /// Creates a [WidgetSpan] with a [UserStatusEmoji], for use in rich text; + /// before or after a text span. + /// + /// Use [position] to tell the emoji span where it is located relative to + /// another span, so that it can adjust the necessary padding from it. + static InlineSpan asWidgetSpan({ + int? userId, + StatusEmoji? emoji, + required double fontSize, + required TextScaler textScaler, + StatusEmojiPosition position = StatusEmojiPosition.after, + bool neverAnimate = true, + }) { + final (double paddingStart, double paddingEnd) = switch (position) { + StatusEmojiPosition.before => (0, _spanPadding), + StatusEmojiPosition.after => (_spanPadding, 0), + }; + final size = textScaler.scale(fontSize); + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: UserStatusEmoji(userId: userId, emoji: emoji, size: size, + padding: EdgeInsetsDirectional.only(start: paddingStart, end: paddingEnd), + neverAnimate: neverAnimate)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final effectiveEmoji = emoji ?? store.getUserStatus(userId!).emoji; + + final placeholder = SizedBox.shrink(); + if (effectiveEmoji == null) return placeholder; + + final emojiDisplay = store.emojiDisplayFor( + emojiType: effectiveEmoji.reactionType, + emojiCode: effectiveEmoji.emojiCode, + emojiName: effectiveEmoji.emojiName) + // Web doesn't seem to respect the emojiset user settings for user status. + // .resolve(store.userSettings) + ; + return switch (emojiDisplay) { + UnicodeEmojiDisplay() => Padding( + padding: padding, + child: UnicodeEmojiWidget(size: size, emojiDisplay: emojiDisplay)), + ImageEmojiDisplay() => Padding( + padding: padding, + child: ImageEmojiWidget( + size: size, + emojiDisplay: emojiDisplay, + neverAnimate: neverAnimate, + // If image emoji fails to load, show nothing. + errorBuilder: (_, _, _) => placeholder)), + // The user-status feature doesn't support a :text_emoji:-style display. + // Also, if an image emoji's URL string doesn't parse, it'll fall back to + // a :text_emoji:-style display. We show nothing for this case. + TextEmojiDisplay() => placeholder, + }; + } +} + +/// The position of the status emoji span relative to another text span. +enum StatusEmojiPosition { before, after } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 83889555f8..2014885ccc 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,7 +5,9 @@ import FlutterMacOS import Foundation +import app_settings import device_info_plus +import file_picker import file_selector_macos import firebase_core import firebase_messaging @@ -18,7 +20,9 @@ import video_player_avfoundation import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppSettingsPlugin.register(with: registry.registrar(forPlugin: "AppSettingsPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 7125e9452b..cf0f3913b6 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,67 +1,71 @@ PODS: + - app_settings (5.1.1): + - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS + - file_picker (0.0.1): + - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS - - Firebase/CoreOnly (11.6.0): - - FirebaseCore (~> 11.6.0) - - Firebase/Messaging (11.6.0): + - Firebase/CoreOnly (12.0.0): + - FirebaseCore (~> 12.0.0) + - Firebase/Messaging (12.0.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 11.6.0) - - firebase_core (3.10.0): - - Firebase/CoreOnly (~> 11.6.0) + - FirebaseMessaging (~> 12.0.0) + - firebase_core (4.0.0): + - Firebase/CoreOnly (~> 12.0.0) - FlutterMacOS - - firebase_messaging (15.2.0): - - Firebase/CoreOnly (~> 11.6.0) - - Firebase/Messaging (~> 11.6.0) + - firebase_messaging (16.0.0): + - Firebase/CoreOnly (~> 12.0.0) + - Firebase/Messaging (~> 12.0.0) - firebase_core - FlutterMacOS - - 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.6.0): - - FirebaseCore (~> 11.6.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseCore (12.0.0): + - FirebaseCoreInternal (~> 12.0.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (12.0.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (12.0.0): + - FirebaseCore (~> 12.0.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (11.6.0): - - FirebaseCore (~> 11.6.0) - - FirebaseInstallations (~> 11.0) - - GoogleDataTransport (~> 10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/Reachability (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseMessaging (12.0.0): + - FirebaseCore (~> 12.0.0) + - FirebaseInstallations (~> 12.0.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - nanopb (~> 3.30910.0) - FlutterMacOS (1.0.0) - GoogleDataTransport (10.1.0): - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - GoogleUtilities/AppDelegateSwizzler (8.0.2): + - GoogleUtilities/AppDelegateSwizzler (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (8.0.2): + - GoogleUtilities/Environment (8.1.0): - GoogleUtilities/Privacy - - GoogleUtilities/Logger (8.0.2): + - GoogleUtilities/Logger (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/Network (8.0.2): + - GoogleUtilities/Network (8.1.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (8.0.2)": + - "GoogleUtilities/NSData+zlib (8.1.0)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (8.0.2) - - GoogleUtilities/Reachability (8.0.2): + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (8.0.2): + - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - nanopb (3.30910.0): @@ -77,25 +81,31 @@ PODS: - PromisesObjC (2.4.0) - share_plus (0.0.1): - FlutterMacOS - - sqlite3 (3.47.2): - - sqlite3/common (= 3.47.2) - - sqlite3/common (3.47.2) - - sqlite3/dbstatvtab (3.47.2): + - sqlite3 (3.50.4): + - sqlite3/common (= 3.50.4) + - sqlite3/common (3.50.4) + - sqlite3/dbstatvtab (3.50.4): + - sqlite3/common + - sqlite3/fts5 (3.50.4): + - sqlite3/common + - sqlite3/math (3.50.4): - sqlite3/common - - sqlite3/fts5 (3.47.2): + - sqlite3/perf-threadsafe (3.50.4): - sqlite3/common - - sqlite3/perf-threadsafe (3.47.2): + - sqlite3/rtree (3.50.4): - sqlite3/common - - sqlite3/rtree (3.47.2): + - sqlite3/session (3.50.4): - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (~> 3.47.2) + - sqlite3 (~> 3.50.3) - sqlite3/dbstatvtab - sqlite3/fts5 + - sqlite3/math - sqlite3/perf-threadsafe - sqlite3/rtree + - sqlite3/session - url_launcher_macos (0.0.1): - FlutterMacOS - video_player_avfoundation (0.0.1): @@ -105,7 +115,9 @@ PODS: - FlutterMacOS DEPENDENCIES: + - app_settings (from `Flutter/ephemeral/.symlinks/plugins/app_settings/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) - firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`) @@ -132,8 +144,12 @@ SPEC REPOS: - sqlite3 EXTERNAL SOURCES: + app_settings: + :path: Flutter/ephemeral/.symlinks/plugins/app_settings/macos device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos file_selector_macos: :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos firebase_core: @@ -158,28 +174,30 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos SPEC CHECKSUMS: - device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215 - file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d - Firebase: 374a441a91ead896215703a674d58cdb3e9d772b - firebase_core: 6d9bb8b0ea817e8fe0d928177d42275b45fdba6f - firebase_messaging: ae8e88b586e4d50abc7cac5bacf74d21967fd226 - FirebaseCore: 48b0dd707581cf9c1a1220da68223fb0a562afaa - FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2 - FirebaseInstallations: efc0946fc756e4d22d8113f7c761948120322e8c - FirebaseMessaging: e1aca1fcc23e8b9eddb0e33f375ff90944623021 - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + app_settings: cd21e176b56f8172043640ade81322a98896bff4 + device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a + file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 + Firebase: 800d487043c0557d9faed71477a38d9aafb08a41 + firebase_core: eeea10f64026b68cd0bc3dee079ab4717e22909e + firebase_messaging: 5eefcd5bde556bfacdd9968e11c52f39032dfbe5 + FirebaseCore: 055f4ab117d5964158c833f3d5e7ec6d91648d4a + FirebaseCoreInternal: dedc28e569a4be85f38f3d6af1070a2e12018d55 + FirebaseInstallations: d4c7c958f99c8860d7fcece786314ae790e2f988 + FirebaseMessaging: af49f8d7c0a3d2a017d9302c80946f45a7777dde + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 - GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - share_plus: 1fa619de8392a4398bfaf176d441853922614e89 - sqlite3: 7559e33dae4c78538df563795af3a86fc887ee71 - sqlite3_flutter_libs: 58ae36c0dd086395d066b4fe4de9cdca83e717b3 - url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 - video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 - wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b + sqlite3_flutter_libs: 616267f2fca40e9c6af8c5d82324e05667040b6e + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + wakelock_plus: 21ddc249ac4b8d018838dbdabd65c5976c308497 PODFILE CHECKSUM: bd6842df0dd91920553fbfacd50c921f7e63a62f diff --git a/pigeon/android_intents.dart b/pigeon/android_intents.dart new file mode 100644 index 0000000000..fa95b9d697 --- /dev/null +++ b/pigeon/android_intents.dart @@ -0,0 +1,53 @@ +import 'package:pigeon/pigeon.dart'; + +// To rebuild this pigeon's output after editing this file, +// run `tools/check pigeon --fix`. +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/host/android_intents.g.dart', + kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/AndroidIntents.g.kt', + kotlinOptions: KotlinOptions( + package: 'com.zulip.flutter', + // One error class is already generated in AndroidNotifications.g.kt , + // so avoid generating another one, preventing duplicate classes under + // the same namespace. + includeErrorClass: false))) + +// TODO separate out API calls for resolving file name, getting mimetype, getting bytes? +class IntentSharedFile { + const IntentSharedFile({ + required this.name, + required this.mimeType, + required this.bytes, + }); + + final String? name; + final String? mimeType; + final Uint8List bytes; +} + +sealed class AndroidIntentEvent { + const AndroidIntentEvent(); + + // Pigeon doesn't seem to allow fields in sealed classes. + // final String action; +} + +class AndroidIntentSendEvent extends AndroidIntentEvent { + const AndroidIntentSendEvent({ + required this.action, + required this.extraText, + required this.extraStream, + }); + + // This would be either 'android.intent.action.SEND' or + // 'android.intent.action.SEND_MULTIPLE' for this event type. + final String action; + + final String? extraText; + final List? extraStream; +} + +@EventChannelApi() +abstract class AndroidIntentsEventChannelApi { + AndroidIntentEvent androidIntentEvents(); +} diff --git a/pigeon/android_notifications.dart b/pigeon/android_notifications.dart new file mode 100644 index 0000000000..901369001c --- /dev/null +++ b/pigeon/android_notifications.dart @@ -0,0 +1,306 @@ +import 'package:pigeon/pigeon.dart'; + +// To rebuild this pigeon's output after editing this file, +// run `tools/check pigeon --fix`. +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/host/android_notifications.g.dart', + kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt', + kotlinOptions: KotlinOptions(package: 'com.zulip.flutter'), +)) + +/// Corresponds to `androidx.core.app.NotificationChannelCompat` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat +class NotificationChannel { + /// Corresponds to `androidx.core.app.NotificationChannelCompat.Builder` + /// + /// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat.Builder + NotificationChannel({ + required this.id, + required this.importance, + this.name, + this.lightsEnabled, + this.soundUrl, + this.vibrationPattern, + }); + + final String id; + + /// Specifies the importance level of notifications + /// to be posted on this channel. + /// + /// Must be a valid constant from [NotificationImportance]. + final int importance; + + final String? name; + final bool? lightsEnabled; + final String? soundUrl; + final Int64List? vibrationPattern; +} + +/// Corresponds to `android.content.Intent` +/// +/// See: +/// https://developer.android.com/reference/android/content/Intent +/// https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E) +class AndroidIntent { + AndroidIntent({required this.action, required this.dataUrl, this.flags = 0}); + + final String action; + final String dataUrl; + + /// A combination of flags from [IntentFlag]. + final int flags; +} + +/// Corresponds to `android.app.PendingIntent`. +/// +/// See: https://developer.android.com/reference/android/app/PendingIntent +class PendingIntent { + /// Corresponds to `PendingIntent.getActivity`. + PendingIntent({required this.requestCode, required this.intent, required this.flags}); + + final int requestCode; + final AndroidIntent intent; + + /// A combination of flags from [PendingIntent.flags], and others associated + /// with `Intent`; see Android docs for `PendingIntent.getActivity`. + final int flags; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.InboxStyle` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.InboxStyle +class InboxStyle { + InboxStyle({required this.summaryText}); + + final String summaryText; +} + +/// Corresponds to `androidx.core.app.Person` +/// +/// See: https://developer.android.com/reference/androidx/core/app/Person +class Person { + Person({ + required this.iconBitmap, + required this.key, + required this.name, + }); + + /// An icon for this person. + /// + /// This should be compressed image data, in a format to be passed + /// to `androidx.core.graphics.drawable.IconCompat.createWithData`. + /// Supported formats include JPEG, PNG, and WEBP. + /// + /// See: + /// https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int) + final Uint8List? iconBitmap; + + final String key; + final String name; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message +class MessagingStyleMessage { + MessagingStyleMessage({ + required this.text, + required this.timestampMs, + required this.person, + }); + + final String text; + final int timestampMs; + final Person person; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle +class MessagingStyle { + MessagingStyle({ + required this.user, + required this.conversationTitle, + required this.isGroupConversation, + required this.messages, + }); + + final Person user; + final String? conversationTitle; + final List messages; + final bool isGroupConversation; +} + +/// Corresponds to `android.app.Notification` +/// +/// See: https://developer.android.com/reference/kotlin/android/app/Notification +class Notification { + Notification({required this.group, required this.extras}); + + final String group; + final Map extras; + // Various other properties too; add them if needed. +} + +/// Corresponds to `android.service.notification.StatusBarNotification` +/// +/// See: https://developer.android.com/reference/android/service/notification/StatusBarNotification +class StatusBarNotification { + StatusBarNotification({required this.id, required this.tag, required this.notification}); + + final int id; + final String tag; + final Notification notification; + + // Ignore `groupKey` and `key`. While the `.groupKey` contains the + // `.notification.group`, and the `.key` contains the `.id` and `.tag`, + // they also have more stuff added on (and their structure doesn't seem to + // be documented.) + // final String? groupKey; + // final String? key; + + // Various other properties too; add them if needed. +} + +/// Represents details about a notification sound stored in the +/// shared media store. +/// +/// Returned as a list entry by +/// [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory]. +class StoredNotificationSound { + StoredNotificationSound({ + required this.fileName, + required this.isOwned, + required this.contentUrl, + }); + + /// The display name of the sound file. + final String fileName; + + /// Specifies whether this file was created by the app. + /// + /// It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the + /// metadata matches the app's package name. + final bool isOwned; + + /// A `content://…` URL pointing to the sound file. + final String contentUrl; +} + +@HostApi() +abstract class AndroidNotificationHostApi { + /// Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`. + /// + /// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat) + void createNotificationChannel(NotificationChannel channel); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat() + List getNotificationChannels(); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel` + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String) + void deleteNotificationChannel(String channelId); + + /// The list of notification sound files present under `Notifications/Zulip/` + /// in the device's shared media storage, + /// found with `android.content.ContentResolver.query`. + /// + /// This is a complex ad-hoc method. + /// For detailed behavior, see its implementation. + /// + /// Requires minimum of Android 10 (API 29) or higher. + /// + /// See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String) + List listStoredSoundsInNotificationsDirectory(); + + /// Wraps `android.content.ContentResolver.insert` combined with + /// `android.content.ContentResolver.openOutputStream` and + /// `android.content.res.Resources.openRawResource`. + /// + /// Copies a raw resource audio file to `Notifications/Zulip/` + /// directory in device's shared media storage. Returns the URL + /// of the target file in media store. + /// + /// Requires minimum of Android 10 (API 29) or higher. + /// + /// See: + /// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues) + /// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) + /// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) + String copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName}); + + /// Corresponds to `android.app.NotificationManager.notify`, + /// combined with `androidx.core.app.NotificationCompat.Builder`. + /// + /// The arguments `tag` and `id` go to the `notify` call. + /// The rest go to method calls on the builder. + /// + /// The `color` should be in the form 0xAARRGGBB. + /// See [ColorExtension.argbInt]. + /// + /// The `smallIconResourceName` is passed to `android.content.res.Resources.getIdentifier` + /// to get a resource ID to pass to `Builder.setSmallIcon`. + /// Whatever name is passed there must appear in keep.xml too: + /// see https://github.com/zulip/zulip-flutter/issues/528 . + /// + /// See: + /// https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify + /// https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder + // TODO(pigeon): Try ProxyApi for Notification objects, once that exists for Kotlin. + // As of 2024-03, ProxyApi is actively being implemented; the Dart side just landed. + // https://github.com/flutter/flutter/issues/134777 + void notify({ + String? tag, + required int id, + + // The remaining arguments go to method calls on NotificationCompat.Builder. + bool? autoCancel, + required String channelId, + int? color, + PendingIntent? contentIntent, + String? contentText, + String? contentTitle, + Map? extras, + String? groupKey, + InboxStyle? inboxStyle, + bool? isGroupSummary, + MessagingStyle? messagingStyle, + int? number, + String? smallIconResourceName, + // NotificationCompat.Builder has lots more methods; add as needed. + // Keep them alphabetized, for easy comparison with that class's docs. + }); + + /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, + /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. + /// + /// Returns the messaging style, if any, of an active notification + /// that has tag `tag`. If there are several such notifications, + /// an arbitrary one of them is used. + /// Returns null if there are no such notifications. + /// + /// See: + /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getActiveNotifications() + /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification) + MessagingStyle? getActiveNotificationMessagingStyleByTag(String tag); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`. + /// + /// The keys of entries to fetch from notification's extras bundle must be + /// specified in the [desiredExtras] list. If this list is empty, then + /// [Notifications.extras] will also be empty. If value of the matched entry + /// is not of type string or is null, then that entry will be skipped. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications() + List getActiveNotifications({required List desiredExtras}); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) + void cancel({String? tag, required int id}); +} diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart index 708ae4efb5..66c1bd2e71 100644 --- a/pigeon/notifications.dart +++ b/pigeon/notifications.dart @@ -3,304 +3,51 @@ import 'package:pigeon/pigeon.dart'; // To rebuild this pigeon's output after editing this file, // run `tools/check pigeon --fix`. @ConfigurePigeon(PigeonOptions( - dartOut: 'lib/host/android_notifications.g.dart', - kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt', - kotlinOptions: KotlinOptions(package: 'com.zulip.flutter'), + dartOut: 'lib/host/notifications.g.dart', + swiftOut: 'ios/Runner/Notifications.g.swift', )) -/// Corresponds to `androidx.core.app.NotificationChannelCompat` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat -class NotificationChannel { - /// Corresponds to `androidx.core.app.NotificationChannelCompat.Builder` - /// - /// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat.Builder - NotificationChannel({ - required this.id, - required this.importance, - this.name, - this.lightsEnabled, - this.soundUrl, - this.vibrationPattern, - }); - - final String id; +class NotificationDataFromLaunch { + const NotificationDataFromLaunch({required this.payload}); - /// Specifies the importance level of notifications - /// to be posted on this channel. + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. /// - /// Must be a valid constant from [NotificationImportance]. - final int importance; - - final String? name; - final bool? lightsEnabled; - final String? soundUrl; - final Int64List? vibrationPattern; -} - -/// Corresponds to `android.content.Intent` -/// -/// See: -/// https://developer.android.com/reference/android/content/Intent -/// https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E) -class AndroidIntent { - AndroidIntent({required this.action, required this.dataUrl, this.flags = 0}); - - final String action; - final String dataUrl; - - /// A combination of flags from [IntentFlag]. - final int flags; -} - -/// Corresponds to `android.app.PendingIntent`. -/// -/// See: https://developer.android.com/reference/android/app/PendingIntent -class PendingIntent { - /// Corresponds to `PendingIntent.getActivity`. - PendingIntent({required this.requestCode, required this.intent, required this.flags}); - - final int requestCode; - final AndroidIntent intent; - - /// A combination of flags from [PendingIntent.flags], and others associated - /// with `Intent`; see Android docs for `PendingIntent.getActivity`. - final int flags; -} - -/// Corresponds to `androidx.core.app.NotificationCompat.InboxStyle` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.InboxStyle -class InboxStyle { - InboxStyle({required this.summaryText}); - - final String summaryText; + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + final Map payload; } -/// Corresponds to `androidx.core.app.Person` -/// -/// See: https://developer.android.com/reference/androidx/core/app/Person -class Person { - Person({ - required this.iconBitmap, - required this.key, - required this.name, - }); +class NotificationTapEvent { + const NotificationTapEvent({required this.payload}); - /// An icon for this person. + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. /// - /// This should be compressed image data, in a format to be passed - /// to `androidx.core.graphics.drawable.IconCompat.createWithData`. - /// Supported formats include JPEG, PNG, and WEBP. - /// - /// See: - /// https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int) - final Uint8List? iconBitmap; - - final String key; - final String name; -} - -/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message -class MessagingStyleMessage { - MessagingStyleMessage({ - required this.text, - required this.timestampMs, - required this.person, - }); - - final String text; - final int timestampMs; - final Person person; -} - -/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle -class MessagingStyle { - MessagingStyle({ - required this.user, - required this.conversationTitle, - required this.isGroupConversation, - required this.messages, - }); - - final Person user; - final String? conversationTitle; - final List messages; - final bool isGroupConversation; -} - -/// Corresponds to `android.app.Notification` -/// -/// See: https://developer.android.com/reference/kotlin/android/app/Notification -class Notification { - Notification({required this.group, required this.extras}); - - final String group; - final Map extras; - // Various other properties too; add them if needed. -} - -/// Corresponds to `android.service.notification.StatusBarNotification` -/// -/// See: https://developer.android.com/reference/android/service/notification/StatusBarNotification -class StatusBarNotification { - StatusBarNotification({required this.id, required this.tag, required this.notification}); - - final int id; - final String tag; - final Notification notification; - - // Ignore `groupKey` and `key`. While the `.groupKey` contains the - // `.notification.group`, and the `.key` contains the `.id` and `.tag`, - // they also have more stuff added on (and their structure doesn't seem to - // be documented.) - // final String? groupKey; - // final String? key; - - // Various other properties too; add them if needed. -} - -/// Represents details about a notification sound stored in the -/// shared media store. -/// -/// Returned as a list entry by -/// [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory]. -class StoredNotificationSound { - StoredNotificationSound({ - required this.fileName, - required this.isOwned, - required this.contentUrl, - }); - - /// The display name of the sound file. - final String fileName; - - /// Specifies whether this file was created by the app. - /// - /// It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the - /// metadata matches the app's package name. - final bool isOwned; - - /// A `content://…` URL pointing to the sound file. - final String contentUrl; + /// See [notificationTapEvents]. + final Map payload; } @HostApi() -abstract class AndroidNotificationHostApi { - /// Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`. - /// - /// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat) - void createNotificationChannel(NotificationChannel channel); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`. - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat() - List getNotificationChannels(); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel` - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String) - void deleteNotificationChannel(String channelId); - - /// The list of notification sound files present under `Notifications/Zulip/` - /// in the device's shared media storage, - /// found with `android.content.ContentResolver.query`. - /// - /// This is a complex ad-hoc method. - /// For detailed behavior, see its implementation. - /// - /// Requires minimum of Android 10 (API 29) or higher. - /// - /// See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String) - List listStoredSoundsInNotificationsDirectory(); - - /// Wraps `android.content.ContentResolver.insert` combined with - /// `android.content.ContentResolver.openOutputStream` and - /// `android.content.res.Resources.openRawResource`. - /// - /// Copies a raw resource audio file to `Notifications/Zulip/` - /// directory in device's shared media storage. Returns the URL - /// of the target file in media store. - /// - /// Requires minimum of Android 10 (API 29) or higher. - /// - /// See: - /// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues) - /// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) - /// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) - String copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName}); - - /// Corresponds to `android.app.NotificationManager.notify`, - /// combined with `androidx.core.app.NotificationCompat.Builder`. - /// - /// The arguments `tag` and `id` go to the `notify` call. - /// The rest go to method calls on the builder. - /// - /// The `color` should be in the form 0xAARRGGBB. - /// See [ColorExtension.argbInt]. - /// - /// The `smallIconResourceName` is passed to `android.content.res.Resources.getIdentifier` - /// to get a resource ID to pass to `Builder.setSmallIcon`. - /// Whatever name is passed there must appear in keep.xml too: - /// see https://github.com/zulip/zulip-flutter/issues/528 . - /// - /// See: - /// https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify - /// https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder - // TODO(pigeon): Try ProxyApi for Notification objects, once that exists for Kotlin. - // As of 2024-03, ProxyApi is actively being implemented; the Dart side just landed. - // https://github.com/flutter/flutter/issues/134777 - void notify({ - String? tag, - required int id, - - // The remaining arguments go to method calls on NotificationCompat.Builder. - bool? autoCancel, - required String channelId, - int? color, - PendingIntent? contentIntent, - String? contentText, - String? contentTitle, - Map? extras, - String? groupKey, - InboxStyle? inboxStyle, - bool? isGroupSummary, - MessagingStyle? messagingStyle, - int? number, - String? smallIconResourceName, - // NotificationCompat.Builder has lots more methods; add as needed. - // Keep them alphabetized, for easy comparison with that class's docs. - }); - - /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, - /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. - /// - /// Returns the messaging style, if any, of an active notification - /// that has tag `tag`. If there are several such notifications, - /// an arbitrary one of them is used. - /// Returns null if there are no such notifications. - /// - /// See: - /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getActiveNotifications() - /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification) - MessagingStyle? getActiveNotificationMessagingStyleByTag(String tag); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`. - /// - /// The keys of entries to fetch from notification's extras bundle must be - /// specified in the [desiredExtras] list. If this list is empty, then - /// [Notifications.extras] will also be empty. If value of the matched entry - /// is not of type string or is null, then that entry will be skipped. - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications() - List getActiveNotifications({required List desiredExtras}); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`. - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) - void cancel({String? tag, required int id}); +abstract class NotificationHostApi { + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// Returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + NotificationDataFromLaunch? getNotificationDataFromLaunch(); +} + +@EventChannelApi() +abstract class NotificationEventChannelApi { + /// An event stream that emits a notification payload when the app + /// encounters a notification tap, while the app is running. + /// + /// Emits an event when + /// `userNotificationCenter(_:didReceive:withCompletionHandler:)` gets + /// called, indicating that the user has tapped on a notification. The + /// emitted payload will be the raw APNs data dictionary from the + /// `UNNotificationResponse` passed to that method. + NotificationTapEvent notificationTapEvents(); } diff --git a/pubspec.lock b/pubspec.lock index 4ee3317f8d..bc3cb33f00 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,55 +5,50 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f url: "https://pub.dev" source: hosted - version: "76.0.0" + version: "85.0.0" _flutterfire_internals: dependency: transitive description: name: _flutterfire_internals - sha256: "27899c95f9e7ec06c8310e6e0eac967707714b9f1450c4a58fa00ca011a4a8ae" + sha256: bb84ee51e527053dd8e25ecc9f97a6abfdc19130fb4d883e4e8585e23e7e6dd8 url: "https://pub.dev" source: hosted - version: "1.3.49" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" + version: "1.3.60" analyzer: dependency: transitive description: name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" url: "https://pub.dev" source: hosted - version: "6.11.0" + version: "7.7.1" app_settings: dependency: "direct main" description: name: app_settings - sha256: "09bc7fe0313a507087bec1a3baf555f0576e816a760cbb31813a88890a09d9e5" + sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795" url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "6.1.1" args: dependency: "direct dev" description: name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" async: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" boolean_selector: dependency: transitive description: @@ -66,10 +61,10 @@ packages: dependency: transitive description: name: build - sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + sha256: "7d95cbbb1526ab5ae977df9b4cc660963b9b27f6d1075c0b34653868911385e4" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "3.0.0" build_config: dependency: transitive description: @@ -82,34 +77,34 @@ packages: dependency: transitive description: name: build_daemon - sha256: "294a2edaf4814a378725bfe6358210196f5ea37af89ecd81bfa32960113d4948" + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" url: "https://pub.dev" source: hosted - version: "4.0.3" + version: "4.0.4" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "99d3980049739a985cf9b21f30881f46db3ebc62c5b8d5e60e27440876b1ba1e" + sha256: "38c9c339333a09b090a638849a4c56e70a404c6bdd3b511493addfbc113b60c2" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "3.0.0" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573" + sha256: b971d4a1c789eba7be3e6fe6ce5e5b50fd3719e3cb485b3fad6d04358304351d url: "https://pub.dev" source: hosted - version: "2.4.14" + version: "2.6.0" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + sha256: c04e612ca801cd0928ccdb891c263a2b1391cb27940a5ea5afcf9ba894de5d62 url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "9.2.0" built_collection: dependency: transitive description: @@ -122,10 +117,10 @@ packages: dependency: transitive description: name: built_value - sha256: "28a712df2576b63c6c005c465989a348604960c0958d28be5303ba9baa841ac2" + sha256: "0b1b12a0a549605e5f04476031cd0bc91ead1d7c8e830773a18ee54179b3cb62" url: "https://pub.dev" source: hosted - version: "8.9.3" + version: "8.11.0" characters: dependency: transitive description: @@ -146,18 +141,26 @@ packages: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" checks: dependency: "direct dev" description: name: checks - sha256: aad431b45a8ae2fa26db8c22e385b9cdec73f72986a1d9d9f2017f4c39ecf5c9 + sha256: "016871c84732c1ac9856b8940236d5a5802ba638b3bd3e0ea7027b51a35f7aa7" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.3.1" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" cli_util: dependency: transitive description: @@ -211,10 +214,10 @@ packages: dependency: transitive description: name: coverage - sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43 + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.15.0" cross_file: dependency: transitive description: @@ -232,7 +235,7 @@ packages: source: hosted version: "3.0.6" csslib: - dependency: transitive + dependency: "direct main" description: name: csslib sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" @@ -243,66 +246,66 @@ packages: dependency: transitive description: name: dart_style - sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" url: "https://pub.dev" source: hosted - version: "2.3.7" + version: "3.1.1" dbus: dependency: transitive description: name: dbus - sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: "4fa68e53e26ab17b70ca39f072c285562cfc1589df5bb1e9295db90f6645f431" + sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" url: "https://pub.dev" source: hosted - version: "11.2.0" + version: "11.5.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f url: "https://pub.dev" source: hosted - version: "7.0.2" + version: "7.0.3" drift: dependency: "direct main" description: name: drift - sha256: af3941e4d544727b2eb80590eb64e9cb8d77cd68c7690265502ea6a2427aa621 + sha256: "6aaea757f53bb035e8a3baedf3d1d53a79d6549a6c13d84f7546509da9372c7c" url: "https://pub.dev" source: hosted - version: "2.23.1" + version: "2.28.1" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: fa98fdbb7303a1b5b2dc110cb516eda2253a5d291680f8cbc72b1af24099f7f9 + sha256: "2fc05ad458a7c562755bf0cae11178dfc58387a416829b78d4da5155a61465fd" url: "https://pub.dev" source: hosted - version: "2.23.1" + version: "2.28.1" fake_async: dependency: "direct dev" description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" ffi: dependency: transitive description: name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" file: dependency: transitive description: @@ -315,10 +318,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: c904b4ab56d53385563c7c39d8e9fa9af086f91495dfc48717ad84a42c3cf204 + sha256: "13ba4e627ef24503a465d1d61b32596ce10eb6b8903678d362a528f9939b4aa8" url: "https://pub.dev" source: hosted - version: "8.1.7" + version: "10.2.1" file_selector_linux: dependency: transitive description: @@ -331,10 +334,10 @@ packages: dependency: transitive description: name: file_selector_macos - sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" + sha256: "8c9250b2bd2d8d4268e39c82543bacbaca0fda7d29e0728c3c4bbb7c820fd711" url: "https://pub.dev" source: hosted - version: "0.9.4+2" + version: "0.9.4+3" file_selector_platform_interface: dependency: transitive description: @@ -347,58 +350,58 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: "8f5d2f6590d51ecd9179ba39c64f722edc15226cc93dcc8698466ad36a4a85a4" + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" url: "https://pub.dev" source: hosted - version: "0.9.3+3" + version: "0.9.3+4" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: "0307c1fde82e2b8b97e0be2dab93612aff9a72f31ebe9bfac66ed8b37ef7c568" + sha256: "6b343e6f7b72a4f32d7ce8df8c9a28d8f54b4ac20d7c6500f3e8b3969afca457" url: "https://pub.dev" source: hosted - version: "3.10.0" + version: "4.0.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: d7253d255ff10f85cfd2adaba9ac17bae878fa3ba577462451163bd9f1d1f0bf + sha256: "5dbc900677dcbe5873d22ad7fbd64b047750124f1f9b7ebe2a33b9ddccc838eb" url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "6.0.0" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: fbc008cf390d909b823763064b63afefe9f02d8afdb13eb3f485b871afee956b + sha256: "5d28b14dd32282fb7ce2b22b897362453755b6b8541d491127dc72b755bb7b16" url: "https://pub.dev" source: hosted - version: "2.19.0" + version: "3.0.0" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: "48a8a59197c1c5174060ba9aa1e0036e9b5a0d28a0cc22d19c1fcabc67fafe3c" + sha256: "10272b553a49c13a6cedfd00121047157521f82a5d3f2a1706b9dd28342cc482" url: "https://pub.dev" source: hosted - version: "15.2.0" + version: "16.0.0" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "9770a8e91f54296829dcaa61ce9b7c2f9ae9abbf99976dd3103a60470d5264dd" + sha256: b846a305feb3f74ee3f0aace447f65a4696bc6550bc828ecf5a84a1b77473d16 url: "https://pub.dev" source: hosted - version: "4.6.0" + version: "4.7.0" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "329ca4ef45ec616abe6f1d5e58feed0934a50840a65aa327052354ad3c64ed77" + sha256: "28714749880f7242c5fb3b1ee6c66b41f61453f02ae348b43c82957df80b87ae" url: "https://pub.dev" source: hosted - version: "3.10.0" + version: "4.0.0" fixnum: dependency: transitive description: @@ -416,10 +419,10 @@ packages: dependency: "direct dev" description: name: flutter_checks - sha256: b0a40b15d38436b7c08b83353e74b0569c1ec687bd81c9556091fbb8807c1b99 + sha256: "800acdd6973e1306f65ef3b2287d91c0e45c8e64b696d593297c0c1c1e2740bc" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.2" flutter_color_models: dependency: "direct main" description: @@ -438,10 +441,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -451,10 +454,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e url: "https://pub.dev" source: hosted - version: "2.0.24" + version: "2.0.28" flutter_test: dependency: "direct dev" description: flutter @@ -482,10 +485,10 @@ packages: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" graphs: dependency: transitive description: @@ -498,18 +501,18 @@ packages: dependency: "direct main" description: name: html - sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" url: "https://pub.dev" source: hosted - version: "0.15.5" + version: "0.15.6" http: dependency: "direct main" description: name: http - sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.4.0" http_multi_server: dependency: transitive description: @@ -538,10 +541,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: b62d34a506e12bb965e824b6db4fbf709ee4589cf5d3e99b45ab2287b008ee0c + sha256: "6fae381e6af2bbe0365a5e4ce1db3959462fa0c4d234facf070746024bb80c8d" url: "https://pub.dev" source: hosted - version: "0.8.12+20" + version: "0.8.12+24" image_picker_for_web: dependency: transitive description: @@ -562,18 +565,18 @@ packages: dependency: transitive description: name: image_picker_linux - sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9" url: "https://pub.dev" source: hosted - version: "0.2.1+1" + version: "0.2.1+2" image_picker_macos: dependency: transitive description: name: image_picker_macos - sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1" url: "https://pub.dev" source: hosted - version: "0.2.1+1" + version: "0.2.1+2" image_picker_platform_interface: dependency: transitive description: @@ -607,10 +610,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.2" io: dependency: transitive description: @@ -623,10 +626,10 @@ packages: dependency: transitive description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -639,50 +642,50 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: b0a98230538fe5d0b60a22fb6bf1b6cb03471b53e3324ff6069c591679dd59c9 + sha256: ce2cf974ccdee13be2a510832d7fba0b94b364e0b0395dee42abaa51b855be27 url: "https://pub.dev" source: hosted - version: "6.9.3" + version: "6.10.0" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" legacy_checks: dependency: "direct dev" description: name: legacy_checks - sha256: b22e5b1ff55a14e8bd91acafbabff909e14829b73ca492dcdaf0fbbb70476079 + sha256: "1322d6f15e27c214b225d32c9802cd8d312a0373a45efccf48e5e2bb42c7611b" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.1.1" lints: dependency: transitive description: name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "6.0.0" logging: dependency: transitive description: @@ -691,14 +694,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" matcher: dependency: transitive description: @@ -719,10 +714,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: "direct main" description: @@ -751,26 +746,26 @@ packages: dependency: transitive description: name: package_config - sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.0" package_info_plus: dependency: "direct main" description: name: package_info_plus - sha256: "70c421fe9d9cc1a9a7f3b05ae56befd469fe4f8daa3b484823141a55442d858d" + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" url: "https://pub.dev" source: hosted - version: "8.1.2" + version: "8.3.0" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: a5ef9986efc7bf772f2696183a3992615baa76c1ffb1189318dd8803778fb05b + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.2.0" path: dependency: "direct main" description: @@ -791,10 +786,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 url: "https://pub.dev" source: hosted - version: "2.2.15" + version: "2.2.17" path_provider_foundation: dependency: transitive description: @@ -831,18 +826,18 @@ packages: dependency: transitive description: name: petitparser - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + sha256: "9436fe11f82d7cc1642a8671e5aa4149ffa9ae9116e6cf6dd665fc0653e3825c" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.0.0" pigeon: dependency: "direct dev" description: name: pigeon - sha256: "694073e4677d631a5aa2c633c5944140c0e7f361cbf3e55b1709dd11688713bb" + sha256: b65acb352dc5a5f8615d074a83419388cbcc249f07c6d8c78b5bc16680a55dda url: "https://pub.dev" source: hosted - version: "22.7.2" + version: "26.0.0" platform: dependency: transitive description: @@ -852,7 +847,7 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: "direct dev" + dependency: transitive description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" @@ -879,18 +874,18 @@ packages: dependency: transitive description: name: process - sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "5.0.5" pub_semver: dependency: transitive description: name: pub_semver - sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" pubspec_parse: dependency: transitive description: @@ -911,18 +906,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "6327c3f233729374d0abaafd61f6846115b2a481b4feddd8534211dc10659400" + sha256: b2961506569e28948d75ec346c28775bb111986bb69dc6a20754a457e3d97fa0 url: "https://pub.dev" source: hosted - version: "10.1.3" + version: "11.0.0" share_plus_platform_interface: dependency: "direct main" description: name: share_plus_platform_interface - sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + sha256: "1032d392bc5d2095a77447a805aa3f804d2ae6a4d5eef5e6ebb3bd94c1bc19ef" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "6.0.0" shelf: dependency: transitive description: @@ -951,10 +946,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.0" sky_engine: dependency: transitive description: flutter @@ -964,18 +959,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + sha256: fc787b1f89ceac9580c3616f899c9a447413cbdac1df071302127764c023a134 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" source_helper: dependency: transitive description: name: source_helper - sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + sha256: "4f81479fe5194a622cdd1713fe1ecb683a6e6c85cd8cec8e2e35ee5ab3fdf2a1" url: "https://pub.dev" source: hosted - version: "1.3.5" + version: "1.3.6" source_map_stack_trace: dependency: transitive description: @@ -1012,26 +1007,26 @@ packages: dependency: "direct main" description: name: sqlite3 - sha256: c284434c408d207863800341298cadfde23abe074a0f01b19c9d8cce4edb8eaa + sha256: dd806fff004a0aeb01e208b858dbc649bc72104670d425a81a6dd17698535f6e url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.8.0" sqlite3_flutter_libs: dependency: "direct main" description: name: sqlite3_flutter_libs - sha256: "73016db8419f019e807b7a5e5fbf2a7bd45c165fed403b8e7681230f3a102785" + sha256: fd996da5515a73aacd0a04ae7063db5fe8df42670d974df4c3ee538c652eef2e url: "https://pub.dev" source: hosted - version: "0.5.28" + version: "0.5.38" sqlparser: dependency: transitive description: name: sqlparser - sha256: "4cad4b2c5f63dc9ea1a8dcffb58cf762322bea5dd8836870164a65e913bdae41" + sha256: "7c859c803cf7e9a84d6db918bac824545045692bbe94a6386bd3a45132235d09" url: "https://pub.dev" source: hosted - version: "0.40.0" + version: "0.41.1" stack_trace: dependency: "direct dev" description: @@ -1084,26 +1079,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8391fbe68d520daf2314121764d38e37f934c02fd7301ad18307bd93bd6b725d" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.25.14" + version: "1.26.3" test_api: dependency: "direct dev" description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.8" + version: "0.6.12" timing: dependency: transitive description: @@ -1120,30 +1115,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unorm_dart: + dependency: "direct main" + description: + name: unorm_dart + sha256: "8e3870a1caa60bde8352f9597dd3535d8068613269444f8e35ea8925ec84c1f5" + url: "https://pub.dev" + source: hosted + version: "0.3.1+1" url_launcher: dependency: "direct main" description: name: url_launcher - sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.2" url_launcher_android: dependency: "direct main" description: name: url_launcher_android - sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" url: "https://pub.dev" source: hosted - version: "6.3.14" + version: "6.3.16" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.3" url_launcher_linux: dependency: transitive description: @@ -1172,10 +1175,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" url_launcher_windows: dependency: transitive description: @@ -1196,114 +1199,114 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" video_player: dependency: "direct main" description: name: video_player - sha256: "4a8c3492d734f7c39c2588a3206707a05ee80cef52e8c7f3b2078d430c84bc17" + sha256: "0d55b1f1a31e5ad4c4967bfaa8ade0240b07d20ee4af1dfef5f531056512961a" url: "https://pub.dev" source: hosted - version: "2.9.2" + version: "2.10.0" video_player_android: dependency: transitive description: name: video_player_android - sha256: "7018dbcb395e2bca0b9a898e73989e67c0c4a5db269528e1b036ca38bcca0d0b" + sha256: "0fabf59eea728a6a887f29f2818eafbefb4b37c727dbb62dccef56c9287a692f" url: "https://pub.dev" source: hosted - version: "2.7.17" + version: "2.8.10" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "61c54fb08fee52861d819a9b3b8e30b92456dad43a875434c677c892eb7772de" + sha256: "509ef9cfe7a3379783ccf306d45f5b5fc9db747401f956ce31c963417019e48e" url: "https://pub.dev" source: hosted - version: "2.6.6" + version: "2.8.2" video_player_platform_interface: dependency: "direct dev" description: name: video_player_platform_interface - sha256: "229d7642ccd9f3dc4aba169609dd6b5f3f443bb4cc15b82f7785fcada5af9bbb" + sha256: cf2a1d29a284db648fd66cbd18aacc157f9862d77d2cc790f6f9678a46c1db5a url: "https://pub.dev" source: hosted - version: "6.2.3" + version: "6.4.0" video_player_web: dependency: transitive description: name: video_player_web - sha256: "881b375a934d8ebf868c7fb1423b2bfaa393a0a265fa3f733079a86536064a10" + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.4.0" vm_service: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.0.2" wakelock_plus: dependency: "direct main" description: name: wakelock_plus - sha256: "36c88af0b930121941345306d259ec4cc4ecca3b151c02e3a9e71aede83c615e" + sha256: a474e314c3e8fb5adef1f9ae2d247e57467ad557fa7483a2b895bc1b421c5678 url: "https://pub.dev" source: hosted - version: "1.2.10" + version: "1.3.2" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "70e780bc99796e1db82fe764b1e7dcb89a86f1e5b3afb1db354de50f2e41eb7a" + sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207 url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.3" watcher: dependency: transitive description: name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" web: dependency: transitive description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web_socket: dependency: transitive description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" webdriver: dependency: transitive description: name: webdriver - sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "3.1.0" webkit_inspection_protocol: dependency: transitive description: @@ -1316,18 +1319,18 @@ packages: dependency: transitive description: name: win32 - sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" url: "https://pub.dev" source: hosted - version: "5.10.0" + version: "5.14.0" win32_registry: dependency: transitive description: name: win32_registry - sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" url: "https://pub.dev" source: hosted - version: "1.1.5" + version: "2.1.0" xdg_directories: dependency: transitive description: @@ -1340,10 +1343,10 @@ packages: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "3202a47961c1a0af6097c9f8c1b492d705248ba309e6f7a72410422c05046851" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.0" yaml: dependency: transitive description: @@ -1360,5 +1363,5 @@ packages: source: path version: "0.0.1" sdks: - dart: ">=3.8.0-24.0.dev <4.0.0" - flutter: ">=3.29.0-1.0.pre.105" + dart: ">=3.10.0-71.0.dev <4.0.0" + flutter: ">=3.33.0-1.0.pre-1285" diff --git a/pubspec.yaml b/pubspec.yaml index 27b7519334..d5a42bc1ca 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.25+25 +version: 30.0.264+264 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.8.0-24.0.dev <4.0.0' - flutter: '>=3.29.0-1.0.pre.105' # c1ffaa9d9deb3e0853176271922e0b1c1356d21f + sdk: '>=3.10.0-71.0.dev <4.0.0' + flutter: '>=3.33.0-1.0.pre-1285' # d6153d1d3c21f60d9aeec8b3df9991ec16dd50f3 # To update dependencies, see instructions in README.md. dependencies: @@ -35,15 +35,16 @@ dependencies: # https://github.com/dart-lang/i18n/issues/759#issuecomment-1864316701 # https://github.com/flutter/flutter/issues/117163 - app_settings: ^5.0.0 + app_settings: ^6.1.1 collection: ^1.17.2 convert: ^3.1.1 crypto: ^3.0.3 + csslib: ^1.0.2 device_info_plus: ^11.2.0 - drift: ^2.5.0 - file_picker: ^8.0.0+1 - firebase_core: ^3.3.0 - firebase_messaging: ^15.0.1 + drift: ^2.23.0 + file_picker: ^10.1.2 + firebase_core: ^4.0.0 + firebase_messaging: ^16.0.0 flutter_color_models: ^1.3.3+2 html: ^0.15.1 http: ^1.0.0 @@ -54,16 +55,17 @@ dependencies: package_info_plus: ^8.0.0 path: ^1.8.3 path_provider: ^2.0.13 - share_plus: ^10.1.3 - share_plus_platform_interface: ^5.0.2 + share_plus: ^11.0.0 + share_plus_platform_interface: ^6.0.0 sqlite3: ^2.4.0 sqlite3_flutter_libs: ^0.5.13 url_launcher: ^6.1.11 url_launcher_android: ">=6.1.0" - video_player: ^2.8.3 + video_player: ^2.10.0 wakelock_plus: ^1.2.8 zulip_plugin: path: ./packages/zulip_plugin + unorm_dart: ^0.3.1+1 # Keep list sorted when adding dependencies; it helps prevent merge conflicts. dependency_overrides: @@ -96,13 +98,12 @@ dev_dependencies: clock: ^1.1.1 drift_dev: ^2.5.2 fake_async: ^1.3.1 - flutter_checks: ^0.1.1 - flutter_lints: ^5.0.0 + flutter_checks: ^0.1.2 + flutter_lints: ^6.0.0 ini: ^2.1.0 json_serializable: ^6.5.4 legacy_checks: ^0.1.0 - pigeon: ^22.7.2 - plugin_platform_interface: ^2.1.8 + pigeon: ^26.0.0 stack_trace: ^1.11.1 test: ^1.23.1 test_api: ^0.7.3 @@ -114,6 +115,7 @@ flutter: uses-material-design: true assets: + - assets/KaTeX/LICENSE - assets/Noto_Color_Emoji/LICENSE - assets/Pygments/AUTHORS.txt - assets/Pygments/LICENSE.txt @@ -121,6 +123,74 @@ flutter: - assets/Source_Sans_3/LICENSE.md fonts: + # KaTeX's custom fonts. + - family: KaTeX_AMS + fonts: + - asset: assets/KaTeX/KaTeX_AMS-Regular.ttf + + - family: KaTeX_Caligraphic + fonts: + - asset: assets/KaTeX/KaTeX_Caligraphic-Regular.ttf + - asset: assets/KaTeX/KaTeX_Caligraphic-Bold.ttf + weight: 700 + + - family: KaTeX_Fraktur + fonts: + - asset: assets/KaTeX/KaTeX_Fraktur-Regular.ttf + - asset: assets/KaTeX/KaTeX_Fraktur-Bold.ttf + weight: 700 + + - family: KaTeX_Main + fonts: + - asset: assets/KaTeX/KaTeX_Main-Regular.ttf + - asset: assets/KaTeX/KaTeX_Main-Bold.ttf + weight: 700 + - asset: assets/KaTeX/KaTeX_Main-Italic.ttf + style: italic + - asset: assets/KaTeX/KaTeX_Main-BoldItalic.ttf + weight: 700 + style: italic + + - family: KaTeX_Math + fonts: + - asset: assets/KaTeX/KaTeX_Math-Italic.ttf + style: italic + - asset: assets/KaTeX/KaTeX_Math-BoldItalic.ttf + weight: 700 + style: italic + + - family: KaTeX_SansSerif + fonts: + - asset: assets/KaTeX/KaTeX_SansSerif-Regular.ttf + - asset: assets/KaTeX/KaTeX_SansSerif-Bold.ttf + weight: 700 + - asset: assets/KaTeX/KaTeX_SansSerif-Italic.ttf + style: italic + + - family: KaTeX_Script + fonts: + - asset: assets/KaTeX/KaTeX_Script-Regular.ttf + + - family: KaTeX_Size1 + fonts: + - asset: assets/KaTeX/KaTeX_Size1-Regular.ttf + + - family: KaTeX_Size2 + fonts: + - asset: assets/KaTeX/KaTeX_Size2-Regular.ttf + + - family: KaTeX_Size3 + fonts: + - asset: assets/KaTeX/KaTeX_Size3-Regular.ttf + + - family: KaTeX_Size4 + fonts: + - asset: assets/KaTeX/KaTeX_Size4-Regular.ttf + + - family: KaTeX_Typewriter + fonts: + - asset: assets/KaTeX/KaTeX_Typewriter-Regular.ttf + # Google's emoji font. (Web uses these emoji for the "Google" emojiset.) # # This should not be used on iOS. diff --git a/shell.nix b/shell.nix index 7404f88f6c..e286f2d4c1 100644 --- a/shell.nix +++ b/shell.nix @@ -12,7 +12,7 @@ mkShell { gtk3 # Curiously `nix-env -i` can't handle this one adequately. # But `nix-shell` on this shell.nix does fine. pcre - epoxy + libepoxy # This group all seem not strictly necessary -- commands like # `flutter run -d linux` seem to *work* fine without them, but @@ -34,9 +34,11 @@ mkShell { xorg.libXtst.out pcre2.dev - jdk11 + jdk17 android-studio android-tools + + nodejs ]; LD_LIBRARY_PATH = lib.makeLibraryPath [ diff --git a/test/api/core_test.dart b/test/api/core_test.dart index b45cdeaebf..e2c6e72ecb 100644 --- a/test/api/core_test.dart +++ b/test/api/core_test.dart @@ -460,7 +460,7 @@ void main() { }); } - const packageInfo = PackageInfo(version: '0.0.1', buildNumber: '1'); + final packageInfo = eg.packageInfo(version: '0.0.1', buildNumber: '1'); const testCases = [ ('ZulipFlutter/0.0.1+1 (Android 14)', AndroidDeviceInfo(release: '14', sdkInt: 34), ), @@ -504,7 +504,7 @@ Future tryRequest({ fromJson ??= (((Map x) => x) as T Function(Map)); return FakeApiConnection.with_((connection) { connection.prepare( - exception: exception, httpStatus: httpStatus, json: json, body: body); + httpException: exception, httpStatus: httpStatus, json: json, body: body); return connection.get(kExampleRouteName, fromJson!, 'example/route', {}); }); } diff --git a/test/api/fake_api.dart b/test/api/fake_api.dart index 5e91a55cb2..2382ab859b 100644 --- a/test/api/fake_api.dart +++ b/test/api/fake_api.dart @@ -1,9 +1,11 @@ import 'dart:collection'; import 'dart:convert'; +import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:zulip/api/core.dart'; +import 'package:zulip/api/exception.dart'; import 'package:zulip/model/store.dart'; import '../example_data.dart' as eg; @@ -79,6 +81,10 @@ class FakeHttpClient extends http.BaseClient { } } + void clearPreparedResponses() { + _preparedResponses.clear(); + } + @override Future send(http.BaseRequest request) { _requestHistory.add(request); @@ -209,30 +215,79 @@ class FakeApiConnection extends ApiConnection { List takeRequests() => client.takeRequests(); - /// Prepare the response for the next request. + /// Prepare the HTTP response for the next request. /// - /// If `exception` is null, the next request will produce an [http.Response] + /// If `httpException` and `apiException` are both null, then + /// the next request will produce an [http.Response] /// with the given `httpStatus`, defaulting to 200. The body of the response /// will be `body` if non-null, or `jsonEncode(json)` if `json` is non-null, /// or else ''. The `body` and `json` parameters must not both be non-null. /// - /// If `exception` is non-null, then `httpStatus`, `body`, and `json` must - /// all be null, and the next request will throw the given exception. + /// If `httpException` is non-null, then `apiException`, + /// `httpStatus`, `body`, and `json` must all be null, and the next request + /// will throw the given exception within the HTTP client layer, + /// causing the API request to throw a [NetworkException] + /// wrapping the given exception. /// - /// In either case, the next request will complete a duration of `delay` + /// If `apiException` is non-null, then `httpException`, + /// `httpStatus`, `body`, and `json` must all be null, and the next request + /// will throw an exception equivalent to the given exception + /// (except [ApiRequestException.routeName], which is ignored). + /// + /// In each case, the next request will complete a duration of `delay` /// after being started. void prepare({ - Object? exception, + Object? httpException, + ZulipApiException? apiException, int? httpStatus, Map? json, String? body, Duration delay = Duration.zero, }) { assert(isOpen); + + // The doc on [http.BaseClient.send] goes further than the following + // condition, suggesting that any exception thrown there should be an + // [http.ClientException]. But from the upstream implementation, in the + // actual live app, we already get TlsException and SocketException, + // without them getting wrapped in http.ClientException as that specifies. + // So naturally our tests need to simulate those too. + if (httpException is ApiRequestException) { + throw FlutterError.fromParts([ + ErrorSummary('FakeApiConnection.prepare was passed an ApiRequestException.'), + ErrorDescription( + 'The `httpException` parameter to FakeApiConnection.prepare describes ' + 'an exception for the underlying HTTP request to throw. ' + 'In the actual app, that will never be a Zulip-specific exception ' + 'like an ApiRequestException.'), + ErrorHint('Try using the `apiException` parameter instead.') + ]); + } + + if (apiException != null) { + assert(httpException == null + && httpStatus == null && json == null && body == null); + httpStatus = apiException.httpStatus; + json = { + 'result': 'error', + 'code': apiException.code, + 'msg': apiException.message, + ...apiException.data, + }; + } + client.prepare( - exception: exception, + exception: httpException, httpStatus: httpStatus, json: json, body: body, delay: delay, ); } + + void clearPreparedResponses() { + client.clearPreparedResponses(); + } +} + +extension FakeApiConnectionChecks on Subject { + Subject get isOpen => has((x) => x.isOpen, 'isOpen'); } diff --git a/test/api/fake_api_test.dart b/test/api/fake_api_test.dart index 25b4bcff2e..53dae4e870 100644 --- a/test/api/fake_api_test.dart +++ b/test/api/fake_api_test.dart @@ -5,6 +5,7 @@ import 'package:test/scaffolding.dart'; import 'package:zulip/api/exception.dart'; import '../fake_async.dart'; +import '../stdlib_checks.dart'; import 'exception_checks.dart'; import 'fake_api.dart'; @@ -25,6 +26,40 @@ void main() { ..asString.contains('FakeApiConnection.prepare')); }); + test('prepare HTTP exception -> get NetworkException', () async { + final connection = FakeApiConnection(); + final exception = Exception('oops'); + connection.prepare(httpException: exception); + await check(connection.get('aRoute', (json) => json, '/', null)) + .throws((it) => it.isA() + ..cause.identicalTo(exception)); + }); + + test('error message on prepare API exception as "HTTP exception"', () async { + final connection = FakeApiConnection(); + final exception = ZulipApiException(routeName: 'someRoute', + httpStatus: 456, code: 'SOME_ERROR', + data: {'foo': ['bar']}, message: 'Something failed'); + check(() => connection.prepare(httpException: exception)) + .throws().asString.contains('apiException'); + }); + + test('prepare API exception', () async { + final connection = FakeApiConnection(); + final exception = ZulipApiException(routeName: 'someRoute', + httpStatus: 456, code: 'SOME_ERROR', + data: {'foo': ['bar']}, message: 'Something failed'); + connection.prepare(apiException: exception); + await check(connection.get('aRoute', (json) => json, '/', null)) + .throws((it) => it.isA() + ..routeName.equals('aRoute') // actual route, not the prepared one + ..routeName.not((it) => it.equals(exception.routeName)) + ..httpStatus.equals(exception.httpStatus) + ..code.equals(exception.code) + ..data.deepEquals(exception.data) + ..message.equals(exception.message)); + }); + test('delay success', () => awaitFakeAsync((async) async { final connection = FakeApiConnection(); connection.prepare(delay: const Duration(seconds: 2), @@ -44,7 +79,7 @@ void main() { test('delay exception', () => awaitFakeAsync((async) async { final connection = FakeApiConnection(); connection.prepare(delay: const Duration(seconds: 2), - exception: Exception("oops")); + httpException: Exception("oops")); Object? error; unawaited(connection.get('aRoute', (json) => null, '/', null) diff --git a/test/api/model/events_checks.dart b/test/api/model/events_checks.dart index c1fa0a117f..d29a7acb65 100644 --- a/test/api/model/events_checks.dart +++ b/test/api/model/events_checks.dart @@ -23,7 +23,6 @@ extension RealmUserUpdateEventChecks on Subject { Subject get timezone => has((e) => e.timezone, 'timezone'); Subject get botOwnerId => has((e) => e.botOwnerId, 'botOwnerId'); Subject get role => has((e) => e.role, 'role'); - Subject get isBillingAdmin => has((e) => e.isBillingAdmin, 'isBillingAdmin'); Subject get customProfileField => has((e) => e.customProfileField, 'customProfileField'); Subject get newEmail => has((e) => e.newEmail, 'newEmail'); Subject?> get deliveryEmail => has((e) => e.deliveryEmail, 'deliveryEmail'); @@ -39,20 +38,17 @@ extension SubscriptionUpdateEventChecks on Subject { extension MessageEventChecks on Subject { Subject get message => has((e) => e.message, 'message'); + Subject get localMessageId => has((e) => e.localMessageId, 'localMessageId'); } extension UpdateMessageEventChecks on Subject { Subject get userId => has((e) => e.userId, 'userId'); - Subject get renderingOnly => has((e) => e.renderingOnly, 'renderingOnly'); + Subject get renderingOnly => has((e) => e.renderingOnly, 'renderingOnly'); Subject get messageId => has((e) => e.messageId, 'messageId'); Subject> get messageIds => has((e) => e.messageIds, 'messageIds'); Subject> get flags => has((e) => e.flags, 'flags'); - Subject get editTimestamp => has((e) => e.editTimestamp, 'editTimestamp'); - 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 editTimestamp => has((e) => e.editTimestamp, 'editTimestamp'); + Subject get moveData => has((e) => e.moveData, 'moveData'); Subject get origContent => has((e) => e.origContent, 'origContent'); Subject get origRenderedContent => has((e) => e.origRenderedContent, 'origRenderedContent'); Subject get content => has((e) => e.content, 'content'); @@ -60,6 +56,14 @@ extension UpdateMessageEventChecks on Subject { Subject get isMeMessage => has((e) => e.isMeMessage, 'isMeMessage'); } +extension UpdateMessageMoveDataChecks on Subject { + Subject get origStreamId => has((e) => e.origStreamId, 'origStreamId'); + Subject get newStreamId => has((e) => e.newStreamId, 'newStreamId'); + Subject get origTopic => has((e) => e.origTopic, 'origTopic'); + Subject get newTopic => has((e) => e.newTopic, 'newTopic'); + Subject get propagateMode => has((e) => e.propagateMode, 'propagateMode'); +} + extension DeleteMessageEventChecks on Subject { Subject get messageType => has((e) => e.messageType, 'messageType'); } diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index 51b36350cc..7cdfa94eff 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -101,24 +101,106 @@ void main() { 'edit_timestamp': 1718741351, 'stream_id': eg.stream().streamId, }; + final baseMoveJson = { ...baseJson, + 'orig_subject': 'foo', + 'propagate_mode': 'change_all', + }; + + test('smoke moveData', () { + check(Event.fromJson({ ...baseMoveJson, + 'stream_id': 1, + 'new_stream_id': 2, + 'orig_subject': 'foo', + 'subject': 'bar', + 'propagate_mode': 'change_all', + })).isA().moveData.isNotNull() + ..origStreamId.equals(1) + ..newStreamId.equals(2) + ..origTopic.equals(const TopicName('foo')) + ..newTopic.equals(const TopicName('bar')) + ..propagateMode.equals(PropagateMode.changeAll); + }); test('stream_id -> origStreamId', () { - check(Event.fromJson({ ...baseJson, + check(Event.fromJson({ ...baseMoveJson, 'stream_id': 1, 'new_stream_id': 2, - }) as UpdateMessageEvent) + })).isA().moveData.isNotNull() ..origStreamId.equals(1) ..newStreamId.equals(2); }); test('orig_subject -> origTopic, subject -> newTopic', () { - check(Event.fromJson({ ...baseJson, + check(Event.fromJson({ ...baseMoveJson, 'orig_subject': 'foo', 'subject': 'bar', - }) as UpdateMessageEvent) + })).isA().moveData.isNotNull() ..origTopic.equals(const TopicName('foo')) ..newTopic.equals(const TopicName('bar')); }); + + test('new channel, same topic: fill in newTopic', () { + // The server omits 'subject' in this situation. + check(Event.fromJson({ ...baseMoveJson, + 'stream_id': 1, + 'new_stream_id': 2, + 'orig_subject': 'foo', + 'subject': null, + })).isA().moveData.isNotNull() + ..origTopic.equals(const TopicName('foo')) + ..newTopic.equals(const TopicName('foo')); + }); + + test('same channel, new topic; fill in newStreamId', () { + // The server omits 'new_stream_id' in this situation. + check(Event.fromJson({ ...baseMoveJson, + 'stream_id': 1, + 'new_stream_id': null, + 'orig_subject': 'foo', + 'subject': 'bar', + })).isA().moveData.isNotNull() + ..origStreamId.equals(1) + ..newStreamId.equals(1); + }); + + test('no message move', () { + check(Event.fromJson({ ...baseJson, + 'orig_content': 'foo', + 'orig_rendered_content': 'foo', + 'content': 'bar', + 'rendered_content': 'bar', + })).isA().moveData.isNull(); + }); + + test('stream move but no orig_subject', () { + check(() => Event.fromJson({ ...baseMoveJson, + 'stream_id': 1, + 'new_stream_id': 2, + 'orig_subject': null, + })).throws(); + }); + + test('move but no subject or new_stream_id', () { + check(() => Event.fromJson({ ...baseMoveJson, + 'new_stream_id': null, + 'subject': null, + })).throws(); + }); + + test('move but no orig_stream_id', () { + check(() => Event.fromJson({ ...baseMoveJson, + 'stream_id': null, + 'new_stream_id': 2, + })).throws(); + }); + + test('move but no propagate_mode', () { + check(() => Event.fromJson({ ...baseMoveJson, + 'orig_subject': 'foo', + 'subject': 'bar', + 'propagate_mode': null, + })).throws(); + }); }); test('delete_message: require streamId and topic for stream messages', () { diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 8b39b1ad57..236d5b869f 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -1,6 +1,31 @@ import 'package:checks/checks.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/submessage.dart'; +import 'package:zulip/basic.dart'; + +extension UserStatusChecks on Subject { + Subject get text => has((x) => x.text, 'text'); + Subject get emoji => has((x) => x.emoji, 'emoji'); +} + +extension StatusEmojiChecks on Subject { + Subject get emojiName => has((x) => x.emojiName, 'emojiName'); + Subject get emojiCode => has((x) => x.emojiCode, 'emojiCode'); + Subject get reactionType => has((x) => x.reactionType, 'reactionType'); +} + +extension UserStatusChangeChecks on Subject { + Subject> get text => has((x) => x.text, 'text'); + Subject> get emoji => has((x) => x.emoji, 'emoji'); +} + +extension UserGroupChecks on Subject { + Subject get id => has((x) => x.id, 'id'); + Subject get name => has((x) => x.name, 'name'); + Subject get description => has((x) => x.description, 'description'); + Subject get isSystemGroup => has((x) => x.isSystemGroup, 'isSystemGroup'); + Subject get deactivated => has((x) => x.deactivated, 'deactivated'); +} extension UserChecks on Subject { Subject get userId => has((x) => x.userId, 'userId'); @@ -9,7 +34,6 @@ extension UserChecks on Subject { Subject get fullName => has((x) => x.fullName, 'fullName'); Subject get dateJoined => has((x) => x.dateJoined, 'dateJoined'); Subject get isActive => has((x) => x.isActive, 'isActive'); - Subject get isBillingAdmin => has((x) => x.isBillingAdmin, 'isBillingAdmin'); Subject get isBot => has((x) => x.isBot, 'isBot'); Subject get botType => has((x) => x.botType, 'botType'); Subject get botOwnerId => has((x) => x.botOwnerId, 'botOwnerId'); @@ -21,9 +45,38 @@ extension UserChecks on Subject { Subject get isSystemBot => has((x) => x.isSystemBot, 'isSystemBot'); } +extension SavedSnippetChecks on Subject { + Subject get id => has((x) => x.id, 'id'); + Subject get title => has((x) => x.title, 'title'); + Subject get content => has((x) => x.content, 'content'); + Subject get dateCreated => has((x) => x.dateCreated, 'dateCreated'); +} + extension ZulipStreamChecks on Subject { } +extension TopicNameChecks on Subject { + Subject get apiName => has((x) => x.apiName, 'apiName'); + Subject get displayName => has((x) => x.displayName, 'displayName'); +} + +extension StreamConversationChecks on Subject { + Subject get streamId => has((x) => x.streamId, 'streamId'); + Subject get topic => has((x) => x.topic, 'topic'); + Subject get displayRecipient => has((x) => x.displayRecipient, 'displayRecipient'); +} + +extension DmConversationChecks on Subject { + Subject> get allRecipientIds => has((x) => x.allRecipientIds, 'allRecipientIds'); +} + +extension MessageBaseChecks on Subject> { + Subject get id => has((e) => e.id, 'id'); + Subject get senderId => has((e) => e.senderId, 'senderId'); + Subject get timestamp => has((e) => e.timestamp, 'timestamp'); + Subject get conversation => has((e) => e.conversation, 'conversation'); +} + extension MessageChecks on Subject { Subject get client => has((e) => e.client, 'client'); Subject get content => has((e) => e.content, 'content'); @@ -36,24 +89,18 @@ extension MessageChecks on Subject { Subject get recipientId => has((e) => e.recipientId, 'recipientId'); Subject get senderEmail => has((e) => e.senderEmail, 'senderEmail'); 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 poll => has((e) => e.poll, 'poll'); - Subject get timestamp => has((e) => e.timestamp, 'timestamp'); Subject get type => has((e) => e.type, 'type'); Subject> get flags => has((e) => e.flags, 'flags'); Subject get matchContent => has((e) => e.matchContent, 'matchContent'); Subject get matchTopic => has((e) => e.matchTopic, 'matchTopic'); } -extension 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 streamId => has((e) => e.streamId, 'streamId'); Subject get topic => has((e) => e.topic, 'topic'); + Subject get displayRecipient => has((e) => e.displayRecipient, 'displayRecipient'); } extension ReactionsChecks on Subject { diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index 95737c173f..f04bbd6d4b 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -4,6 +4,7 @@ import 'package:checks/checks.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import '../../example_data.dart' as eg; import '../../stdlib_checks.dart'; @@ -25,6 +26,71 @@ void main() { }); }); + test('UserStatusChange', () { + void doCheck({ + required (String? statusText, String? emojiName, + String? emojiCode, String? reactionType) incoming, + required (Option text, Option emoji) expected, + }) { + check(UserStatusChange.fromJson({ + 'status_text': incoming.$1, + 'emoji_name': incoming.$2, + 'emoji_code': incoming.$3, + 'reaction_type': incoming.$4, + })) + ..text.equals(expected.$1) + ..emoji.equals(expected.$2); + } + + doCheck( + incoming: ('Busy', 'working_on_it', '1f6e0', 'unicode_emoji'), + expected: (OptionSome('Busy'), OptionSome(StatusEmoji( + emojiName: 'working_on_it', + emojiCode: '1f6e0', + reactionType: ReactionType.unicodeEmoji)))); + + doCheck( + incoming: ('', 'working_on_it', '1f6e0', 'unicode_emoji'), + expected: (OptionSome(null), OptionSome(StatusEmoji( + emojiName: 'working_on_it', + emojiCode: '1f6e0', + reactionType: ReactionType.unicodeEmoji)))); + + doCheck( + incoming: (null, 'working_on_it', '1f6e0', 'unicode_emoji'), + expected: (OptionNone(), OptionSome(StatusEmoji( + emojiName: 'working_on_it', + emojiCode: '1f6e0', + reactionType: ReactionType.unicodeEmoji)))); + + doCheck( + incoming: ('Busy', '', '', ''), + expected: (OptionSome('Busy'), OptionSome(null))); + + doCheck( + incoming: ('Busy', null, null, null), + expected: (OptionSome('Busy'), OptionNone())); + + doCheck( + incoming: ('', '', '', ''), + expected: (OptionSome(null), OptionSome(null))); + + doCheck( + incoming: (null, null, null, null), + expected: (OptionNone(), OptionNone())); + + // For the API quirk when `reaction_type` is 'unicode_emoji' when the + // emoji is cleared. + doCheck( + incoming: ('', '', '', 'unicode_emoji'), + expected: (OptionSome(null), OptionSome(null))); + + // Hardly likely to happen from the API standpoint, but we handle it anyway. + doCheck( + incoming: (null, null, null, 'unicode_emoji'), + expected: (OptionNone(), OptionNone())); + }); + group('User', () { final Map baseJson = Map.unmodifiable({ 'user_id': 123, @@ -36,7 +102,6 @@ void main() { 'is_owner': false, 'is_admin': false, 'is_guest': false, - 'is_billing_admin': false, 'is_bot': false, 'role': 400, 'timezone': 'UTC', @@ -62,7 +127,6 @@ void main() { test('is_system_bot', () { check(mkUser({}).isSystemBot).isFalse(); - check(mkUser({'is_cross_realm_bot': true}).isSystemBot).isTrue(); check(mkUser({'is_system_bot': true}).isSystemBot).isTrue(); }); }); @@ -172,9 +236,9 @@ void main() { return DmMessage.fromJson({ ...baseJson, ...specialJson }); } - Iterable asRecipients(Iterable users) { + List> asRecipients(Iterable users) { return users.map((u) => - DmRecipient(id: u.userId, email: u.email, fullName: u.fullName)); + {'id': u.userId, 'email': u.email, 'full_name': u.fullName}).toList(); } Map withRecipients(Iterable recipients) { @@ -183,7 +247,7 @@ void main() { 'sender_id': from.userId, 'sender_email': from.email, 'sender_full_name': from.fullName, - 'display_recipient': asRecipients(recipients).map((r) => r.toJson()).toList(), + 'display_recipient': asRecipients(recipients), }; } @@ -191,23 +255,6 @@ void main() { User user3 = eg.user(userId: 3); User user11 = eg.user(userId: 11); - test('displayRecipient', () { - check(parse(withRecipients([user2])).displayRecipient) - .deepEquals(asRecipients([user2])); - - check(parse(withRecipients([user2, user3])).displayRecipient) - .deepEquals(asRecipients([user2, user3])); - check(parse(withRecipients([user3, user2])).displayRecipient) - .deepEquals(asRecipients([user2, user3])); - - check(parse(withRecipients([user2, user3, user11])).displayRecipient) - .deepEquals(asRecipients([user2, user3, user11])); - check(parse(withRecipients([user3, user11, user2])).displayRecipient) - .deepEquals(asRecipients([user2, user3, user11])); - check(parse(withRecipients([user11, user2, user3])).displayRecipient) - .deepEquals(asRecipients([user2, user3, user11])); - }); - test('allRecipientIds', () { check(parse(withRecipients([user2])).allRecipientIds) .deepEquals([2]); @@ -286,16 +333,6 @@ void main() { checkEditState(MessageEditState.edited, [{'prev_content': 'old_content'}]); }); - - test("'prev_topic' present without the 'topic' field -> moved", () { - checkEditState(MessageEditState.moved, - [{'prev_topic': 'old_topic'}]); - }); - - test("'prev_subject' present from a pre-5.0 server -> moved", () { - checkEditState(MessageEditState.moved, - [{'prev_subject': 'old_topic'}]); - }); }); group('topic resolved in edit history', () { diff --git a/test/api/model/narrow_test.dart b/test/api/model/narrow_test.dart new file mode 100644 index 0000000000..42c991c6e4 --- /dev/null +++ b/test/api/model/narrow_test.dart @@ -0,0 +1,4 @@ +void main() { + // resolveApiNarrowForServer is covered in test/api/route/messages_test.dart, + // in the ApiNarrow.toJson test. +} diff --git a/test/api/model/web_auth_test.dart b/test/api/model/web_auth_test.dart index 01a670103f..7f8c326ed6 100644 --- a/test/api/model/web_auth_test.dart +++ b/test/api/model/web_auth_test.dart @@ -23,19 +23,6 @@ void main() { check(payload.decodeApiKey(otp)).equals(eg.selfAccount.apiKey); }); - // TODO(server-5) remove - test('legacy: no userId', () { - final queryParams = {...wellFormed.queryParameters}..remove('user_id'); - final payload = WebAuthPayload.parse( - wellFormed.replace(queryParameters: queryParams)); - check(payload) - ..otpEncryptedApiKey.equals(encryptedApiKey) - ..email.equals('self@example') - ..userId.isNull() - ..realm.equals(Uri.parse('https://chat.example/')); - check(payload.decodeApiKey(otp)).equals(eg.selfAccount.apiKey); - }); - test('parse fails when an expected field is missing', () { final queryParams = {...wellFormed.queryParameters}..remove('email'); final input = wellFormed.replace(queryParameters: queryParams); @@ -93,6 +80,6 @@ void main() { extension WebAuthPayloadChecks on Subject { Subject get otpEncryptedApiKey => has((x) => x.otpEncryptedApiKey, 'otpEncryptedApiKey'); Subject get email => has((x) => x.email, 'email'); - Subject get userId => has((x) => x.userId, 'userId'); + Subject get userId => has((x) => x.userId, 'userId'); Subject get realm => has((x) => x.realm, 'realm'); } diff --git a/test/api/route/channels_test.dart b/test/api/route/channels_test.dart index 011dc508c5..0a73dded59 100644 --- a/test/api/route/channels_test.dart +++ b/test/api/route/channels_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:checks/checks.dart'; import 'package:http/http.dart' as http; import 'package:flutter_test/flutter_test.dart'; @@ -8,6 +10,38 @@ import '../../stdlib_checks.dart'; import '../fake_api.dart'; void main() { + test('smoke subscribeToChannel', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: {}); + await subscribeToChannel(connection, + subscriptions: ['foo'], + principals: [1]); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/subscriptions') + ..bodyFields.deepEquals({ + 'subscriptions': jsonEncode([{'name': 'foo'}]), + 'principals': jsonEncode([1]), + }); + }); + }); + + test('smoke unsubscribeFromChannel', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: {}); + await unsubscribeFromChannel(connection, + subscriptions: ['foo'], + principals: [1]); + check(connection.takeRequests()).single.isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/users/me/subscriptions') + ..bodyFields.deepEquals({ + 'subscriptions': jsonEncode(['foo']), + 'principals': jsonEncode([1]), + }); + }); + }); + test('smoke updateUserTopic', () { return FakeApiConnection.with_((connection) async { connection.prepare(json: {}); diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index 4da4334bae..121e0ef282 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -15,124 +15,18 @@ import '../fake_api.dart'; import 'route_checks.dart'; void main() { - group('getMessageCompat', () { - Future checkGetMessageCompat(FakeApiConnection connection, { - required bool expectLegacy, - required int messageId, - bool? applyMarkdown, - }) async { - final result = await getMessageCompat(connection, - messageId: messageId, - applyMarkdown: applyMarkdown, - ); - if (expectLegacy) { - check(connection.lastRequest).isA() - ..method.equals('GET') - ..url.path.equals('/api/v1/messages') - ..url.queryParameters.deepEquals({ - 'narrow': jsonEncode([ApiNarrowMessageId(messageId)]), - 'anchor': messageId.toString(), - 'num_before': '0', - 'num_after': '0', - if (applyMarkdown != null) 'apply_markdown': applyMarkdown.toString(), - 'client_gravatar': 'true', - }); - } else { - check(connection.lastRequest).isA() - ..method.equals('GET') - ..url.path.equals('/api/v1/messages/$messageId') - ..url.queryParameters.deepEquals({ - if (applyMarkdown != null) 'apply_markdown': applyMarkdown.toString(), - }); - } - return result; - } - - test('modern; message found', () { - return FakeApiConnection.with_((connection) async { - final message = eg.streamMessage(); - final fakeResult = GetMessageResult(message: message); - connection.prepare(json: fakeResult.toJson()); - final result = await checkGetMessageCompat(connection, - expectLegacy: false, - messageId: message.id, - applyMarkdown: true, - ); - check(result).isNotNull().jsonEquals(message); - }); - }); - - test('modern; message not found', () { - return FakeApiConnection.with_((connection) async { - final message = eg.streamMessage(); - final fakeResponseJson = { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }; - connection.prepare(httpStatus: 400, json: fakeResponseJson); - final result = await checkGetMessageCompat(connection, - expectLegacy: false, - messageId: message.id, - applyMarkdown: true, - ); - check(result).isNull(); - }); - }); - - test('legacy; message found', () { - return FakeApiConnection.with_(zulipFeatureLevel: 119, (connection) async { - final message = eg.streamMessage(); - final fakeResult = GetMessagesResult( - anchor: message.id, - foundNewest: false, - foundOldest: false, - foundAnchor: true, - historyLimited: false, - messages: [message], - ); - connection.prepare(json: fakeResult.toJson()); - final result = await checkGetMessageCompat(connection, - expectLegacy: true, - messageId: message.id, - applyMarkdown: true, - ); - check(result).isNotNull().jsonEquals(message); - }); - }); - - test('legacy; message not found', () { - return FakeApiConnection.with_(zulipFeatureLevel: 119, (connection) async { - final message = eg.streamMessage(); - final fakeResult = GetMessagesResult( - anchor: message.id, - foundNewest: false, - foundOldest: false, - foundAnchor: false, - historyLimited: false, - messages: [], - ); - connection.prepare(json: fakeResult.toJson()); - final result = await checkGetMessageCompat(connection, - expectLegacy: true, - messageId: message.id, - applyMarkdown: true, - ); - check(result).isNull(); - }); - }); - }); - group('getMessage', () { Future checkGetMessage( FakeApiConnection connection, { required int messageId, bool? applyMarkdown, + required bool allowEmptyTopicName, required Map expected, }) async { final result = await getMessage(connection, messageId: messageId, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); check(connection.lastRequest).isA() ..method.equals('GET') @@ -149,7 +43,11 @@ void main() { await checkGetMessage(connection, messageId: 1, applyMarkdown: true, - expected: {'apply_markdown': 'true'}); + allowEmptyTopicName: true, + expected: { + 'apply_markdown': 'true', + 'allow_empty_topic_name': 'true', + }); }); }); @@ -159,50 +57,97 @@ void main() { await checkGetMessage(connection, messageId: 1, applyMarkdown: false, - expected: {'apply_markdown': 'false'}); + allowEmptyTopicName: true, + expected: { + 'apply_markdown': 'false', + 'allow_empty_topic_name': 'true', + }); }); }); - test('Throws assertion error when FL <120', () { - return FakeApiConnection.with_(zulipFeatureLevel: 119, (connection) async { + test('allow empty topic name', () { + return FakeApiConnection.with_((connection) async { connection.prepare(json: fakeResult.toJson()); - check(() => getMessage(connection, + await checkGetMessage(connection, messageId: 1, - )).throws(); + allowEmptyTopicName: true, + expected: {'allow_empty_topic_name': 'true'}); }); }); }); - test('Narrow.toJson', () { + test('ApiNarrow.toJson', () { return FakeApiConnection.with_((connection) async { void checkNarrow(ApiNarrow narrow, String expected) { - narrow = resolveDmElements(narrow, connection.zulipFeatureLevel!); + narrow = resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!); check(jsonEncode(narrow)).equals(expected); } + checkNarrow(const MentionsNarrow().apiEncode(), jsonEncode([ + {'operator': 'is', 'operand': 'mentioned'}, + ])); + checkNarrow(const StarredMessagesNarrow().apiEncode(), jsonEncode([ + {'operator': 'is', 'operand': 'starred'}, + ])); + checkNarrow(const CombinedFeedNarrow().apiEncode(), jsonEncode([])); checkNarrow(const ChannelNarrow(12).apiEncode(), jsonEncode([ - {'operator': 'stream', 'operand': 12}, + {'operator': 'channel', 'operand': 12}, ])); checkNarrow(eg.topicNarrow(12, 'stuff').apiEncode(), jsonEncode([ - {'operator': 'stream', 'operand': 12}, + {'operator': 'channel', 'operand': 12}, {'operator': 'topic', 'operand': 'stuff'}, ])); - checkNarrow(const MentionsNarrow().apiEncode(), jsonEncode([ - {'operator': 'is', 'operand': 'mentioned'}, + checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([ + {'operator': 'channel', 'operand': 12}, + {'operator': 'topic', 'operand': 'stuff'}, + {'operator': 'with', 'operand': 1}, ])); - checkNarrow(const StarredMessagesNarrow().apiEncode(), jsonEncode([ - {'operator': 'is', 'operand': 'starred'}, + checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([ + {'operator': 'dm', 'operand': [123, 234]}, + ])); + checkNarrow([ApiNarrowDm([123, 234]), ApiNarrowWith(1)], jsonEncode([ + {'operator': 'dm', 'operand': [123, 234]}, + {'operator': 'with', 'operand': 1}, ])); + connection.zulipFeatureLevel = 270; + checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([ + {'operator': 'channel', 'operand': 12}, + {'operator': 'topic', 'operand': 'stuff'}, + ])); checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([ {'operator': 'dm', 'operand': [123, 234]}, ])); + checkNarrow([ApiNarrowDm([123, 234]), ApiNarrowWith(1)], jsonEncode([ + {'operator': 'dm', 'operand': [123, 234]}, + ])); + + connection.zulipFeatureLevel = 249; + checkNarrow(const ChannelNarrow(12).apiEncode(), jsonEncode([ + {'operator': 'stream', 'operand': 12}, + ])); + checkNarrow(eg.topicNarrow(12, 'stuff').apiEncode(), jsonEncode([ + {'operator': 'stream', 'operand': 12}, + {'operator': 'topic', 'operand': 'stuff'}, + ])); + checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([ + {'operator': 'stream', 'operand': 12}, + {'operator': 'topic', 'operand': 'stuff'}, + ])); connection.zulipFeatureLevel = 176; + checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([ + {'operator': 'stream', 'operand': 12}, + {'operator': 'topic', 'operand': 'stuff'}, + ])); checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([ {'operator': 'pm-with', 'operand': [123, 234]}, ])); + checkNarrow([ApiNarrowDm([123, 234]), ApiNarrowWith(1)], jsonEncode([ + {'operator': 'pm-with', 'operand': [123, 234]}, + ])); + connection.zulipFeatureLevel = eg.futureZulipFeatureLevel; }); }); @@ -230,12 +175,14 @@ void main() { required int numAfter, bool? clientGravatar, bool? applyMarkdown, + required bool allowEmptyTopicName, required Map expected, }) async { final result = await getMessages(connection, narrow: narrow, anchor: anchor, includeAnchor: includeAnchor, numBefore: numBefore, numAfter: numAfter, clientGravatar: clientGravatar, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); check(connection.lastRequest).isA() ..method.equals('GET') @@ -254,21 +201,24 @@ void main() { await checkGetMessages(connection, narrow: const CombinedFeedNarrow().apiEncode(), anchor: AnchorCode.newest, numBefore: 10, numAfter: 20, + allowEmptyTopicName: true, expected: { 'narrow': jsonEncode([]), 'anchor': 'newest', 'num_before': '10', 'num_after': '20', + 'allow_empty_topic_name': 'true', }); }); }); - test('narrow uses resolveDmElements to encode', () { + test('narrow uses resolveApiNarrowForServer to encode', () { return FakeApiConnection.with_(zulipFeatureLevel: 176, (connection) async { connection.prepare(json: fakeResult.toJson()); await checkGetMessages(connection, narrow: [ApiNarrowDm([123, 234])], anchor: AnchorCode.newest, numBefore: 10, numAfter: 20, + allowEmptyTopicName: true, expected: { 'narrow': jsonEncode([ {'operator': 'pm-with', 'operand': [123, 234]}, @@ -276,6 +226,7 @@ void main() { 'anchor': 'newest', 'num_before': '10', 'num_after': '20', + 'allow_empty_topic_name': 'true', }); }); }); @@ -287,11 +238,13 @@ void main() { narrow: const CombinedFeedNarrow().apiEncode(), anchor: const NumericAnchor(42), numBefore: 10, numAfter: 20, + allowEmptyTopicName: true, expected: { 'narrow': jsonEncode([]), 'anchor': '42', 'num_before': '10', 'num_after': '20', + 'allow_empty_topic_name': 'true', }); }); }); @@ -337,8 +290,8 @@ void main() { 'to': streamId.toString(), 'topic': topic, 'content': content, - 'queue_id': '"abc:123"', - 'local_id': '"456"', + 'queue_id': 'abc:123', + 'local_id': '456', 'read_by_sender': 'true', }); }); @@ -429,6 +382,7 @@ void main() { bool? sendNotificationToOldThread, bool? sendNotificationToNewThread, String? content, + String? prevContentSha256, int? streamId, required Map expected, }) async { @@ -439,6 +393,7 @@ void main() { sendNotificationToOldThread: sendNotificationToOldThread, sendNotificationToNewThread: sendNotificationToNewThread, content: content, + prevContentSha256: prevContentSha256, streamId: streamId, ); check(connection.lastRequest).isA() @@ -448,6 +403,20 @@ void main() { return result; } + test('pure content change', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: UpdateMessageResult().toJson()); + await checkUpdateMessage(connection, + messageId: eg.streamMessage().id, + content: 'asdf', + prevContentSha256: '34a780ad578b997db55b260beb60b501f3e04d30ba1a51fcf43cd8dd1241780d', + expected: { + 'content': 'asdf', + 'prev_content_sha256': '34a780ad578b997db55b260beb60b501f3e04d30ba1a51fcf43cd8dd1241780d', + }); + }); + }); + test('topic/content change', () { // A separate test exercises `streamId`; // the API doesn't allow changing channel and content at the same time. @@ -489,7 +458,7 @@ void main() { required String? contentType, }) async { connection.prepare(json: - UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/$filename').toJson()); + UploadFileResult(url: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/$filename').toJson()); await uploadFile(connection, content: Stream.fromIterable(content), length: length, @@ -711,7 +680,7 @@ void main() { }); }); - test('narrow uses resolveDmElements to encode', () { + test('narrow uses resolveApiNarrowForServer to encode', () { return FakeApiConnection.with_(zulipFeatureLevel: 176, (connection) async { connection.prepare(json: mkResult(foundOldest: true).toJson()); await checkUpdateMessageFlagsForNarrow(connection, @@ -752,75 +721,14 @@ void main() { }); }); - group('markAllAsRead', () { - Future checkMarkAllAsRead( - FakeApiConnection connection, { - required Map expected, - }) async { - connection.prepare(json: {}); - await markAllAsRead(connection); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_all_as_read') - ..bodyFields.deepEquals(expected); - } - - test('smoke', () { - return FakeApiConnection.with_((connection) async { - await checkMarkAllAsRead(connection, expected: {}); - }); - }); - }); - - group('markStreamAsRead', () { - Future checkMarkStreamAsRead( - FakeApiConnection connection, { - required int streamId, - required Map expected, - }) async { - connection.prepare(json: {}); - await markStreamAsRead(connection, streamId: streamId); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_stream_as_read') - ..bodyFields.deepEquals(expected); - } - - test('smoke', () { - return FakeApiConnection.with_((connection) async { - await checkMarkStreamAsRead(connection, - streamId: 10, - expected: {'stream_id': '10'}); - }); - }); - }); - - group('markTopicAsRead', () { - Future checkMarkTopicAsRead( - FakeApiConnection connection, { - required int streamId, - required String topicName, - required Map expected, - }) async { - connection.prepare(json: {}); - await markTopicAsRead(connection, - streamId: streamId, topicName: eg.t(topicName)); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_topic_as_read') - ..bodyFields.deepEquals(expected); - } - - test('smoke', () { - return FakeApiConnection.with_((connection) async { - await checkMarkTopicAsRead(connection, - streamId: 10, - topicName: 'topic', - expected: { - 'stream_id': '10', - 'topic_name': 'topic', - }); - }); + test('smoke getReadReceipts', () { + return FakeApiConnection.with_((connection) async { + final response = GetReadReceiptsResult(userIds: [7, 6543, 210]); + connection.prepare(json: response.toJson()); + await getReadReceipts(connection, messageId: 123321); + check(connection.takeRequests()).single.isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/123321/read_receipts'); }); }); } diff --git a/test/api/route/realm_test.dart b/test/api/route/realm_test.dart index c1cc18b98b..5d11a9d51f 100644 --- a/test/api/route/realm_test.dart +++ b/test/api/route/realm_test.dart @@ -22,7 +22,7 @@ void main() { } final fakeResult = ServerEmojiData(codeToNames: { - '1f642': ['smile'], + '1f642': ['slight_smile'], '1f34a': ['orange', 'tangerine', 'mandarin'], }); diff --git a/test/api/route/route_checks.dart b/test/api/route/route_checks.dart index 6d310ab200..daa4628efd 100644 --- a/test/api/route/route_checks.dart +++ b/test/api/route/route_checks.dart @@ -1,8 +1,18 @@ import 'package:checks/checks.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/api/route/saved_snippets.dart'; extension SendMessageResultChecks on Subject { Subject get id => has((e) => e.id, 'id'); } +extension CreateSavedSnippetResultChecks on Subject { + Subject get savedSnippetId => has((e) => e.savedSnippetId, 'savedSnippetId'); +} + +extension GetServerSettingsResultChecks on Subject { + Subject get realmUrl => has((e) => e.realmUrl, 'realmUrl'); +} + // TODO add similar extensions for other classes in api/route/*.dart diff --git a/test/api/route/saved_snippets_test.dart b/test/api/route/saved_snippets_test.dart new file mode 100644 index 0000000000..3eeccbde8b --- /dev/null +++ b/test/api/route/saved_snippets_test.dart @@ -0,0 +1,27 @@ +import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/route/saved_snippets.dart'; + +import '../../stdlib_checks.dart'; +import '../fake_api.dart'; +import 'route_checks.dart'; + +void main() { + test('smoke', () async { + return FakeApiConnection.with_((connection) async { + connection.prepare( + json: CreateSavedSnippetResult(savedSnippetId: 123).toJson()); + final result = await createSavedSnippet(connection, + title: 'test saved snippet', content: 'content'); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/saved_snippets') + ..bodyFields.deepEquals({ + 'title': 'test saved snippet', + 'content': 'content', + }); + check(result).savedSnippetId.equals(123); + }); + }); +} diff --git a/test/api/route/settings_test.dart b/test/api/route/settings_test.dart new file mode 100644 index 0000000000..8b31caa646 --- /dev/null +++ b/test/api/route/settings_test.dart @@ -0,0 +1,53 @@ +import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/settings.dart'; + +import '../../stdlib_checks.dart'; +import '../fake_api.dart'; + +void main() { + test('smoke updateSettings', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: {}); + + final newSettings = {}; + final expectedBodyFields = {}; + for (final name in UserSettingName.values) { + switch (name) { + case UserSettingName.twentyFourHourTime: + newSettings[name] = TwentyFourHourTimeMode.twelveHour; + expectedBodyFields['twenty_four_hour_time'] = 'false'; + case UserSettingName.displayEmojiReactionUsers: + newSettings[name] = false; + expectedBodyFields['display_emoji_reaction_users'] = 'false'; + case UserSettingName.emojiset: + newSettings[name] = Emojiset.googleBlob; + expectedBodyFields['emojiset'] = 'google-blob'; + case UserSettingName.presenceEnabled: + newSettings[name] = true; + expectedBodyFields['presence_enabled'] = 'true'; + } + } + + await updateSettings(connection, newSettings: newSettings); + check(connection.takeRequests()).single.isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/settings') + ..bodyFields.deepEquals(expectedBodyFields); + }); + }); + + test('TwentyFourHourTime.localeDefault', () async { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: {}); + + // TODO(server-future) instead, check for twenty_four_hour_time: null + // (could be an error-prone part of the JSONification) + check(() => updateSettings(connection, + newSettings: {UserSettingName.twentyFourHourTime: TwentyFourHourTimeMode.localeDefault}) + ).throws(); + }); + }); +} diff --git a/test/api/route/users_test.dart b/test/api/route/users_test.dart new file mode 100644 index 0000000000..16975bbc23 --- /dev/null +++ b/test/api/route/users_test.dart @@ -0,0 +1,59 @@ +import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/users.dart'; +import 'package:zulip/basic.dart'; + +import '../../stdlib_checks.dart'; +import '../fake_api.dart'; + +void main() { + test('smoke updateStatus', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: {}); + await updateStatus(connection, change: UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/status') + ..bodyFields.deepEquals({ + 'status_text': 'Busy', + 'emoji_name': 'working_on_it', + 'emoji_code': '1f6e0', + 'reaction_type': 'unicode_emoji', + }); + }); + }); + + test('smoke updatePresence', () { + return FakeApiConnection.with_((connection) async { + final response = UpdatePresenceResult( + presenceLastUpdateId: -1, + serverTimestamp: 1656958539.6287155, + presences: {}, + ); + connection.prepare(json: response.toJson()); + await updatePresence(connection, + lastUpdateId: -1, + historyLimitDays: 21, + newUserInput: false, + pingOnly: false, + status: PresenceStatus.active, + ); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/presence') + ..bodyFields.deepEquals({ + 'last_update_id': '-1', + 'history_limit_days': '21', + 'new_user_input': 'false', + 'ping_only': 'false', + 'status': 'active', + 'slim_presence': 'true', + }); + }); + }); +} diff --git a/test/basic_test.dart b/test/basic_test.dart new file mode 100644 index 0000000000..7389ccca75 --- /dev/null +++ b/test/basic_test.dart @@ -0,0 +1,48 @@ +import 'package:checks/checks.dart'; +import 'package:test/scaffolding.dart'; +import 'package:zulip/basic.dart'; + +void main() { + group('Option', () { + test('==/hashCode', () { + void checkEqual(Object a, Object b) { + check(a).equals(b); + check(a.hashCode).equals(b.hashCode); + } + + void checkUnequal(Object a, Object b) { + check(a).not((it) => it.equals(b)); + } + + checkEqual(OptionNone(), OptionNone()); + checkEqual(OptionNone(), OptionNone()); + + checkEqual(OptionSome(3), OptionSome(3)); + checkEqual(OptionSome(null), OptionSome(null)); + checkEqual(OptionSome(OptionSome(3)), OptionSome(OptionSome(3))); + + checkUnequal(OptionNone(), OptionSome(null)); + checkUnequal(OptionSome(3), OptionSome(OptionSome(3))); + checkUnequal(3, OptionSome(3)); + }); + + test('or', () { + check(OptionSome(3).or(4)).equals(3); + check(OptionSome(3).or(4)).equals(3); + check(OptionSome(null).or(4)).equals(null); + check(OptionNone().or(4)).equals(4); + }); + + test('orElse', () { + check(OptionSome(3).orElse(() => 4)).equals(3); + check(OptionSome(3).orElse(() => 4)).equals(3); + check(OptionSome(null).orElse(() => 4)).equals(null); + check(OptionNone().orElse(() => 4)).equals(4); + + final myError = Error(); + check(OptionSome(3).orElse(() => throw myError)).equals(3); + check(() => OptionNone().orElse(() => throw myError)) + .throws().identicalTo(myError); + }); + }); +} diff --git a/test/example_data.dart b/test/example_data.dart index 6b84bf185c..110a2f0ca8 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:math'; +import 'package:zulip/api/core.dart'; +import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; @@ -8,9 +10,14 @@ import 'package:zulip/api/model/submessage.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/realm.dart'; import 'package:zulip/api/route/channels.dart'; +import 'package:zulip/model/binding.dart'; +import 'package:zulip/model/database.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; +import 'model/binding.dart'; import 'model/test_store.dart'; import 'stdlib_checks.dart'; @@ -18,11 +25,67 @@ void _checkPositive(int? value, String description) { assert(value == null || value > 0, '$description should be positive'); } +//|////////////////////////////////////////////////////////////// +// Error objects. +// + Object nullCheckError() { try { null!; } catch (e) { return e; } // ignore: null_check_always_fails } -//////////////////////////////////////////////////////////////// +/// A Zulip API error with the generic "BAD_REQUEST" error code. +/// +/// The server returns this error code for a wide range of error conditions; +/// it's the default within the server code when no more-specific code is chosen. +ZulipApiException apiBadRequest({ + String routeName = 'someRoute', String message = 'Something failed'}) { + return ZulipApiException( + routeName: routeName, + httpStatus: 400, code: 'BAD_REQUEST', + data: {}, message: message); +} + +/// The error for the "events" route when the target event queue has been +/// garbage collected. +/// +/// https://zulip.com/api/get-events#bad_event_queue_id-errors +ZulipApiException apiExceptionBadEventQueueId({ + String queueId = 'fb67bf8a-c031-47cc-84cf-ed80accacda8', +}) { + return ZulipApiException( + routeName: 'events', httpStatus: 400, code: 'BAD_EVENT_QUEUE_ID', + data: {'queue_id': queueId}, message: 'Bad event queue ID: $queueId'); +} + +/// The error the server gives when the client's credentials +/// (API key together with email and realm URL) are no longer valid. +/// +/// This isn't really documented, but comes from experiment and from +/// reading the server implementation. See: +/// https://github.com/zulip/zulip-flutter/pull/1183#discussion_r1945865983 +/// https://chat.zulip.org/#narrow/channel/378-api-design/topic/general.20handling.20HTTP.20status.20code.20401/near/2090024 +ZulipApiException apiExceptionUnauthorized({String routeName = 'someRoute'}) { + return ZulipApiException( + routeName: routeName, + httpStatus: 401, code: 'UNAUTHORIZED', + data: {}, message: 'Invalid API key'); +} + +//|////////////////////////////////////////////////////////////// +// Time values. +// + +final timeInPast = DateTime.utc(2025, 4, 1, 8, 30, 0); + +/// The UNIX timestamp, in UTC seconds. +/// +/// This is the commonly used format in the Zulip API for timestamps. +int utcTimestamp([DateTime? dateTime]) { + dateTime ??= timeInPast; + return dateTime.toUtc().millisecondsSinceEpoch ~/ 1000; +} + +//|////////////////////////////////////////////////////////////// // Realm-wide (or server-wide) metadata. // @@ -30,8 +93,9 @@ final Uri realmUrl = Uri.parse('https://chat.example/'); Uri get _realmUrl => realmUrl; const String recentZulipVersion = '9.0'; -const int recentZulipFeatureLevel = 278; +const int recentZulipFeatureLevel = 382; const int futureZulipFeatureLevel = 9999; +const int ancientZulipFeatureLevel = kMinSupportedZulipFeatureLevel - 1; GetServerSettingsResult serverSettings({ Map? authenticationMethods, @@ -67,6 +131,97 @@ GetServerSettingsResult serverSettings({ ); } +CustomProfileField customProfileField( + int id, + CustomProfileFieldType type, { + int? order, + bool? displayInProfileSummary, + String? fieldData, +}) { + return CustomProfileField( + id: id, + type: type, + order: order ?? id, + name: 'field$id', + hint: 'hint$id', + fieldData: fieldData ?? '', + displayInProfileSummary: displayInProfileSummary ?? false, + ); +} + +ServerEmojiData _immutableServerEmojiData({ + required Map> codeToNames}) { + return ServerEmojiData( + codeToNames: Map.unmodifiable(codeToNames.map( + (k, v) => MapEntry(k, List.unmodifiable(v))))); +} + +final ServerEmojiData serverEmojiDataPopular = _immutableServerEmojiData(codeToNames: { + '1f44d': ['+1', 'thumbs_up', 'like'], + '1f389': ['tada'], + '1f642': ['slight_smile'], + '2764': ['heart', 'love', 'love_you'], + '1f6e0': ['working_on_it', 'hammer_and_wrench', 'tools'], + '1f419': ['octopus'], +}); + +ServerEmojiData serverEmojiDataPopularPlus(ServerEmojiData data) { + final a = serverEmojiDataPopular; + final b = data; + final result = ServerEmojiData( + codeToNames: {...a.codeToNames, ...b.codeToNames}, + ); + assert( + result.codeToNames.length == a.codeToNames.length + b.codeToNames.length, + 'eg.serverEmojiDataPopularPlus called with data that collides with eg.serverEmojiDataPopular', + ); + return result; +} + +/// Like [serverEmojiDataPopular], but with the legacy '1f642': ['smile'] +/// instead of '1f642': ['slight_smile']; see zulip/zulip@9feba0f16f. +/// +/// zulip/zulip@9feba0f16f is a Server 11 commit. +// TODO(server-11) can drop this +final ServerEmojiData serverEmojiDataPopularLegacy = _immutableServerEmojiData(codeToNames: { + '1f44d': ['+1', 'thumbs_up', 'like'], + '1f389': ['tada'], + '1f642': ['smile'], + '2764': ['heart', 'love', 'love_you'], + '1f6e0': ['working_on_it', 'hammer_and_wrench', 'tools'], + '1f419': ['octopus'], +}); + +/// A fresh user-group ID, from a random but always strictly increasing sequence. +int _nextUserGroupId() => (_lastUserGroupId += 1 + Random().nextInt(10)); +int _lastUserGroupId = 100; + +UserGroup userGroup({ + int? id, + Iterable? members, + Iterable? directSubgroupIds, + String? name, + String? description, + bool isSystemGroup = false, + bool deactivated = false, +}) { + return UserGroup( + id: id ??= _nextUserGroupId(), + members: Set.of(members ?? []), + directSubgroupIds: Set.of(directSubgroupIds ?? []), + name: name ??= 'group-$id', + description: description ?? 'A group named $name', + isSystemGroup: isSystemGroup, + deactivated: deactivated, + ); +} + +final UserGroup nobodyGroup = userGroup( + isSystemGroup: true, + name: 'role:nobody', description: 'Nobody', + members: [], directSubgroupIds: [], +); + RealmEmojiItem realmEmojiItem({ required String emojiCode, required String emojiName, @@ -86,7 +241,7 @@ RealmEmojiItem realmEmojiItem({ ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Users and accounts. // @@ -106,7 +261,7 @@ int _lastEmailSuffix = 1000; /// other data in the test, or if the IDs need to increase in a different order /// from the calls to [user]. /// -/// If `email` is not given, it defaults to `deliveryEmail` if given, +/// If `email` is not given, it defaults to `deliveryEmail` if given and non-null, /// or else to a value resembling the Zulip server's generated fake emails. User user({ int? userId, @@ -116,6 +271,7 @@ User user({ String? dateJoined, bool? isActive, bool? isBot, + int? botOwnerId, UserRole? role, String? avatarUrl, Map? profileData, @@ -129,10 +285,9 @@ User user({ fullName: fullName ?? 'A user', // TODO generate example names dateJoined: dateJoined ?? '2024-02-24T11:18+00:00', isActive: isActive ?? true, - isBillingAdmin: false, isBot: isBot ?? false, botType: null, - botOwnerId: null, + botOwnerId: botOwnerId, role: role ?? UserRole.member, timezone: 'UTC', avatarUrl: avatarUrl, @@ -169,26 +324,105 @@ Account account({ ackedPushToken: ackedPushToken, ); } +const _account = account; -final User selfUser = user(fullName: 'Self User'); +/// A [User] which throws on attempting to mutate any of its fields. +/// +/// We use this to prevent any tests from leaking state through having a +/// [PerAccountStore] (which will be discarded when [TestZulipBinding.reset] +/// is called at the end of the test case) mutate a [User] in its [UserStore] +/// which happens to a value in this file like [selfUser] (which will not be +/// discarded by [TestZulipBinding.reset]). That was the cause of issue #1712. +class _ImmutableUser extends User { + _ImmutableUser.copyUser(User user) : super( + // When adding a field here, be sure to add the corresponding setter below. + userId: user.userId, + deliveryEmail: user.deliveryEmail, + email: user.email, + fullName: user.fullName, + dateJoined: user.dateJoined, + isActive: user.isActive, + isBot: user.isBot, + botType: user.botType, + botOwnerId: user.botOwnerId, + role: user.role, + timezone: user.timezone, + avatarUrl: user.avatarUrl, + avatarVersion: user.avatarVersion, + profileData: user.profileData == null ? null : Map.unmodifiable(user.profileData!), + isSystemBot: user.isSystemBot, + // When adding a field here, be sure to add the corresponding setter below. + ); + + static final Error _error = UnsupportedError( + 'Cannot mutate immutable User.\n' + 'When a test needs to have the store handle an event which will\n' + 'modify a user, use `eg.user()` to make a fresh User object\n' + 'instead of using a shared User object like `eg.selfUser`.'); + + // userId already immutable + @override set deliveryEmail(_) => throw _error; + @override set email(_) => throw _error; + @override set fullName(_) => throw _error; + // dateJoined already immutable + @override set isActive(_) => throw _error; + // isBot already immutable + // botType already immutable + @override set botOwnerId(_) => throw _error; + @override set role(_) => throw _error; + @override set timezone(_) => throw _error; + @override set avatarUrl(_) => throw _error; + @override set avatarVersion(_) => throw _error; + @override set profileData(_) => throw _error; + // isSystemBot already immutable +} + +final User selfUser = _ImmutableUser.copyUser(user(fullName: 'Self User')); +final User otherUser = _ImmutableUser.copyUser(user(fullName: 'Other User')); +final User thirdUser = _ImmutableUser.copyUser(user(fullName: 'Third User')); +final User fourthUser = _ImmutableUser.copyUser(user(fullName: 'Fourth User')); + +// There's no need for an [Account] analogue of [_ImmutableUser], +// because [Account] (which is generated by Drift) is already immutable. final Account selfAccount = account( id: 1001, user: selfUser, apiKey: 'dQcEJWTq3LczosDkJnRTwf31zniGvMrO', // A Zulip API key is 32 digits of base64. ); - -final User otherUser = user(fullName: 'Other User'); final Account otherAccount = account( id: 1002, user: otherUser, apiKey: '6dxT4b73BYpCTU+i4BB9LAKC5h/CufqY', // A Zulip API key is 32 digits of base64. ); +final Account thirdAccount = account( + id: 1003, + user: thirdUser, + apiKey: 'q8HdN7u5Yz3Wc9LhQv1Rb4o2sXjKf6Ut', // A Zulip API key is 32 digits of base64. +); + +//|////////////////////////////////////////////////////////////// +// Data attached to the self-account on the realm +// -final User thirdUser = user(fullName: 'Third User'); +int _nextSavedSnippetId() => _lastSavedSnippetId++; +int _lastSavedSnippetId = 1; -final User fourthUser = user(fullName: 'Fourth User'); +SavedSnippet savedSnippet({ + int? id, + String? title, + String? content, + int? dateCreated, +}) { + _checkPositive(id, 'saved snippet ID'); + return SavedSnippet( + id: id ?? _nextSavedSnippetId(), + title: title ?? 'A saved snippet', + content: content ?? 'foo bar baz', + dateCreated: dateCreated ?? 1234567890, // TODO generate timestamp + ); +} -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Streams and subscriptions. // @@ -207,6 +441,7 @@ ZulipStream stream({ int? streamId, String? name, String? description, + bool? isArchived, String? renderedDescription, int? dateCreated, int? firstMessageId, @@ -215,6 +450,10 @@ ZulipStream stream({ bool? historyPublicToSubscribers, int? messageRetentionDays, ChannelPostPolicy? channelPostPolicy, + GroupSettingValue? canAddSubscribersGroup, + GroupSettingValue? canDeleteAnyMessageGroup, + GroupSettingValue? canDeleteOwnMessageGroup, + GroupSettingValue? canSubscribeGroup, int? streamWeeklyTraffic, }) { _checkPositive(streamId, 'stream ID'); @@ -225,6 +464,7 @@ ZulipStream stream({ return ZulipStream( streamId: effectiveStreamId, name: effectiveName, + isArchived: isArchived ?? false, description: effectiveDescription, renderedDescription: renderedDescription ?? '

    $effectiveDescription

    ', dateCreated: dateCreated ?? 1686774898, @@ -234,6 +474,10 @@ ZulipStream stream({ historyPublicToSubscribers: historyPublicToSubscribers ?? true, messageRetentionDays: messageRetentionDays, channelPostPolicy: channelPostPolicy ?? ChannelPostPolicy.any, + canAddSubscribersGroup: canAddSubscribersGroup ?? GroupSettingValueNamed(nobodyGroup.id), + canDeleteAnyMessageGroup: canDeleteAnyMessageGroup ?? GroupSettingValueNamed(nobodyGroup.id), + canDeleteOwnMessageGroup: canDeleteOwnMessageGroup ?? GroupSettingValueNamed(nobodyGroup.id), + canSubscribeGroup: canSubscribeGroup ?? GroupSettingValueNamed(nobodyGroup.id), streamWeeklyTraffic: streamWeeklyTraffic, ); } @@ -263,6 +507,7 @@ Subscription subscription( return Subscription( streamId: stream.streamId, name: stream.name, + isArchived: stream.isArchived, description: stream.description, renderedDescription: stream.renderedDescription, dateCreated: stream.dateCreated, @@ -272,6 +517,10 @@ Subscription subscription( historyPublicToSubscribers: stream.historyPublicToSubscribers, messageRetentionDays: stream.messageRetentionDays, channelPostPolicy: stream.channelPostPolicy, + canAddSubscribersGroup: stream.canAddSubscribersGroup, + canDeleteAnyMessageGroup: stream.canDeleteAnyMessageGroup, + canDeleteOwnMessageGroup: stream.canDeleteOwnMessageGroup, + canSubscribeGroup: stream.canSubscribeGroup, streamWeeklyTraffic: stream.streamWeeklyTraffic, desktopNotifications: desktopNotifications ?? false, emailNotifications: emailNotifications ?? false, @@ -289,8 +538,8 @@ Subscription subscription( /// 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)); +TopicNarrow topicNarrow(int channelId, String topicName, {int? with_}) { + return TopicNarrow(channelId, TopicName(topicName), with_: with_); } UserTopicItem userTopicItem( @@ -303,25 +552,25 @@ UserTopicItem userTopicItem( ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Messages, and pieces of messages. // -Reaction unicodeEmojiReaction = Reaction( +final Reaction unicodeEmojiReaction = Reaction( emojiName: 'thumbs_up', emojiCode: '1f44d', reactionType: ReactionType.unicodeEmoji, userId: selfUser.userId, ); -Reaction realmEmojiReaction = Reaction( +final Reaction realmEmojiReaction = Reaction( emojiName: 'twocents', emojiCode: '181', reactionType: ReactionType.realmEmoji, userId: selfUser.userId, ); -Reaction zulipExtraEmojiReaction = Reaction( +final Reaction zulipExtraEmojiReaction = Reaction( emojiName: 'zulip', emojiCode: 'zulip', reactionType: ReactionType.zulipExtraEmoji, @@ -401,6 +650,8 @@ StreamMessage streamMessage({ List? reactions, int? timestamp, List? flags, + String? matchContent, + String? matchTopic, List? submessages, }) { _checkPositive(id, 'message ID'); @@ -424,6 +675,8 @@ StreamMessage streamMessage({ 'submessages': submessages ?? [], 'timestamp': timestamp ?? 1678139636, 'type': 'stream', + 'match_content': matchContent, + 'match_subject': matchTopic, }) as Map); } @@ -468,20 +721,81 @@ DmMessage dmMessage({ }) as Map); } -/// A GetMessagesResult the server might return on an `anchor=newest` request. +/// A GetMessagesResult the server might return for +/// a request that sent the given [anchor]. +/// +/// The request's anchor controls the response's [GetMessagesResult.anchor], +/// affects the default for [foundAnchor], +/// and in some cases forces the value of [foundOldest] or [foundNewest]. +GetMessagesResult getMessagesResult({ + required Anchor anchor, + bool? foundAnchor, + bool? foundOldest, + bool? foundNewest, + bool historyLimited = false, + required List messages, +}) { + final resultAnchor = switch (anchor) { + AnchorCode.oldest => 0, + NumericAnchor(:final messageId) => messageId, + AnchorCode.firstUnread => + throw ArgumentError("firstUnread not accepted in this helper; try NumericAnchor"), + AnchorCode.newest => 10_000_000_000_000_000, // that's 16 zeros + }; + + switch (anchor) { + case AnchorCode.oldest || AnchorCode.newest: + assert(foundAnchor == null); + foundAnchor = false; + case AnchorCode.firstUnread || NumericAnchor(): + foundAnchor ??= true; + } + + if (anchor == AnchorCode.oldest) { + assert(foundOldest == null); + foundOldest = true; + } else if (anchor == AnchorCode.newest) { + assert(foundNewest == null); + foundNewest = true; + } + if (foundOldest == null || foundNewest == null) throw ArgumentError(); + + return GetMessagesResult( + anchor: resultAnchor, + foundAnchor: foundAnchor, + foundOldest: foundOldest, + foundNewest: foundNewest, + historyLimited: historyLimited, + messages: messages, + ); +} + +/// A GetMessagesResult the server might return on an `anchor=newest` request, +/// or `anchor=first_unread` when there are no unreads. GetMessagesResult newestGetMessagesResult({ required bool foundOldest, bool historyLimited = false, required List messages, }) { - return GetMessagesResult( - // These anchor, foundAnchor, and foundNewest values are what the server - // appears to always return when the request had `anchor=newest`. - anchor: 10000000000000000, // that's 16 zeros - foundAnchor: false, - foundNewest: true, + return getMessagesResult(anchor: AnchorCode.newest, foundOldest: foundOldest, + historyLimited: historyLimited, messages: messages); +} +/// A GetMessagesResult the server might return on an initial request +/// when the anchor is in the middle of history (e.g., a /near/ link). +GetMessagesResult nearGetMessagesResult({ + required int anchor, + bool foundAnchor = true, + required bool foundOldest, + required bool foundNewest, + bool historyLimited = false, + required List messages, +}) { + return GetMessagesResult( + anchor: anchor, + foundAnchor: foundAnchor, foundOldest: foundOldest, + foundNewest: foundNewest, historyLimited: historyLimited, messages: messages, ); @@ -505,6 +819,63 @@ GetMessagesResult olderGetMessagesResult({ ); } +/// A GetMessagesResult the server might return when we request newer messages. +GetMessagesResult newerGetMessagesResult({ + required int anchor, + bool foundAnchor = false, // the value if the server understood includeAnchor false + required bool foundNewest, + bool historyLimited = false, + required List messages, +}) { + return GetMessagesResult( + anchor: anchor, + foundAnchor: foundAnchor, + foundOldest: false, + foundNewest: foundNewest, + historyLimited: historyLimited, + messages: messages, + ); +} + +int _nextLocalMessageId = 1; + +StreamOutboxMessage streamOutboxMessage({ + int? localMessageId, + int? selfUserId, + int? timestamp, + ZulipStream? stream, + String? topic, + String? content, +}) { + final effectiveStream = stream ?? _stream(streamId: defaultStreamMessageStreamId); + return OutboxMessage.fromConversation( + StreamConversation( + effectiveStream.streamId, TopicName(topic ?? 'topic'), + displayRecipient: null, + ), + localMessageId: localMessageId ?? _nextLocalMessageId++, + selfUserId: selfUserId ?? selfUser.userId, + timestamp: timestamp ?? utcTimestamp(), + contentMarkdown: content ?? 'content') as StreamOutboxMessage; +} + +DmOutboxMessage dmOutboxMessage({ + int? localMessageId, + required User from, + required List to, + int? timestamp, + String? content, +}) { + final allRecipientIds = + [from, ...to].map((user) => user.userId).toList()..sort(); + return OutboxMessage.fromConversation( + DmConversation(allRecipientIds: allRecipientIds), + localMessageId: localMessageId ?? _nextLocalMessageId++, + selfUserId: from.userId, + timestamp: timestamp ?? utcTimestamp(), + contentMarkdown: content ?? 'content') as DmOutboxMessage; +} + PollWidgetData pollWidgetData({ required String question, required List options, @@ -525,7 +896,7 @@ Submessage submessage({ ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Aggregate data structures. // @@ -560,7 +931,7 @@ UnreadMessagesSnapshot unreadMsgs({ } const _unreadMsgs = unreadMsgs; -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Events. // @@ -575,6 +946,14 @@ UserTopicEvent userTopicEvent( ); } +MutedUsersEvent mutedUsersEvent(List userIds) { + return MutedUsersEvent(id: 1, + mutedUsers: userIds.map((id) => MutedUserItem(id: id)).toList()); +} + +MessageEvent messageEvent(Message message, {int? localMessageId}) => + MessageEvent(id: 0, message: message, localMessageId: localMessageId?.toString()); + DeleteMessageEvent deleteMessageEvent(List messages) { assert(messages.isNotEmpty); final streamId = messages.first.streamId; @@ -593,7 +972,7 @@ DeleteMessageEvent deleteMessageEvent(List messages) { UpdateMessageEvent updateMessageEditEvent( Message origMessage, { int? userId = -1, // null means null; default is [selfUser.userId] - bool? renderingOnly = false, + bool renderingOnly = false, int? messageId, List? flags, int? editTimestamp, @@ -610,11 +989,7 @@ UpdateMessageEvent updateMessageEditEvent( messageIds: [messageId], flags: flags ?? origMessage.flags, editTimestamp: editTimestamp ?? 1234567890, // TODO generate timestamp - origStreamId: origMessage is StreamMessage ? origMessage.streamId : null, - newStreamId: null, - propagateMode: null, - origTopic: null, - newTopic: null, + moveData: null, origContent: 'some probably-mismatched old Markdown', origRenderedContent: origMessage.content, content: 'some probably-mismatched new Markdown', @@ -636,8 +1011,6 @@ UpdateMessageEvent _updateMessageMoveEvent( }) { _checkPositive(origStreamId, 'stream ID'); _checkPositive(newStreamId, 'stream ID'); - assert(newTopic != origTopic - || (newStreamId != null && newStreamId != origStreamId)); assert(messageIds.isNotEmpty); return UpdateMessageEvent( id: 0, @@ -647,11 +1020,13 @@ UpdateMessageEvent _updateMessageMoveEvent( messageIds: messageIds, flags: flags, editTimestamp: 1234567890, // TODO generate timestamp - origStreamId: origStreamId, - newStreamId: newStreamId, - propagateMode: propagateMode, - origTopic: origTopic, - newTopic: newTopic, + moveData: UpdateMessageMoveData( + origStreamId: origStreamId, + newStreamId: newStreamId ?? origStreamId, + origTopic: origTopic, + newTopic: newTopic ?? origTopic, + propagateMode: propagateMode, + ), origContent: origContent, origRenderedContent: origContent, content: newContent, @@ -807,6 +1182,9 @@ ChannelUpdateEvent channelUpdateEvent( }) { switch (property) { case ChannelPropertyName.name: + assert(value is String); + case ChannelPropertyName.isArchived: + assert(value is bool); case ChannelPropertyName.description: assert(value is String); case ChannelPropertyName.firstMessageId: @@ -817,6 +1195,11 @@ ChannelUpdateEvent channelUpdateEvent( assert(value is int?); case ChannelPropertyName.channelPostPolicy: assert(value is ChannelPostPolicy); + case ChannelPropertyName.canAddSubscribersGroup: + case ChannelPropertyName.canDeleteAnyMessageGroup: + case ChannelPropertyName.canDeleteOwnMessageGroup: + case ChannelPropertyName.canSubscribeGroup: + assert(value is GroupSettingValue); case ChannelPropertyName.streamWeeklyTraffic: assert(value is int?); } @@ -829,13 +1212,26 @@ ChannelUpdateEvent channelUpdateEvent( ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // The entire per-account or global state. // -TestGlobalStore globalStore({List accounts = const []}) { - return TestGlobalStore(accounts: accounts); +TestGlobalStore globalStore({ + GlobalSettingsData? globalSettings, + Map? boolGlobalSettings, + Map? intGlobalSettings, + List accounts = const [], +}) { + return TestGlobalStore( + globalSettings: globalSettings, + boolGlobalSettings: boolGlobalSettings, + intGlobalSettings: intGlobalSettings, + accounts: accounts, + ); } +const _globalStore = globalStore; + +const String defaultRealmEmptyTopicDisplayName = 'test general chat'; InitialSnapshot initialSnapshot({ String? queueId, @@ -845,23 +1241,38 @@ InitialSnapshot initialSnapshot({ String? zulipMergeBase, List? alertWords, List? customProfileFields, - EmailAddressVisibility? emailAddressVisibility, + int? serverPresencePingIntervalSeconds, + int? serverPresenceOfflineThresholdSeconds, int? serverTypingStartedExpiryPeriodMilliseconds, int? serverTypingStoppedWaitPeriodMilliseconds, int? serverTypingStartedWaitPeriodMilliseconds, + List? mutedUsers, + Map? presences, Map? realmEmoji, + List? realmUserGroups, List? recentPrivateConversations, + List? savedSnippets, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, List? streams, + Map? userStatuses, UserSettings? userSettings, List? userTopics, + GroupSettingValue? realmCanDeleteAnyMessageGroup, + GroupSettingValue? realmCanDeleteOwnMessageGroup, + RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy, RealmWildcardMentionPolicy? realmWildcardMentionPolicy, bool? realmMandatoryTopics, int? realmWaitingPeriodThreshold, + int? realmMessageContentDeleteLimitSeconds, + bool? realmAllowMessageEditing, + int? realmMessageContentEditLimitSeconds, + bool? realmEnableReadReceipts, + bool? realmPresenceDisabled, Map? realmDefaultExternalAccounts, int? maxFileUploadSizeMib, Uri? serverEmojiDataUrl, + String? realmEmptyTopicDisplayName, List? realmUsers, List? realmNonActiveUsers, List? crossRealmBots, @@ -874,51 +1285,93 @@ InitialSnapshot initialSnapshot({ zulipMergeBase: zulipMergeBase ?? recentZulipVersion, alertWords: alertWords ?? ['klaxon'], customProfileFields: customProfileFields ?? [], - emailAddressVisibility: emailAddressVisibility ?? EmailAddressVisibility.everyone, + serverPresencePingIntervalSeconds: serverPresencePingIntervalSeconds ?? 60, + serverPresenceOfflineThresholdSeconds: serverPresenceOfflineThresholdSeconds ?? 140, serverTypingStartedExpiryPeriodMilliseconds: serverTypingStartedExpiryPeriodMilliseconds ?? 15000, serverTypingStoppedWaitPeriodMilliseconds: serverTypingStoppedWaitPeriodMilliseconds ?? 5000, serverTypingStartedWaitPeriodMilliseconds: serverTypingStartedWaitPeriodMilliseconds ?? 10000, + mutedUsers: mutedUsers ?? [], + presences: presences ?? {}, realmEmoji: realmEmoji ?? {}, + realmUserGroups: realmUserGroups ?? [], recentPrivateConversations: recentPrivateConversations ?? [], + savedSnippets: savedSnippets ?? [], subscriptions: subscriptions ?? [], // TODO add subscriptions to default unreadMsgs: unreadMsgs ?? _unreadMsgs(), streams: streams ?? [], // TODO add streams to default + userStatuses: userStatuses ?? {}, userSettings: userSettings ?? UserSettings( - twentyFourHourTime: false, + twentyFourHourTime: TwentyFourHourTimeMode.twelveHour, displayEmojiReactionUsers: true, emojiset: Emojiset.google, + presenceEnabled: true, ), userTopics: userTopics, + // no default; allow `null` to simulate servers without this + realmCanDeleteAnyMessageGroup: realmCanDeleteAnyMessageGroup, + // no default; allow `null` to simulate servers without this + realmCanDeleteOwnMessageGroup: realmCanDeleteOwnMessageGroup, + realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy, realmWildcardMentionPolicy: realmWildcardMentionPolicy ?? RealmWildcardMentionPolicy.everyone, realmMandatoryTopics: realmMandatoryTopics ?? true, realmWaitingPeriodThreshold: realmWaitingPeriodThreshold ?? 0, + realmMessageContentDeleteLimitSeconds: realmMessageContentDeleteLimitSeconds, + realmAllowMessageEditing: realmAllowMessageEditing ?? true, + realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, + realmEnableReadReceipts: realmEnableReadReceipts ?? true, + realmPresenceDisabled: realmPresenceDisabled ?? false, realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {}, maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25, serverEmojiDataUrl: serverEmojiDataUrl ?? realmUrl.replace(path: '/static/emoji.json'), - realmUsers: realmUsers ?? [], + realmEmptyTopicDisplayName: realmEmptyTopicDisplayName ?? defaultRealmEmptyTopicDisplayName, + realmUsers: realmUsers ?? [selfUser], realmNonActiveUsers: realmNonActiveUsers ?? [], crossRealmBots: crossRealmBots ?? [], ); } const _initialSnapshot = initialSnapshot; -PerAccountStore store({Account? account, InitialSnapshot? initialSnapshot}) { - final effectiveAccount = account ?? selfAccount; +PerAccountStore store({ + GlobalStore? globalStore, + User? selfUser, + Account? account, + InitialSnapshot? initialSnapshot, +}) { + assert(!(account != null && selfUser != null)); + final effectiveAccount = account + ?? (selfUser != null ? _account(user: selfUser) : selfAccount); return PerAccountStore.fromInitialSnapshot( - globalStore: globalStore(accounts: [effectiveAccount]), + globalStore: globalStore ?? _globalStore(accounts: [effectiveAccount]), accountId: effectiveAccount.id, initialSnapshot: initialSnapshot ?? _initialSnapshot(), ); } const _store = store; -UpdateMachine updateMachine({Account? account, InitialSnapshot? initialSnapshot}) { +UpdateMachine updateMachine({ + GlobalStore? globalStore, + Account? account, + InitialSnapshot? initialSnapshot, +}) { initialSnapshot ??= _initialSnapshot(); - final store = _store(account: account, initialSnapshot: initialSnapshot); + final store = _store(globalStore: globalStore, + account: account, initialSnapshot: initialSnapshot); return UpdateMachine.fromInitialSnapshot( store: store, initialSnapshot: initialSnapshot); } + +PackageInfo packageInfo({ + String? version, + String? buildNumber, + String? packageName, +}) { + return PackageInfo( + version: version ?? '1.0.0', + buildNumber: buildNumber ?? '1', + packageName: packageName ?? 'com.example.app', + ); +} diff --git a/test/fake_async_checks.dart b/test/fake_async_checks.dart new file mode 100644 index 0000000000..51c653123a --- /dev/null +++ b/test/fake_async_checks.dart @@ -0,0 +1,6 @@ +import 'package:checks/checks.dart'; +import 'package:fake_async/fake_async.dart'; + +extension FakeTimerChecks on Subject { + Subject get duration => has((t) => t.duration, 'duration'); +} diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index 505f5189f2..562e34db2f 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -4,61 +4,134 @@ library; import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -extension PaintChecks on Subject { - Subject get shader => has((x) => x.shader, 'shader'); +//|////////////////////////////////////////////////////////////// +// From the Flutter engine, i.e. from dart:ui. +// + +extension OffsetChecks on Subject { + Subject get dx => has((x) => x.dx, 'dx'); + Subject get dy => has((x) => x.dy, 'dy'); +} + +extension SizeChecks on Subject { + Subject get width => has((x) => x.width, 'width'); + Subject get height => has((x) => x.height, 'height'); } extension RectChecks on Subject { + Subject get left => has((d) => d.left, 'left'); Subject get top => has((d) => d.top, 'top'); + Subject get right => has((d) => d.right, 'right'); Subject get bottom => has((d) => d.bottom, 'bottom'); - + Subject get width => has((d) => d.width, 'width'); + Subject get height => has((d) => d.height, 'height'); // TODO others } -extension AnimationChecks on Subject> { - Subject get status => has((d) => d.status, 'status'); - Subject get value => has((d) => d.value, 'value'); +extension PaintChecks on Subject { + Subject get shader => has((x) => x.shader, 'shader'); +} + +extension FontVariationChecks on Subject { + Subject get axis => has((x) => x.axis, 'axis'); + Subject get value => has((x) => x.value, 'value'); +} + +//|////////////////////////////////////////////////////////////// +// From 'package:flutter/foundation.dart'. +// + +extension ValueListenableChecks on Subject> { + Subject get value => has((c) => c.value, 'value'); } +//|////////////////////////////////////////////////////////////// +// From 'package:flutter/services.dart'. +// + extension ClipboardDataChecks on Subject { Subject get text => has((d) => d.text, 'text'); } -extension ColoredBoxChecks on Subject { - Subject get color => has((d) => d.color, 'color'); +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 GlobalKeyChecks> on Subject> { - Subject get currentContext => has((k) => k.currentContext, 'currentContext'); - Subject get currentWidget => has((k) => k.currentWidget, 'currentWidget'); - Subject get currentState => has((k) => k.currentState, 'currentState'); +//|////////////////////////////////////////////////////////////// +// From 'package:flutter/animation.dart'. +// + +extension AnimationChecks on Subject> { + Subject get status => has((d) => d.status, 'status'); + Subject get value => has((d) => d.value, 'value'); } -extension IconChecks on Subject { - Subject get icon => has((i) => i.icon, 'icon'); - Subject get color => has((i) => i.color, 'color'); +//|////////////////////////////////////////////////////////////// +// From 'package:flutter/painting.dart'. +// + +extension BoxDecorationChecks on Subject { + Subject get color => has((x) => x.color, 'color'); +} + +extension TextStyleChecks on Subject { + Subject get inherit => has((t) => t.inherit, 'inherit'); + Subject get color => has((t) => t.color, 'color'); + Subject get fontSize => has((t) => t.fontSize, 'fontSize'); + Subject get fontStyle => has((t) => t.fontStyle, 'fontStyle'); + Subject get fontWeight => has((t) => t.fontWeight, 'fontWeight'); + Subject get letterSpacing => has((t) => t.letterSpacing, 'letterSpacing'); + Subject?> get fontVariations => has((t) => t.fontVariations, 'fontVariations'); + Subject get fontFamily => has((t) => t.fontFamily, 'fontFamily'); + Subject?> get fontFamilyFallback => has((t) => t.fontFamilyFallback, 'fontFamilyFallback'); // TODO others } -extension RouteChecks on Subject> { - Subject get isFirst => has((r) => r.isFirst, 'isFirst'); - Subject get settings => has((r) => r.settings, 'settings'); +extension InlineSpanChecks on Subject { + Subject get style => has((x) => x.style, 'style'); } -extension PageRouteChecks on Subject> { - Subject get fullscreenDialog => has((x) => x.fullscreenDialog, 'fullscreenDialog'); +//|////////////////////////////////////////////////////////////// +// From 'package:flutter/rendering.dart'. +// + +extension RenderBoxChecks on Subject { + Subject get size => has((x) => x.size, 'size'); } -extension RouteSettingsChecks on Subject { - Subject get name => has((s) => s.name, 'name'); - Subject get arguments => has((s) => s.arguments, 'arguments'); +extension RenderParagraphChecks on Subject { + Subject get text => has((x) => x.text, 'text'); + Subject get didExceedMaxLines => has((x) => x.didExceedMaxLines, 'didExceedMaxLines'); } -extension ValueListenableChecks on Subject> { - Subject get value => has((c) => c.value, 'value'); +//|////////////////////////////////////////////////////////////// +// From 'package:flutter/widgets.dart'. +// + +extension GlobalKeyChecks> on Subject> { + Subject get currentContext => has((k) => k.currentContext, 'currentContext'); + Subject get currentWidget => has((k) => k.currentWidget, 'currentWidget'); + Subject get currentState => has((k) => k.currentState, 'currentState'); +} + +extension ElementChecks on Subject { + Subject get size => has((t) => t.size, 'size'); + // TODO more +} + +extension MediaQueryDataChecks on Subject { + Subject get textScaler => has((x) => x.textScaler, 'textScaler'); + // TODO more +} + +extension ColoredBoxChecks on Subject { + Subject get color => has((d) => d.color, 'color'); } extension TextChecks on Subject { @@ -66,38 +139,66 @@ 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'); } -extension TextFieldChecks on Subject { - Subject get textCapitalization => has((t) => t.textCapitalization, 'textCapitalization'); - Subject get decoration => has((t) => t.decoration, 'decoration'); - Subject get controller => has((t) => t.controller, 'controller'); +extension FocusNodeChecks on Subject { + Subject get hasFocus => has((t) => t.hasFocus, 'hasFocus'); } -extension TextStyleChecks on Subject { - Subject get inherit => has((t) => t.inherit, 'inherit'); - Subject get color => has((t) => t.color, 'color'); - Subject get fontSize => has((t) => t.fontSize, 'fontSize'); - Subject get fontWeight => has((t) => t.fontWeight, 'fontWeight'); - Subject get letterSpacing => has((t) => t.letterSpacing, 'letterSpacing'); - Subject?> get fontVariations => has((t) => t.fontVariations, 'fontVariations'); - Subject get fontFamily => has((t) => t.fontFamily, 'fontFamily'); - Subject?> get fontFamilyFallback => has((t) => t.fontFamilyFallback, 'fontFamilyFallback'); +extension ScrollMetricsChecks on Subject { + Subject get minScrollExtent => has((x) => x.minScrollExtent, 'minScrollExtent'); + Subject get maxScrollExtent => has((x) => x.maxScrollExtent, 'maxScrollExtent'); + Subject get pixels => has((x) => x.pixels, 'pixels'); + Subject get extentBefore => has((x) => x.extentBefore, 'extentBefore'); + Subject get extentAfter => has((x) => x.extentAfter, 'extentAfter'); +} + +extension ScrollPositionChecks on Subject { + Subject get activity => has((x) => x.activity, 'activity'); +} + +extension ScrollActivityChecks on Subject { + Subject get velocity => has((x) => x.velocity, 'velocity'); +} + +extension IconChecks on Subject { + Subject get icon => has((i) => i.icon, 'icon'); + Subject get color => has((i) => i.color, 'color'); // TODO others } -extension FontVariationChecks on Subject { - Subject get axis => has((x) => x.axis, 'axis'); - Subject get value => has((x) => x.value, 'value'); +extension TableRowChecks on Subject { + Subject get decoration => has((x) => x.decoration, 'decoration'); +} + +extension TableChecks on Subject { + Subject> get children => has((x) => x.children, 'children'); +} + +extension RouteChecks on Subject> { + Subject get isFirst => has((r) => r.isFirst, 'isFirst'); + Subject get settings => has((r) => r.settings, 'settings'); +} + +extension RouteSettingsChecks on Subject { + Subject get name => has((s) => s.name, 'name'); + Subject get arguments => has((s) => s.arguments, 'arguments'); +} + +extension PageRouteChecks on Subject> { + Subject get fullscreenDialog => has((x) => x.fullscreenDialog, 'fullscreenDialog'); +} + +//|////////////////////////////////////////////////////////////// +// From 'package:flutter/material.dart'. +// + +extension MaterialChecks on Subject { + Subject get color => has((x) => x.color, 'color'); + // TODO more } extension TextThemeChecks on Subject { @@ -126,46 +227,25 @@ extension TypographyChecks on Subject { Subject get tall => has((t) => t.tall, 'tall'); } -extension InlineSpanChecks on Subject { - Subject get style => has((x) => x.style, 'style'); -} - -extension SizeChecks on Subject { - Subject get width => has((x) => x.width, 'width'); - Subject get height => has((x) => x.height, 'height'); -} - -extension ElementChecks on Subject { - Subject get size => has((t) => t.size, 'size'); - // TODO more -} - -extension MediaQueryDataChecks on Subject { - Subject get textScaler => has((x) => x.textScaler, 'textScaler'); - // TODO more -} - -extension MaterialChecks on Subject { - Subject get color => has((x) => x.color, 'color'); - // TODO more +extension ThemeDataChecks on Subject { + Subject get brightness => has((x) => x.brightness, 'brightness'); } extension InputDecorationChecks on Subject { Subject get hintText => has((x) => x.hintText, 'hintText'); + Subject get hintStyle => has((x) => x.hintStyle, 'hintStyle'); } -extension BoxDecorationChecks on Subject { - Subject get color => has((x) => x.color, 'color'); -} - -extension TableRowChecks on Subject { - Subject get decoration => has((x) => x.decoration, 'decoration'); -} - -extension TableChecks on Subject
    { - Subject> get children => has((x) => x.children, 'children'); +extension TextFieldChecks on Subject { + Subject get textCapitalization => has((t) => t.textCapitalization, 'textCapitalization'); + Subject get decoration => has((t) => t.decoration, 'decoration'); + Subject get controller => has((t) => t.controller, 'controller'); } extension IconButtonChecks on Subject { Subject get isSelected => has((x) => x.isSelected, 'isSelected'); } + +extension SwitchListTileChecks on Subject { + Subject get value => has((x) => x.value, 'value'); +} diff --git a/test/licenses_test.dart b/test/licenses_test.dart new file mode 100644 index 0000000000..8e5cf1fe33 --- /dev/null +++ b/test/licenses_test.dart @@ -0,0 +1,14 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/licenses.dart'; + +import 'fake_async.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('smoke: ensure all additional licenses load', () => awaitFakeAsync((async) async { + await check(additionalLicenses().toList()) + .completes((it) => it.isNotEmpty()); + })); +} diff --git a/test/model/actions_test.dart b/test/model/actions_test.dart new file mode 100644 index 0000000000..e7ba1339dd --- /dev/null +++ b/test/model/actions_test.dart @@ -0,0 +1,218 @@ +import 'dart:io'; + +import 'package:checks/checks.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:zulip/model/actions.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/notifications/receive.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../fake_async.dart'; +import '../model/binding.dart'; +import '../model/store_checks.dart'; +import '../model/test_store.dart'; +import '../notifications/display_test.dart'; +import '../stdlib_checks.dart'; +import '../test_images.dart'; +import 'store_test.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + late PerAccountStore store; + late FakeApiConnection connection; + + http.Client makeFakeHttpClient({http.Response? response, Exception? exception}) { + return http_testing.MockClient((request) async { + assert((response != null) ^ (exception != null)); + if (exception != null) throw exception; + return response!; // TODO return 404 on non avatar urls + }); + } + + final fakeHttpClientGivingSuccess = makeFakeHttpClient( + response: http.Response.bytes(kSolidBlueAvatar, HttpStatus.ok)); + + T runWithHttpClient( + T Function() callback, { + http.Client Function()? httpClientFactory, + }) { + return http.runWithClient(callback, httpClientFactory ?? () => fakeHttpClientGivingSuccess); + } + + Future prepare({String? ackedPushToken = '123'}) async { + addTearDown(testBinding.reset); + final selfAccount = eg.selfAccount.copyWith(ackedPushToken: Value(ackedPushToken)); + await testBinding.globalStore.add(selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(selfAccount.id); + connection = store.connection as FakeApiConnection; + } + + /// Creates and caches a new [FakeApiConnection] in [TestGlobalStore]. + /// + /// In live code, [unregisterToken] makes a new [ApiConnection] for the + /// unregister-token request instead of reusing the store's connection. + /// To enable callers to prepare responses for that request, this function + /// creates a new [FakeApiConnection] and caches it in [TestGlobalStore] + /// for [unregisterToken] to pick up. + /// + /// Call this instead of just turning on + /// [TestGlobalStore.useCachedApiConnections] so that [unregisterToken] + /// doesn't try to call `close` twice on the same connection instance, + /// which isn't allowed. (Once by the unregister-token code + /// and once as part of removing the account.) + FakeApiConnection separateConnection() { + testBinding.globalStore + ..clearCachedApiConnections() + ..useCachedApiConnections = true; + return testBinding.globalStore + .apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + } + + String unregisterApiPathForPlatform(TargetPlatform platform) { + return switch (platform) { + TargetPlatform.android => '/api/v1/users/me/android_gcm_reg_id', + TargetPlatform.iOS => '/api/v1/users/me/apns_device_token', + _ => throw Error(), + }; + } + + void checkSingleUnregisterRequest( + FakeApiConnection connection, { + String? expectedToken, + }) { + final subject = check(connection.takeRequests()).single.isA() + ..method.equals('DELETE') + ..url.path.equals(unregisterApiPathForPlatform(defaultTargetPlatform)); + if (expectedToken != null) { + subject.bodyFields.deepEquals({'token': expectedToken}); + } + } + + group('logOutAccount', () { + test('smoke', () => awaitFakeAsync((async) async { + await prepare(); + check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id); + const unregisterDelay = Duration(seconds: 5); + assert(unregisterDelay > TestGlobalStore.removeAccountDuration); + final newConnection = separateConnection() + ..prepare(delay: unregisterDelay, json: {'msg': '', 'result': 'success'}); + + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); + // Unregister-token request and account removal dispatched together + checkSingleUnregisterRequest(newConnection); + check(testBinding.globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + + async.elapse(TestGlobalStore.removeAccountDuration); + await future; + // Account removal not blocked on unregister-token response + check(testBinding.globalStore).accountIds.isEmpty(); + check(connection.isOpen).isFalse(); + check(newConnection.isOpen).isTrue(); // still busy with unregister-token + + async.elapse(unregisterDelay - TestGlobalStore.removeAccountDuration); + check(newConnection.isOpen).isFalse(); + })); + + test('unregister request has an error', () => awaitFakeAsync((async) async { + await prepare(); + check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id); + const unregisterDelay = Duration(seconds: 5); + assert(unregisterDelay > TestGlobalStore.removeAccountDuration); + final exception = eg.apiExceptionUnauthorized(routeName: 'removeEtcEtcToken'); + final newConnection = separateConnection() + ..prepare(delay: unregisterDelay, apiException: exception); + + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); + // Unregister-token request and account removal dispatched together + checkSingleUnregisterRequest(newConnection); + check(testBinding.globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + + async.elapse(TestGlobalStore.removeAccountDuration); + await future; + // Account removal not blocked on unregister-token response + check(testBinding.globalStore).accountIds.isEmpty(); + check(connection.isOpen).isFalse(); + check(newConnection.isOpen).isTrue(); // for the unregister-token request + + async.elapse(unregisterDelay - TestGlobalStore.removeAccountDuration); + check(newConnection.isOpen).isFalse(); + })); + + test('notifications are removed after logout', () => awaitFakeAsync((async) async { + await prepare(); + testBinding.firebaseMessagingInitialToken = '123'; + addTearDown(NotificationService.debugReset); + NotificationService.debugBackgroundIsolateIsLive = false; + await runWithHttpClient(NotificationService.instance.start); + + // Create a notification to check that it's removed after logout + final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); + testBinding.firebaseMessaging.onMessage.add( + RemoteMessage(data: messageFcmMessage(message).toJson())); + async.flushMicrotasks(); + check(testBinding.androidNotificationHost.activeNotifications).isNotEmpty(); + + await logOutAccount(testBinding.globalStore, eg.selfAccount.id); + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + })); + }); + + group('unregisterToken', () { + testAndroidIos('smoke, happy path', () => awaitFakeAsync((async) async { + await prepare(ackedPushToken: '123'); + + final newConnection = separateConnection() + ..prepare(json: {'msg': '', 'result': 'success'}); + final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); + async.elapse(Duration.zero); + await future; + checkSingleUnregisterRequest(newConnection, expectedToken: '123'); + check(newConnection.isOpen).isFalse(); + })); + + test('fallback to current token if acked is missing', () => awaitFakeAsync((async) async { + await prepare(ackedPushToken: null); + NotificationService.instance.token = ValueNotifier('asdf'); + + final newConnection = separateConnection() + ..prepare(json: {'msg': '', 'result': 'success'}); + final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); + async.elapse(Duration.zero); + await future; + checkSingleUnregisterRequest(newConnection, expectedToken: 'asdf'); + check(newConnection.isOpen).isFalse(); + })); + + test('no error if acked token and current token both missing', () => awaitFakeAsync((async) async { + await prepare(ackedPushToken: null); + NotificationService.instance.token = ValueNotifier(null); + + final newConnection = separateConnection(); + final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); + async.flushTimers(); + await future; + check(newConnection.takeRequests()).isEmpty(); + })); + + test('connection closed if request errors', () => awaitFakeAsync((async) async { + await prepare(ackedPushToken: '123'); + + final exception = eg.apiExceptionUnauthorized(routeName: 'removeEtcEtcToken'); + final newConnection = separateConnection() + ..prepare(apiException: exception); + final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); + async.elapse(Duration.zero); + await future; + checkSingleUnregisterRequest(newConnection, expectedToken: '123'); + check(newConnection.isOpen).isFalse(); + })); + }); +} diff --git a/test/model/autocomplete_checks.dart b/test/model/autocomplete_checks.dart index ec8acbe500..cb08378e77 100644 --- a/test/model/autocomplete_checks.dart +++ b/test/model/autocomplete_checks.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/autocomplete.dart'; +import 'package:zulip/model/compose.dart'; import 'package:zulip/widgets/compose_box.dart'; extension ComposeContentControllerChecks on Subject { @@ -20,6 +21,14 @@ extension UserMentionAutocompleteResultChecks on Subject get userId => has((r) => r.userId, 'userId'); } +extension WildcardMentionAutocompleteResultChecks on Subject { + Subject get wildcardOption => has((x) => x.wildcardOption, 'wildcardOption'); +} + +extension UserGroupMentionAutocompleteResultChecks on Subject { + Subject get groupId => has((r) => r.groupId, 'groupId'); +} + extension TopicAutocompleteResultChecks on Subject { Subject get topic => has((r) => r.topic, 'topic'); } diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index da05030493..a560512553 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:checks/checks.dart'; import 'package:flutter/widgets.dart'; +import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; @@ -19,15 +20,13 @@ import 'package:zulip/widgets/compose_box.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; +import '../stdlib_checks.dart'; import 'test_store.dart'; import 'autocomplete_checks.dart'; 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) { @@ -318,21 +317,39 @@ void main() { for (int i = 1; i <= 2500; i++) { await store.addUser(eg.user(userId: i, email: 'user$i@example.com', fullName: 'User $i')); } + for (int i = 1; i <= 2500; i++) { + await store.addUserGroup(eg.userGroup(id: i, name: 'User Group $i')); + } bool done = false; final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations, narrow: narrow, query: MentionAutocompleteQuery('User 2222')); view.addListener(() { done = true; }); + // three batches for users + await Future(() {}); + check(done).isFalse(); + await Future(() {}); + check(done).isFalse(); + await Future(() {}); + check(done).isFalse(); + + // three batches for user groups await Future(() {}); check(done).isFalse(); await Future(() {}); check(done).isFalse(); await Future(() {}); check(done).isTrue(); - check(view.results).single - .isA() - .userId.equals(2222); + + check(view.results).deepEquals(>[ + (it) => it + .isA() + .userId.equals(2222), + (it) => it + .isA() + .groupId.equals(2222), + ]); }); test('MentionAutocompleteView new query during computation replaces old', () async { @@ -341,6 +358,9 @@ void main() { for (int i = 1; i <= 1500; i++) { await store.addUser(eg.user(userId: i, email: 'user$i@example.com', fullName: 'User $i')); } + for (int i = 1; i <= 1500; i++) { + await store.addUserGroup(eg.userGroup(id: i, name: 'User Group $i')); + } bool done = false; final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations, @@ -351,25 +371,37 @@ void main() { check(done).isFalse(); view.query = MentionAutocompleteQuery('User 234'); - // …new query goes through all batches + // …new query goes through all user batches + await Future(() {}); + check(done).isFalse(); + await Future(() {}); + check(done).isFalse(); + // …and all user-group batches await Future(() {}); check(done).isFalse(); await Future(() {}); check(done).isTrue(); // new result is set - check(view.results).single - .isA() - .userId.equals(234); + + void checkResult() { + check(view.results).deepEquals(>[ + (it) => it + .isA() + .userId.equals(234), + (it) => it + .isA() + .groupId.equals(234), + ]); + } + checkResult(); // new result sticks; it isn't clobbered with old query's result for (int i = 0; i < 10; i++) { // for good measure await Future(() {}); - check(view.results).single - .isA() - .userId.equals(234); + checkResult(); } }); - test('MentionAutocompleteView mutating store.users while in progress does not ' + test('MentionAutocompleteView mutating user store while in progress does not ' 'prevent query from finishing', () async { const narrow = ChannelNarrow(1); final store = eg.store(); @@ -401,62 +433,24 @@ void main() { ..not((results) => results.contains(11000)); }); - group('MentionAutocompleteQuery.testUser', () { - void doCheck(String rawQuery, User user, bool expected) { - final result = MentionAutocompleteQuery(rawQuery) - .testUser(user, AutocompleteDataCache()); - expected ? check(result).isTrue() : check(result).isFalse(); - } - - test('user is always excluded when not active regardless of other criteria', () { - doCheck('Full Name', eg.user(fullName: 'Full Name', isActive: false), false); - // When active then other criteria will be checked - doCheck('Full Name', eg.user(fullName: 'Full Name', isActive: true), true); - }); - - test('user is included if fullname words match the query', () { - doCheck('', eg.user(fullName: 'Full Name'), true); - doCheck('', eg.user(fullName: ''), true); // Unlikely case, but should not crash - doCheck('Full Name', eg.user(fullName: 'Full Name'), true); - doCheck('full name', eg.user(fullName: 'Full Name'), true); - doCheck('Full Name', eg.user(fullName: 'full name'), true); - doCheck('Full', eg.user(fullName: 'Full Name'), true); - doCheck('Name', eg.user(fullName: 'Full Name'), true); - doCheck('Full Name', eg.user(fullName: 'Fully Named'), true); - doCheck('Full Four', eg.user(fullName: 'Full Name Four Words'), true); - doCheck('Name Words', eg.user(fullName: 'Full Name Four Words'), true); - doCheck('Full F', eg.user(fullName: 'Full Name Four Words'), true); - doCheck('F Four', eg.user(fullName: 'Full Name Four Words'), true); - doCheck('full full', eg.user(fullName: 'Full Full Name'), true); - doCheck('full full', eg.user(fullName: 'Full Name Full'), true); - - doCheck('F', eg.user(fullName: ''), false); // Unlikely case, but should not crash - doCheck('Fully Named', eg.user(fullName: 'Full Name'), false); - doCheck('Full Name', eg.user(fullName: 'Full'), false); - doCheck('Full Name', eg.user(fullName: 'Name'), false); - doCheck('ull ame', eg.user(fullName: 'Full Name'), false); - doCheck('ull Name', eg.user(fullName: 'Full Name'), false); - doCheck('Full ame', eg.user(fullName: 'Full Name'), false); - doCheck('Full Full', eg.user(fullName: 'Full Name'), false); - doCheck('Name Name', eg.user(fullName: 'Full Name'), false); - doCheck('Name Full', eg.user(fullName: 'Full Name'), false); - doCheck('Name Four Full Words', eg.user(fullName: 'Full Name Four Words'), false); - doCheck('F Full', eg.user(fullName: 'Full Name Four Words'), false); - doCheck('Four F', eg.user(fullName: 'Full Name Four Words'), false); - }); - }); - - group('MentionAutocompleteView sorting users results', () { + group('MentionAutocompleteView sorting results', () { late PerAccountStore store; Future prepare({ + User? selfUser, List users = const [], + List userGroups = const [], List dmConversations = const [], List messages = const [], }) async { - store = eg.store(initialSnapshot: eg.initialSnapshot( + selfUser ??= eg.selfUser; + if (!users.contains(selfUser)) { + users = [...users, selfUser]; + } + store = eg.store(selfUser: selfUser, initialSnapshot: eg.initialSnapshot( + realmUsers: users, recentPrivateConversations: dmConversations)); - await store.addUsers(users); + await store.addUserGroups(userGroups); await store.addMessages(messages); } @@ -789,18 +783,28 @@ void main() { final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations, narrow: narrow, query: query); view.addListener(() { done = true; }); - await Future(() {}); + await Future(() {}); // users + await Future(() {}); // groups check(done).isTrue(); final results = view.results; view.dispose(); return results; } - Iterable getUsersFromResults(Iterable results) - => results.map((e) => (e as UserMentionAutocompleteResult).userId); + Condition isUser(int userId) { + return (it) => it.isA() + .userId.equals(userId); + } - Iterable getWildcardOptionsFromResults(Iterable results) - => results.map((e) => (e as WildcardMentionAutocompleteResult).wildcardOption); + Condition isUserGroup(int id) { + return (it) => it.isA() + .groupId.equals(id); + } + + Condition isWildcard(WildcardMentionOption option) { + return (it) => it.isA() + .wildcardOption.equals(option); + } final stream = eg.stream(); const topic = 'topic'; @@ -815,37 +819,51 @@ void main() { eg.user(userId: 6, fullName: 'User Six', isBot: true), eg.user(userId: 7, fullName: 'User Seven'), ]; + final selfUser = users.last; - await prepare(users: users, messages: [ - eg.streamMessage(id: 50, sender: users[1-1], stream: stream, topic: topic), - eg.streamMessage(id: 60, sender: users[5-1], stream: stream, topic: 'other $topic'), - ], dmConversations: [ - RecentDmConversation(userIds: [4], maxMessageId: 300), - RecentDmConversation(userIds: [1], maxMessageId: 200), - RecentDmConversation(userIds: [1, 2], maxMessageId: 100), - ]); + final userGroups = [ + eg.userGroup(id: 1, name: 'User Group One'), + eg.userGroup(id: 2, name: 'User Group Two'), + eg.userGroup(id: 3, name: 'User Group Three'), + eg.userGroup(id: 4, name: 'User Group Four'), + ]; + + await prepare(users: users, selfUser: selfUser, userGroups: userGroups, + messages: [ + eg.streamMessage(sender: users[1-1], stream: stream, topic: topic), + eg.streamMessage(sender: users[5-1], stream: stream, topic: 'other $topic'), + eg.dmMessage(from: users[1-1], to: [users[2-1], selfUser]), + eg.dmMessage(from: users[1-1], to: [selfUser]), + eg.dmMessage(from: users[4-1], to: [selfUser]), + ]); - // Check the ranking of the full list of mentions. + // Check the ranking of the full list of mentions, + // i.e. the results for an empty query. // The order should be: - // 1. Wildcards before individual users. + // 1. Wildcards before individual users; user groups (alphabetically) after. // 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]); + // 6. User groups by name alphabetical order. + check(await getResults(topicNarrow, MentionAutocompleteQuery(''))).deepEquals([ + isWildcard(WildcardMentionOption.all), + isWildcard(WildcardMentionOption.topic), + ...[1, 5, 4, 2, 7, 3, 6].map(isUser), + ...[4, 1, 3, 2].map(isUserGroup), + ]); // Check the ranking applies also to results filtered by a query. - 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]); + check(await getResults(topicNarrow, MentionAutocompleteQuery('t'))).deepEquals([ + isWildcard(WildcardMentionOption.stream), + isWildcard(WildcardMentionOption.topic), + isUser(2), isUser(3), // 2 before 3 by DM recency + isUserGroup(3), isUserGroup(2), // 3 before 2 by alphabet ("…Three" before "…Two") + ]); + check(await getResults(topicNarrow, MentionAutocompleteQuery('f'))).deepEquals([ + isUser(5), isUser(4), + isUserGroup(4), + ]); }); }); @@ -901,26 +919,49 @@ void main() { }); } - final localizedTestCases = [ - ('ال', channelNarrow, [WildcardMentionOption.all, WildcardMentionOption.topic]), - ('الجميع', topicNarrow, [WildcardMentionOption.all]), - ('الموضوع', channelNarrow, [WildcardMentionOption.topic]), - ('ق', topicNarrow, [WildcardMentionOption.channel]), - ('دفق', channelNarrow, [WildcardMentionOption.stream]), - ('الكل', dmNarrow, [WildcardMentionOption.everyone]), + WildcardTester wildcardTesterForLocale(bool Function(Locale) localePredicate) { + final locale = ZulipLocalizations.supportedLocales.firstWhere(localePredicate); + final localizations = lookupZulipLocalizations(locale); - ('top', channelNarrow, [WildcardMentionOption.topic]), - ('channel', topicNarrow, [WildcardMentionOption.channel]), - ('every', dmNarrow, [WildcardMentionOption.everyone]), - ]; + return (String query, Narrow narrow, List expected) { + test('locale "$locale" -> query "$query" in ${narrow.runtimeType} -> $expected', () { + check(getWildcardOptionsFor(query, narrow: narrow, + localizations: localizations)).deepEquals(expected); + }); + }; + } - 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); - }); + for (final option in WildcardMentionOption.values) { + // These are hard-coded, and they happened to be lowercase and without + // diacritics when written. + // Throw if that changes, to not accidentally break fuzzy matching. + check(option.canonicalString).equals( + AutocompleteQuery.lowercaseAndStripDiacritics(option.canonicalString)); } + final testArabic = wildcardTesterForLocale((locale) => locale.languageCode == 'ar'); + testArabic('ال', channelNarrow, [WildcardMentionOption.all, WildcardMentionOption.topic]); + testArabic('الجميع', topicNarrow, [WildcardMentionOption.all]); + testArabic('الموضوع', channelNarrow, [WildcardMentionOption.topic]); + testArabic('ق', topicNarrow, [WildcardMentionOption.channel]); + testArabic('دفق', channelNarrow, [WildcardMentionOption.stream]); + testArabic('الكل', dmNarrow, [WildcardMentionOption.everyone]); + testArabic('top', channelNarrow, [WildcardMentionOption.topic]); + testArabic('channel', topicNarrow, [WildcardMentionOption.channel]); + testArabic('every', dmNarrow, [WildcardMentionOption.everyone]); + + final testEnglish = wildcardTesterForLocale((locale) => locale.languageCode == 'en'); + testEnglish('topic', topicNarrow, [WildcardMentionOption.topic]); + testEnglish('Topic', topicNarrow, [WildcardMentionOption.topic]); + + final testGerman = wildcardTesterForLocale((locale) => locale.languageCode == 'de'); + testGerman('Thema', topicNarrow, [WildcardMentionOption.topic]); + testGerman('thema', topicNarrow, [WildcardMentionOption.topic]); + + final testPolish = wildcardTesterForLocale((locale) => locale.languageCode == 'pl'); + testPolish('wątek', topicNarrow, [WildcardMentionOption.topic]); + testPolish('watek', topicNarrow, [WildcardMentionOption.topic]); + test('no wildcards for a silent mention', () { check(getWildcardOptionsFor('', isSilent: true, narrow: channelNarrow)) .isEmpty(); @@ -955,6 +996,216 @@ void main() { }); }); + group('MentionAutocompleteQuery.testUser', () { + late PerAccountStore store; + + void doCheck(String rawQuery, User user, bool expected) { + final result = MentionAutocompleteQuery(rawQuery).testUser(user, store); + expected + ? check(result).isA() + : check(result).isNull(); + } + + test('user is always excluded when not active regardless of other criteria', () { + store = eg.store(); + + doCheck('Full Name', eg.user(fullName: 'Full Name', isActive: false), false); + // When active then other criteria will be checked + doCheck('Full Name', eg.user(fullName: 'Full Name', isActive: true), true); + }); + + test('user is always excluded when muted, regardless of other criteria', () async { + store = eg.store(); + await store.setMutedUsers([1]); + doCheck('Full Name', eg.user(userId: 1, fullName: 'Full Name'), false); + // When not muted, then other criteria will be checked + doCheck('Full Name', eg.user(userId: 2, fullName: 'Full Name'), true); + }); + + test('user is included if fullname words match the query', () { + store = eg.store(); + + doCheck('', eg.user(fullName: 'Full Name'), true); + doCheck('', eg.user(fullName: ''), true); // Unlikely case, but should not crash + doCheck('Full Name', eg.user(fullName: 'Full Name'), true); + doCheck('full name', eg.user(fullName: 'Full Name'), true); + doCheck('Full Name', eg.user(fullName: 'full name'), true); + doCheck('Full', eg.user(fullName: 'Full Name'), true); + doCheck('Name', eg.user(fullName: 'Full Name'), true); + doCheck('Full Name', eg.user(fullName: 'Fully Named'), true); + doCheck('Full Four', eg.user(fullName: 'Full Name Four Words'), true); + doCheck('Name Words', eg.user(fullName: 'Full Name Four Words'), true); + doCheck('Full F', eg.user(fullName: 'Full Name Four Words'), true); + doCheck('F Four', eg.user(fullName: 'Full Name Four Words'), true); + doCheck('full full', eg.user(fullName: 'Full Full Name'), true); + doCheck('full full', eg.user(fullName: 'Full Name Full'), true); + + doCheck('F', eg.user(fullName: ''), false); // Unlikely case, but should not crash + doCheck('Fully Named', eg.user(fullName: 'Full Name'), false); + doCheck('Full Name', eg.user(fullName: 'Full'), false); + doCheck('Full Name', eg.user(fullName: 'Name'), false); + doCheck('ull ame', eg.user(fullName: 'Full Name'), false); + doCheck('ull Name', eg.user(fullName: 'Full Name'), false); + doCheck('Full ame', eg.user(fullName: 'Full Name'), false); + doCheck('Full Full', eg.user(fullName: 'Full Name'), false); + doCheck('Name Name', eg.user(fullName: 'Full Name'), false); + doCheck('Name Full', eg.user(fullName: 'Full Name'), false); + doCheck('Name Four Full Words', eg.user(fullName: 'Full Name Four Words'), false); + doCheck('F Full', eg.user(fullName: 'Full Name Four Words'), false); + doCheck('Four F', eg.user(fullName: 'Full Name Four Words'), false); + }); + }); + + group('MentionAutocompleteQuery ranking', () { + // This gets filled lazily, but never reset. + // We're counting on this group's tests never doing anything to mutate it. + PerAccountStore? store; + + int? rankOf(String queryStr, Object candidate) { + final query = MentionAutocompleteQuery(queryStr); + final result = switch (candidate) { + WildcardMentionOption() => query.testWildcardOption(candidate, + localizations: GlobalLocalizations.zulipLocalizations), + User() => query.testUser(candidate, (store ??= eg.store())), + UserGroup() => query.testUserGroup(candidate, (store ??= eg.store())), + _ => throw StateError('invalid candidate'), + }; + return result?.rank; + } + + void checkPrecedes(String query, Object a, Object b) { + check(rankOf(query, a)!).isLessThan(rankOf(query, b)!); + } + + void checkSameRank(String query, Object a, Object b) { + check(rankOf(query, a)!).equals(rankOf(query, b)!); + } + + void checkAllSameRank(String query, Iterable candidates) { + // (i.e. throw here if it's not a match) + final firstCandidateRank = rankOf(query, candidates.first)!; + + final ranks = candidates.skip(1).map((candidate) => rankOf(query, candidate)); + check(ranks).every((it) => it.equals(firstCandidateRank)); + } + + test('wildcards, then users', () { + checkSameRank('', WildcardMentionOption.all, WildcardMentionOption.topic); + checkPrecedes('', WildcardMentionOption.topic, eg.user()); + checkSameRank('', eg.user(), eg.user()); + }); + + test('wildcard-vs-user more significant than match quality', () { + // Make the query an exact match for the user's name. + final user = eg.user(fullName: 'Ann'); + checkPrecedes(user.fullName, WildcardMentionOption.channel, user); + }); + + test('user name match is case- and diacritics-insensitive', () { + final users = [ + eg.user(fullName: 'Édith Piaf'), + eg.user(fullName: 'édith piaf'), + eg.user(fullName: 'Edith Piaf'), + eg.user(fullName: 'edith piaf'), + ]; + + checkAllSameRank('Édith Piaf', users); // exact + checkAllSameRank('Edith Piaf', users); // exact + checkAllSameRank('édith piaf', users); // exact + checkAllSameRank('edith piaf', users); // exact + + checkAllSameRank('Édith Pi', users); // total-prefix + checkAllSameRank('Edith Pi', users); // total-prefix + checkAllSameRank('édith pi', users); // total-prefix + checkAllSameRank('edith pi', users); // total-prefix + + checkAllSameRank('Éd Pi', users); // word-prefixes + checkAllSameRank('Ed Pi', users); // word-prefixes + checkAllSameRank('éd pi', users); // word-prefixes + checkAllSameRank('ed pi', users); // word-prefixes + }); + + test('user name match: exact over total-prefix', () { + final user1 = eg.user(fullName: 'Chris'); + final user2 = eg.user(fullName: 'Chris Bobbe'); + + checkPrecedes('chris', user1, user2); + }); + + test('user name match: total-prefix over word-prefixes', () { + final user1 = eg.user(fullName: 'So Many Ideas'); + final user2 = eg.user(fullName: 'Some Merry User'); + + checkPrecedes('so m', user1, user2); + }); + + test('group name is case- and diacritics-insensitive', () { + final userGroups = [ + eg.userGroup(name: 'Mobile Team'), + eg.userGroup(name: 'mobile team'), + eg.userGroup(name: 'möbile team'), + ]; + + checkAllSameRank('mobile team', userGroups); // exact + checkAllSameRank('mobile te', userGroups); // total-prefix + checkAllSameRank('mob te', userGroups); // word-prefixes + }); + + test('group name match: exact over total-prefix', () { + final userGroup1 = eg.userGroup(name: 'Mobile'); + final userGroup2 = eg.userGroup(name: 'Mobile Team'); + + checkPrecedes('mobile', userGroup1, userGroup2); + }); + + test('group name match: total-prefix over word-prefixes', () { + final userGroup1 = eg.userGroup(name: 'So Many Ideas'); + final userGroup2 = eg.userGroup(name: 'Some Merry Group'); + + checkPrecedes('so m', userGroup1, userGroup2); + }); + + test('email match is case- and diacritics-insensitive', () { + // "z" name to prevent accidental name match with example data + final users = [ + eg.user(fullName: 'z', deliveryEmail: 'email@example.com'), + eg.user(fullName: 'z', deliveryEmail: 'EmAiL@ExAmPlE.com'), + eg.user(fullName: 'z', deliveryEmail: 'ēmail@example.com'), + ]; + + checkAllSameRank('email@example.com', users); + checkAllSameRank('email@e', users); + checkAllSameRank('email@', users); + checkAllSameRank('email', users); + checkAllSameRank('ema', users); + }); + + test('email match is by prefix only', () { + // "z" name to prevent accidental name match with example data + final user = eg.user(fullName: 'z', deliveryEmail: 'email@example.com'); + + check(rankOf('e', user)).isNotNull(); + check(rankOf('mail', user)).isNull(); + check(rankOf('example', user)).isNull(); + check(rankOf('example.com', user)).isNull(); + }); + + test('full list of ranks', () { + final user1 = eg.user(fullName: 'some user', deliveryEmail: 'email@example.com'); + final userGroup1 = eg.userGroup(name: 'some user group'); + check([ + rankOf('', WildcardMentionOption.all), // wildcard + rankOf('some user', user1), // user, exact name match + rankOf('some us', user1), // user, total-prefix name match + rankOf('so us', user1), // user, word-prefixes name match + rankOf('some user group', userGroup1), // user group, exact name match + rankOf('some us', userGroup1), // user group, total-prefix name match + rankOf('so us gr', userGroup1), // user group, word-prefixes name match + rankOf('email', user1), // user, no name match, email match + ]).deepEquals([0, 1, 2, 3, 4, 5, 6, 7]); + }); + }); + group('ComposeTopicAutocomplete.autocompleteIntent', () { void doTest(String markedText, TopicAutocompleteQuery? expectedQuery) { final parsed = parseMarkedText(markedText); @@ -1026,9 +1277,25 @@ void main() { check(done).isTrue(); }); + test('TopicAutocompleteView getStreamTopics request', () async { + final store = eg.store(); + final connection = store.connection as FakeApiConnection; + + connection.prepare(json: GetStreamTopicsResult( + topics: [eg.getStreamTopicsEntry(name: '')], + ).toJson()); + TopicAutocompleteView.init(store: store, streamId: 1000, + query: TopicAutocompleteQuery('foo')); + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/users/me/1000/topics') + ..url.queryParameters['allow_empty_topic_name'].equals('true'); + }); + group('TopicAutocompleteQuery.testTopic', () { + final store = eg.store(); void doCheck(String rawQuery, String topic, bool expected) { - final result = TopicAutocompleteQuery(rawQuery).testTopic(eg.t(topic)); + final result = TopicAutocompleteQuery(rawQuery).testTopic(eg.t(topic), store); expected ? check(result).isTrue() : check(result).isFalse(); } @@ -1041,3 +1308,5 @@ void main() { }); }); } + +typedef WildcardTester = void Function(String query, Narrow narrow, List expected); diff --git a/test/model/binding.dart b/test/model/binding.dart index 039d6c3787..c7fe2bf639 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -7,11 +7,14 @@ 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_intents.dart'; import 'package:zulip/host/android_notifications.dart'; +import 'package:zulip/host/notifications.dart'; import 'package:zulip/model/binding.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app.dart'; +import '../example_data.dart' as eg; import 'test_store.dart'; /// The binding instance used in tests. @@ -30,7 +33,7 @@ TestZulipBinding get testBinding => TestZulipBinding.instance; /// and [TestGlobalStore.add] to set up test data there. Such test functions /// must also call [reset] to clean up the global store. /// -/// The global store returned by [loadGlobalStore], and consequently by +/// The global store returned by [getGlobalStore], and consequently by /// [GlobalStoreWidget.of] in application code, will be a [TestGlobalStore]. class TestZulipBinding extends ZulipBinding { /// Initialize the binding if necessary, and ensure it is a [TestZulipBinding]. @@ -86,7 +89,7 @@ class TestZulipBinding extends ZulipBinding { /// /// Tests that access this getter, or that mount a [GlobalStoreWidget], /// should clean up by calling [reset]. - TestGlobalStore get globalStore => _globalStore ??= TestGlobalStore(accounts: []); + TestGlobalStore get globalStore => _globalStore ??= eg.globalStore(); TestGlobalStore? _globalStore; bool _debugAlreadyLoadedStore = false; @@ -103,6 +106,9 @@ class TestZulipBinding extends ZulipBinding { @override Future getGlobalStore() => Future.value(globalStore); + @override + GlobalStore? getGlobalStoreSync() => globalStore; + @override Future getGlobalStoreUniquely() { assert(() { @@ -160,11 +166,21 @@ class TestZulipBinding extends ZulipBinding { /// The value that `ZulipBinding.instance.launchUrl()` should return. /// - /// See also [takeLaunchUrlCalls]. + /// See also: + /// * [launchUrlException] + /// * [takeLaunchUrlCalls] bool launchUrlResult = true; + /// The [PlatformException] that `ZulipBinding.instance.launchUrl()` should throw. + /// + /// See also: + /// * [launchUrlResult] + /// * [takeLaunchUrlCalls] + PlatformException? launchUrlException; + void _resetLaunchUrl() { launchUrlResult = true; + launchUrlException = null; _launchUrlCalls = null; } @@ -188,6 +204,22 @@ class TestZulipBinding extends ZulipBinding { url_launcher.LaunchMode mode = url_launcher.LaunchMode.platformDefault, }) async { (_launchUrlCalls ??= []).add((url: url, mode: mode)); + + if (!launchUrlResult && launchUrlException != null) { + throw FlutterError.fromParts([ + ErrorSummary( + 'TestZulipBinding.launchUrl called ' + 'with launchUrlResult: false and non-null launchUrlException'), + ErrorHint( + 'Tests should either set launchUrlResult or launchUrlException, ' + 'but not both.'), + ]); + } + + if (launchUrlException != null) { + throw launchUrlException!; + } + return launchUrlResult; } @@ -211,6 +243,9 @@ class TestZulipBinding extends ZulipBinding { _closeInAppWebViewCallCount++; } + @override + DateTime utcNow() => clock.now().toUtc(); + @override Stopwatch stopwatch() => clock.stopwatch(); @@ -230,7 +265,7 @@ class TestZulipBinding extends ZulipBinding { /// The value that `ZulipBinding.instance.packageInfo` should return. PackageInfo packageInfoResult = _defaultPackageInfo; - static const _defaultPackageInfo = PackageInfo(version: '0.0.1', buildNumber: '1'); + static final _defaultPackageInfo = eg.packageInfo(); void _resetPackageInfo() { packageInfoResult = _defaultPackageInfo; @@ -278,14 +313,18 @@ class TestZulipBinding extends ZulipBinding { void _resetNotifications() { _androidNotificationHostApi = null; + _notificationPigeonApi = null; } + @override + FakeAndroidNotificationHostApi get androidNotificationHost => + (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); FakeAndroidNotificationHostApi? _androidNotificationHostApi; @override - FakeAndroidNotificationHostApi get androidNotificationHost { - return (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); - } + FakeNotificationPigeonApi get notificationPigeonApi => + (_notificationPigeonApi ??= FakeNotificationPigeonApi()); + FakeNotificationPigeonApi? _notificationPigeonApi; /// The value that `ZulipBinding.instance.pickFiles()` should return. /// @@ -381,10 +420,14 @@ class TestZulipBinding extends ZulipBinding { Future toggleWakelock({required bool enable}) async { _wakelockEnabled = enable; } + + @override + // TODO(#1787) implement androidIntentEvents and write related tests + Stream get androidIntentEvents => throw UnimplementedError(); } class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { - //////////////////////////////// + //|////////////////////////////// // Permissions. NotificationSettings requestPermissionResult = const NotificationSettings( @@ -433,7 +476,7 @@ class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { return requestPermissionResult; } - //////////////////////////////// + //|////////////////////////////// // Tokens. String? _initialToken; @@ -489,7 +532,7 @@ class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { } } - //////////////////////////////// + //|////////////////////////////// // Messages. StreamController onMessage = StreamController.broadcast(); @@ -723,6 +766,32 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { } } +class FakeNotificationPigeonApi implements NotificationPigeonApi { + NotificationDataFromLaunch? _notificationDataFromLaunch; + + /// Populates the notification data for launch to be returned + /// by [getNotificationDataFromLaunch]. + void setNotificationDataFromLaunch(NotificationDataFromLaunch? data) { + _notificationDataFromLaunch = data; + } + + @override + Future getNotificationDataFromLaunch() async => + _notificationDataFromLaunch; + + StreamController? _notificationTapEventsStreamController; + + void addNotificationTapEvent(NotificationTapEvent event) { + _notificationTapEventsStreamController!.add(event); + } + + @override + Stream notificationTapEventsStream() { + _notificationTapEventsStreamController ??= StreamController(); + return _notificationTapEventsStreamController!.stream; + } +} + typedef AndroidNotificationHostApiNotifyCall = ({ String? tag, int id, diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart index f22ac7cc8f..07e542e6a1 100644 --- a/test/model/channel_test.dart +++ b/test/model/channel_test.dart @@ -1,4 +1,3 @@ - import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; @@ -7,7 +6,9 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/channel.dart'; +import '../api/model/model_checks.dart'; import '../example_data.dart' as eg; +import '../stdlib_checks.dart'; import 'test_store.dart'; void main() { @@ -68,6 +69,23 @@ void main() { )); checkUnified(store); }); + + test('unsubscribed then subscribed by events', () async { + // Regression test for: https://chat.zulip.org/#narrow/channel/48-mobile/topic/Unsubscribe.20then.20resubscribe.20to.20channel/with/2160241 + final stream = eg.stream(); + final store = eg.store(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + checkUnified(store); + + await store.handleEvent(SubscriptionRemoveEvent(id: 1, + streamIds: [stream.streamId])); + checkUnified(store); + + await store.handleEvent(SubscriptionAddEvent(id: 1, + subscriptions: [eg.subscription(stream)])); + checkUnified(store); + }); }); group('SubscriptionEvent', () { @@ -129,7 +147,7 @@ void main() { test('with nothing for topic', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.muted); check(store.topicVisibilityPolicy(stream1.streamId, eg.t('topic'))) .equals(UserTopicVisibilityPolicy.none); }); @@ -141,9 +159,13 @@ void main() { UserTopicVisibilityPolicy.unmuted, UserTopicVisibilityPolicy.followed, ]) { - await store.addUserTopic(stream1, 'topic', policy); + await store.setUserTopic(stream1, 'topic', policy); check(store.topicVisibilityPolicy(stream1.streamId, eg.t('topic'))) .equals(policy); + + // Case-insensitive + check(store.topicVisibilityPolicy(stream1.streamId, eg.t('ToPiC'))) + .equals(policy); } }); }); @@ -176,27 +198,39 @@ void main() { final store = eg.store(); await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1)); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isFalse(); check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isFalse(); + + // Case-insensitive + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('ToPiC'))).isFalse(); + check(store.isTopicVisible (stream1.streamId, eg.t('ToPiC'))).isFalse(); }); test('with policy unmuted', () async { final store = eg.store(); await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.unmuted); check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isTrue(); + + // Case-insensitive + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('tOpIc'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('tOpIc'))).isTrue(); }); test('with policy followed', () async { final store = eg.store(); await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.followed); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.followed); check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isTrue(); + + // Case-insensitive + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('TOPIC'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('TOPIC'))).isTrue(); }); }); @@ -204,12 +238,29 @@ void main() { UserTopicEvent mkEvent(UserTopicVisibilityPolicy policy) => eg.userTopicEvent(stream1.streamId, 'topic', policy); + // For testing case-insensitivity + UserTopicEvent mkEventDifferentlyCased(UserTopicVisibilityPolicy policy) => + eg.userTopicEvent(stream1.streamId, 'ToPiC', policy); + + assert(() { + // (sanity check on mkEvent and mkEventDifferentlyCased) + final event1 = mkEvent(UserTopicVisibilityPolicy.followed); + final event2 = mkEventDifferentlyCased(UserTopicVisibilityPolicy.followed); + return event1.topicName.isSameAs(event2.topicName) + && event1.topicName.apiName != event2.topicName.apiName; + }()); + void checkChanges(PerAccountStore store, UserTopicVisibilityPolicy newPolicy, - VisibilityEffect expectedInStream, VisibilityEffect expectedOverall) { + UserTopicVisibilityEffect expectedInStream, + UserTopicVisibilityEffect expectedOverall) { final event = mkEvent(newPolicy); check(store.willChangeIfTopicVisibleInStream(event)).equals(expectedInStream); check(store.willChangeIfTopicVisible (event)).equals(expectedOverall); + + final event2 = mkEventDifferentlyCased(newPolicy); + check(store.willChangeIfTopicVisibleInStream(event2)).equals(expectedInStream); + check(store.willChangeIfTopicVisible (event2)).equals(expectedOverall); } test('stream not muted, policy none -> followed, no change', () async { @@ -217,7 +268,7 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1)); checkChanges(store, UserTopicVisibilityPolicy.followed, - VisibilityEffect.none, VisibilityEffect.none); + UserTopicVisibilityEffect.none, UserTopicVisibilityEffect.none); }); test('stream not muted, policy none -> muted, means muted', () async { @@ -225,7 +276,7 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1)); checkChanges(store, UserTopicVisibilityPolicy.muted, - VisibilityEffect.muted, VisibilityEffect.muted); + UserTopicVisibilityEffect.muted, UserTopicVisibilityEffect.muted); }); test('stream muted, policy none -> followed, means none/unmuted', () async { @@ -233,7 +284,7 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); checkChanges(store, UserTopicVisibilityPolicy.followed, - VisibilityEffect.none, VisibilityEffect.unmuted); + UserTopicVisibilityEffect.none, UserTopicVisibilityEffect.unmuted); }); test('stream muted, policy none -> muted, means muted/none', () async { @@ -241,7 +292,7 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); checkChanges(store, UserTopicVisibilityPolicy.muted, - VisibilityEffect.muted, VisibilityEffect.none); + UserTopicVisibilityEffect.muted, UserTopicVisibilityEffect.none); }); final policies = [ @@ -276,10 +327,10 @@ void main() { final newVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, eg.t('topic')); final newVisible = store.isTopicVisible(stream1.streamId, eg.t('topic')); - VisibilityEffect fromOldNew(bool oldVisible, bool newVisible) { - if (newVisible == oldVisible) return VisibilityEffect.none; - if (newVisible) return VisibilityEffect.unmuted; - return VisibilityEffect.muted; + UserTopicVisibilityEffect fromOldNew(bool oldVisible, bool newVisible) { + if (newVisible == oldVisible) return UserTopicVisibilityEffect.none; + if (newVisible) return UserTopicVisibilityEffect.unmuted; + return UserTopicVisibilityEffect.muted; } check(willChangeInStream) .equals(fromOldNew(oldVisibleInStream, newVisibleInStream)); @@ -323,7 +374,7 @@ void main() { group('events', () { test('add with new stream', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); compareTopicVisibility(store, [ eg.userTopicItem(stream1, 'topic', UserTopicVisibilityPolicy.muted), ]); @@ -331,8 +382,8 @@ void main() { test('add in existing stream', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); - await store.addUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.unmuted); compareTopicVisibility(store, [ eg.userTopicItem(stream1, 'topic', UserTopicVisibilityPolicy.muted), eg.userTopicItem(stream1, 'other topic', UserTopicVisibilityPolicy.unmuted), @@ -341,18 +392,24 @@ void main() { test('update existing policy', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.unmuted); compareTopicVisibility(store, [ eg.userTopicItem(stream1, 'topic', UserTopicVisibilityPolicy.unmuted), ]); + + // case-insensitivity + await store.setUserTopic(stream1, 'ToPiC', UserTopicVisibilityPolicy.followed); + compareTopicVisibility(store, [ + eg.userTopicItem(stream1, 'topic', UserTopicVisibilityPolicy.followed), + ]); }); test('remove, with others in stream', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); - await store.addUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.unmuted); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.none); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.none); compareTopicVisibility(store, [ eg.userTopicItem(stream1, 'other topic', UserTopicVisibilityPolicy.unmuted), ]); @@ -360,16 +417,18 @@ void main() { test('remove, as last in stream', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.none); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + // case-insensitivity + await store.setUserTopic(stream1, 'ToPiC', UserTopicVisibilityPolicy.none); compareTopicVisibility(store, [ ]); }); test('treat unknown enum value as removing', () async { final store = eg.store(); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); - await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.unknown); + await store.setUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); + // case-insensitivity + await store.setUserTopic(stream1, 'ToPiC', UserTopicVisibilityPolicy.unknown); compareTopicVisibility(store, [ ]); }); @@ -386,7 +445,8 @@ void main() { ])); check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 1'))) .equals(UserTopicVisibilityPolicy.muted); - check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 2'))) + // case-insensitivity + check(store.topicVisibilityPolicy(stream.streamId, eg.t('ToPiC 2'))) .equals(UserTopicVisibilityPolicy.unmuted); check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 3'))) .equals(UserTopicVisibilityPolicy.followed); @@ -394,4 +454,106 @@ void main() { .equals(UserTopicVisibilityPolicy.none); }); }); + + group('hasPostingPermission', () { + final testCases = [ + (ChannelPostPolicy.unknown, UserRole.unknown, true), + (ChannelPostPolicy.unknown, UserRole.guest, true), + (ChannelPostPolicy.unknown, UserRole.member, true), + (ChannelPostPolicy.unknown, UserRole.moderator, true), + (ChannelPostPolicy.unknown, UserRole.administrator, true), + (ChannelPostPolicy.unknown, UserRole.owner, true), + (ChannelPostPolicy.any, UserRole.unknown, true), + (ChannelPostPolicy.any, UserRole.guest, true), + (ChannelPostPolicy.any, UserRole.member, true), + (ChannelPostPolicy.any, UserRole.moderator, true), + (ChannelPostPolicy.any, UserRole.administrator, true), + (ChannelPostPolicy.any, UserRole.owner, true), + (ChannelPostPolicy.fullMembers, UserRole.unknown, true), + (ChannelPostPolicy.fullMembers, UserRole.guest, false), + // The fullMembers/member case gets its own tests further below. + // (ChannelPostPolicy.fullMembers, UserRole.member, /* complicated */), + (ChannelPostPolicy.fullMembers, UserRole.moderator, true), + (ChannelPostPolicy.fullMembers, UserRole.administrator, true), + (ChannelPostPolicy.fullMembers, UserRole.owner, true), + (ChannelPostPolicy.moderators, UserRole.unknown, true), + (ChannelPostPolicy.moderators, UserRole.guest, false), + (ChannelPostPolicy.moderators, UserRole.member, false), + (ChannelPostPolicy.moderators, UserRole.moderator, true), + (ChannelPostPolicy.moderators, UserRole.administrator, true), + (ChannelPostPolicy.moderators, UserRole.owner, true), + (ChannelPostPolicy.administrators, UserRole.unknown, true), + (ChannelPostPolicy.administrators, UserRole.guest, false), + (ChannelPostPolicy.administrators, UserRole.member, false), + (ChannelPostPolicy.administrators, UserRole.moderator, false), + (ChannelPostPolicy.administrators, UserRole.administrator, true), + (ChannelPostPolicy.administrators, UserRole.owner, true), + ]; + + for (final (ChannelPostPolicy policy, UserRole role, bool canPost) in testCases) { + test('"${role.name}" user ${canPost ? 'can' : "can't"} post in channel ' + 'with "${policy.name}" policy', () { + final store = eg.store(); + final actual = store.hasPostingPermission( + inChannel: eg.stream(channelPostPolicy: policy), user: eg.user(role: role), + // [byDate] is not actually relevant for these test cases; for the + // ones which it is, they're practiced below. + byDate: DateTime.now()); + check(actual).equals(canPost); + }); + } + + group('"member" user posting in a channel with "fullMembers" policy', () { + PerAccountStore localStore({required int realmWaitingPeriodThreshold}) => + eg.store(initialSnapshot: eg.initialSnapshot( + realmWaitingPeriodThreshold: realmWaitingPeriodThreshold)); + + User memberUser({required String dateJoined}) => eg.user( + role: UserRole.member, dateJoined: dateJoined); + + test('a "full" member -> can post in the channel', () { + final store = localStore(realmWaitingPeriodThreshold: 3); + final hasPermission = store.hasPostingPermission( + inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), + user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), + byDate: DateTime.utc(2024, 11, 28, 10, 00)); + check(hasPermission).isTrue(); + }); + + test('not a "full" member -> cannot post in the channel', () { + final store = localStore(realmWaitingPeriodThreshold: 3); + final actual = store.hasPostingPermission( + inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), + user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), + byDate: DateTime.utc(2024, 11, 28, 09, 59)); + check(actual).isFalse(); + }); + }); + }); + + group('makeTopicKeyedMap', () { + test('"a" equals "A"', () { + final map = makeTopicKeyedMap() + ..[eg.t('a')] = 1 + ..[eg.t('A')] = 2; + check(map) + ..[eg.t('a')].equals(2) + ..[eg.t('A')].equals(2) + ..entries.which((it) => it.single + ..key.apiName.equals('a') + ..value.equals(2)); + }); + + test('"A" equals "a"', () { + final map = makeTopicKeyedMap() + ..[eg.t('A')] = 1 + ..[eg.t('a')] = 2; + check(map) + ..[eg.t('A')].equals(2) + ..[eg.t('a')].equals(2) + ..entries.which((it) => it.single + ..key.apiName.equals('A') + ..value.equals(2)); + }); + }); } diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index 73fa9452d7..7d6e81db33 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -1,5 +1,6 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/store.dart'; @@ -225,26 +226,69 @@ hello group('mention', () { group('user', () { final user = eg.user(userId: 123, fullName: 'Full Name'); - test('not silent', () { + final message = eg.streamMessage(sender: user); + test('not silent', () async { + final store = eg.store(); + await store.addUser(user); check(userMention(user, silent: false)).equals('@**Full Name|123**'); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); }); - test('silent', () { + test('silent', () async { + final store = eg.store(); + await store.addUser(user); check(userMention(user, silent: true)).equals('@_**Full Name|123**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); }); test('`users` passed; has two users with same fullName', () async { final store = eg.store(); await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName)]); - check(userMention(user, silent: true, users: store.users)).equals('@_**Full Name|123**'); + check(userMention(user, silent: true, users: store)).equals('@_**Full Name|123**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); }); test('`users` passed; has two same-name users but one of them is deactivated', () async { final store = eg.store(); await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName, isActive: false)]); - check(userMention(user, silent: true, users: store.users)).equals('@_**Full Name|123**'); + check(userMention(user, silent: true, users: store)).equals('@_**Full Name|123**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); }); test('`users` passed; user has unique fullName', () async { final store = eg.store(); await store.addUsers([user, eg.user(userId: 234, fullName: 'Another Name')]); - check(userMention(user, silent: true, users: store.users)).equals('@_**Full Name**'); + check(userMention(user, silent: true, users: store)).equals('@_**Full Name**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); + }); + + test('userMentionFromMessage, known user', () async { + final user = eg.user(userId: 123, fullName: 'Full Name'); + final store = eg.store(); + await store.addUser(user); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: user.userId, fullName: 'New Name')); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**New Name|123**'); + }); + + test('userMentionFromMessage, unknown user', () async { + final store = eg.store(); + check(store.getUser(user.userId)).isNull(); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); + }); + + test('userMentionFromMessage, muted user', () async { + final store = eg.store(); + await store.addUser(user); + await store.setMutedUsers([user.userId]); + check(store.isUserMuted(user.userId)).isTrue(); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); // not replaced with 'Muted user' }); }); @@ -272,12 +316,28 @@ hello check(wildcardMention(WildcardMentionOption.topic, store: store())) .equals('@**topic**'); }); + + group('user group', () { + final userGroup = eg.userGroup(name: 'Group Name'); + test('not silent', () async { + final store = eg.store(); + await store.addUserGroup(userGroup); + check(userGroupMention(userGroup.name, silent: false)) + .equals('@*Group Name*'); + }); + test('silent', () async { + final store = eg.store(); + await store.addUserGroup(userGroup); + check(userGroupMention(userGroup.name, silent: true)) + .equals('@_*Group Name*'); + }); + }); }); test('inlineLink', () { - check(inlineLink('CZO', Uri.parse('https://chat.zulip.org/'))).equals('[CZO](https://chat.zulip.org/)'); - check(inlineLink('Uploading file.txt…', null)).equals('[Uploading file.txt…]()'); - check(inlineLink('IMG_2488.png', Uri.parse('/user_uploads/2/a3/ucEMyjxk90mcNF0y9rmW5XKO/IMG_2488.png'))) + check(inlineLink('CZO', 'https://chat.zulip.org/')).equals('[CZO](https://chat.zulip.org/)'); + check(inlineLink('Uploading file.txt…', '')).equals('[Uploading file.txt…]()'); + check(inlineLink('IMG_2488.png', '/user_uploads/2/a3/ucEMyjxk90mcNF0y9rmW5XKO/IMG_2488.png')) .equals('[IMG_2488.png](/user_uploads/2/a3/ucEMyjxk90mcNF0y9rmW5XKO/IMG_2488.png)'); }); @@ -289,6 +349,19 @@ hello await store.addStream(stream); await store.addUser(sender); + check(quoteAndReplyPlaceholder( + GlobalLocalizations.zulipLocalizations, store, message: message)).equals(''' +@_**Full Name|123** [said](${eg.selfAccount.realmUrl}#narrow/channel/1-test-here/topic/some.20topic/near/${message.id}): *(loading message ${message.id})* +'''); + + check(quoteAndReply(store, message: message, rawContent: 'Hello world!')).equals(''' +@_**Full Name|123** [said](${eg.selfAccount.realmUrl}#narrow/channel/1-test-here/topic/some.20topic/near/${message.id}): +```quote +Hello world! +``` +'''); + + store.connection.zulipFeatureLevel = 249; 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/content_test.dart b/test/model/content_test.dart index 361259eb61..84e3baed42 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -6,7 +6,9 @@ import 'package:stack_trace/stack_trace.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/model/code_block.dart'; import 'package:zulip/model/content.dart'; +import 'package:zulip/model/katex.dart'; +import 'binding.dart'; import 'content_checks.dart'; /// An example of Zulip content for test cases. @@ -83,6 +85,13 @@ class ContentExample { '

    bold

    ', const StrongNode(nodes: [TextNode('bold')])); + static final deleted = ContentExample.inline( + 'deleted/strike-through', + '~~strike through~~', + expectedText: 'strike through', + '

    strike through

    ', + const DeletedNode(nodes: [TextNode('strike through')])); + static final emphasis = ContentExample.inline( 'emphasis/italic', '*italic*', @@ -269,6 +278,26 @@ class ContentExample { url: '/#narrow/channel/378-api-design/topic/notation.20for.20near.20links/near/1972281', nodes: [TextNode('#api design > notation for near links @ 💬')])); + static const orderedListCustomStart = ContentExample( + 'ordered list with custom start', + '5. fifth\n6. sixth', + '
      \n
    1. fifth
    2. \n
    3. sixth
    4. \n
    ', + [OrderedListNode(start: 5, [ + [ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('fifth')])], + [ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('sixth')])], + ])], + ); + + static const orderedListLargeStart = ContentExample( + 'ordered list with large start number', + '9999. first\n10000. second', + '
      \n
    1. first
    2. \n
    3. second
    4. \n
    ', + [OrderedListNode(start: 9999, [ + [ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('first')])], + [ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('second')])], + ])], + ); + static const spoilerDefaultHeader = ContentExample( 'spoiler with default header', '```spoiler\nhello world\n```', @@ -306,8 +335,8 @@ class ContentExample { '

    italic zulip

    \n' '', [SpoilerNode( - header: [ListNode(ListStyle.ordered, [ - [ListNode(ListStyle.unordered, [ + header: [OrderedListNode(start: 1, [ + [UnorderedListNode([ [HeadingNode(level: HeadingLevel.h2, links: null, nodes: [ TextNode('hello'), ])] @@ -489,22 +518,103 @@ class ContentExample { static final mathInline = ContentExample.inline( 'inline math', r"$$ \lambda $$", - expectedText: r'\lambda', + expectedText: r'λ', '

    ' 'λ' ' \\lambda ' '

    ', - const MathInlineNode(texSource: r'\lambda')); + MathInlineNode(texSource: r'\lambda', nodes: [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'λ'), + ]), + ])); + + // A test message to test the fallback behaviour of KaTeX implementation. + static final mathInlineUnknown = ContentExample.inline( + 'inline math', + null, // r"$$ \lambda $$" (hypothetical server variation) + expectedText: r'\lambda', + '

    ' + 'λ' + ' \\lambda ' + '

    ', + MathInlineNode(texSource: r'\lambda', nodes: null)); static const mathBlock = ContentExample( 'math block', "```math\n\\lambda\n```", - expectedText: r'\lambda', + expectedText: r'λ', '

    ' 'λ' '\\lambda' '

    ', - [MathBlockNode(texSource: r'\lambda')]); + [MathBlockNode(texSource: r'\lambda', nodes: [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'λ'), + ]), + ])]); + + // A test message to test the fallback behaviour of KaTeX implementation. + static const mathBlockUnknown = ContentExample( + 'math block unknown, fallback to TeX source', + null, // r"```math\n\lambda\n```" (hypothetical server variation) + expectedText: r'\lambda', + '

    ' + 'λ' + '\\lambda' + '

    ', + [MathBlockNode(texSource: r'\lambda', nodes: null)]); + + static const mathBlocksMultipleInParagraph = ContentExample( + 'math blocks, multiple in paragraph', + '```math\na\n\nb\n```', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/.E2.9C.94.20Rajesh/near/2001490 + '

    ' + '' + 'a' + 'a' + '\n\n' + '' + 'b' + 'b' + '

    ', [ + MathBlockNode(texSource: 'a', nodes: [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'a'), + ]), + ]), + MathBlockNode(texSource: 'b', nodes: [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'b'), + ]), + ]), + ]); static const mathBlockInQuote = ContentExample( 'math block in quote', @@ -520,7 +630,101 @@ class ContentExample { '\\lambda' '' '
    \n

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

    ' + '' + 'a' + 'a' + '' + '\n\n' + '' + 'b' + 'b' + '' + '
    \n

    \n
    ', + [QuotationNode([ + MathBlockNode(texSource: 'a', nodes: [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'a'), + ]), + ]), + MathBlockNode(texSource: 'b', nodes: [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'b'), + ]), + ]), + ])]); + + static const mathBlockBetweenImages = ContentExample( + 'math block between images', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Greg/near/2035891 + 'https://upload.wikimedia.org/wikipedia/commons/7/78/Verregende_bloem_van_een_Helenium_%27El_Dorado%27._22-07-2023._%28d.j.b%29.jpg\n```math\na\n```\nhttps://upload.wikimedia.org/wikipedia/commons/thumb/7/71/Zaadpluizen_van_een_Clematis_texensis_%27Princess_Diana%27._18-07-2023_%28actm.%29_02.jpg/1280px-Zaadpluizen_van_een_Clematis_texensis_%27Princess_Diana%27._18-07-2023_%28actm.%29_02.jpg', + '
    ' + '' + '
    ' + '

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

    \n' + '
    ' + '' + '
    ', + [ + ImageNodeList([ + ImageNode( + srcUrl: '/external_content/de28eb3abf4b7786de4545023dc42d434a2ea0c2/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f372f37382f566572726567656e64655f626c6f656d5f76616e5f65656e5f48656c656e69756d5f253237456c5f446f7261646f2532372e5f32322d30372d323032332e5f253238642e6a2e622532392e6a7067', + thumbnailUrl: null, + loading: false, + originalWidth: null, + originalHeight: null), + ]), + MathBlockNode(texSource: 'a', nodes: [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'a'), + ]), + ]), + ImageNodeList([ + ImageNode( + srcUrl: '/external_content/58b0ef9a06d7bb24faec2b11df2f57f476e6f6bb/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f372f37312f5a616164706c75697a656e5f76616e5f65656e5f436c656d617469735f746578656e7369735f2532375072696e636573735f4469616e612532372e5f31382d30372d323032335f2532386163746d2e2532395f30322e6a70672f3132383070782d5a616164706c75697a656e5f76616e5f65656e5f436c656d617469735f746578656e7369735f2532375072696e636573735f4469616e612532372e5f31382d30372d323032335f2532386163746d2e2532395f30322e6a7067', + thumbnailUrl: null, + loading: false, + originalWidth: null, + originalHeight: null), + ]), + ]); static const imageSingle = ContentExample( 'single image', @@ -763,7 +967,7 @@ class ContentExample { '
    ' '' '
    \n', [ - ListNode(ListStyle.unordered, [[ + UnorderedListNode([[ ImageNodeList([ ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', thumbnailUrl: null, loading: false, @@ -785,7 +989,7 @@ class ContentExample { '
    ' '' '
    \n', [ - ListNode(ListStyle.unordered, [[ + UnorderedListNode([[ ParagraphNode(wasImplicit: true, links: null, nodes: [ LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]), TextNode(' '), @@ -814,7 +1018,7 @@ class ContentExample { '' '' 'more text\n', [ - ListNode(ListStyle.unordered, [[ + UnorderedListNode([[ const ParagraphNode(wasImplicit: true, links: null, nodes: [ LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]), TextNode(' '), @@ -956,6 +1160,128 @@ class ContentExample { InlineVideoNode(srcUrl: '/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm'), ]); + static const audioInline = ContentExample( + 'audio inline', + '![crab-rave.mp3](/user_uploads/2/f2/a_WnijOXIeRnI6OSxo9F6gZM/crab-rave.mp3)', + '

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

    ', [ + ParagraphNode(links: null, nodes: [ + LinkNode(url: '/user_uploads/2/f2/a_WnijOXIeRnI6OSxo9F6gZM/crab-rave.mp3', nodes: [TextNode('crab-rave.mp3')]), + ]), + ]); + + static const websitePreviewSmoke = ContentExample( + 'website preview smoke', + 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html', + '

    https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html

    \n' + '
    ' + '' + '
    ' + '' + '
    Zulip is an organized team chat app for distributed teams of all sizes.
    ', [ + ParagraphNode(links: [], nodes: [ + LinkNode( + nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html')], + url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html'), + ]), + WebsitePreviewNode( + hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html', + imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67', + title: 'Zulip — organized team chat', + description: 'Zulip is an organized team chat app for distributed teams of all sizes.'), + ]); + + static const websitePreviewWithoutTitle = ContentExample( + 'website preview without title', + 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html', + '

    https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html

    \n' + '
    ' + '' + '
    ' + '
    Zulip is an organized team chat app for distributed teams of all sizes.
    ', [ + ParagraphNode(links: [], nodes: [ + LinkNode( + nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html')], + url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html'), + ]), + WebsitePreviewNode( + hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html', + imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67', + title: null, + description: 'Zulip is an organized team chat app for distributed teams of all sizes.'), + ]); + + static const websitePreviewWithoutDescription = ContentExample( + 'website preview without description', + 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html', + '

    https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html

    \n' + '', [ + ParagraphNode(links: [], nodes: [ + LinkNode( + nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html')], + url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html'), + ]), + WebsitePreviewNode( + hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html', + imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67', + title: 'Zulip — organized team chat', + description: null), + ]); + + static const websitePreviewWithoutTitleOrDescription = ContentExample( + 'website preview without title and description', + 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html', + '

    https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html

    \n' + '
    ' + '' + '
    ', [ + ParagraphNode(links: [], nodes: [ + LinkNode( + nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html')], + url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html'), + ]), + WebsitePreviewNode( + hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html', + imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67', + title: null, + description: null), + ]); + + static const legacyWebsitePreviewSmoke = ContentExample( + 'legacy website preview smoke', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/URL.20previews/near/192777 + 'www.youtube.com', + '

    www.youtube.com

    \n' + '
    ' + '' + '
    ' + '' + '
    Enjoy the videos and music you love, upload original content, and share it all with friends, family, and the world on YouTube.
    ', [ + ParagraphNode(links: [], nodes: [ + LinkNode( + nodes: [TextNode('www.youtube.com')], + url: 'http://www.youtube.com'), + ]), + WebsitePreviewNode( + hrefUrl: 'http://www.youtube.com', + imageSrcUrl: 'https://youtube.com/yts/img/yt_1200-vfl4C3T0K.png', + title: 'YouTube', + description: 'Enjoy the videos and music you love, upload ' + 'original content, and share it all with friends, family, and ' + 'the world on YouTube.'), + ]); + static const tableWithSingleRow = ContentExample( 'table with single row', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/1971202 @@ -1167,18 +1493,21 @@ UnimplementedInlineContentNode inlineUnimplemented(String html) { return UnimplementedInlineContentNode(htmlNode: fragment.nodes.single); } -void testParse(String name, String html, List nodes) { +void testParse(String name, String html, List nodes, { + Object? skip, +}) { test(name, () { check(parseContent(html)) .equalsNode(ZulipContent(nodes: nodes)); - }); + }, skip: skip); } -void testParseExample(ContentExample example) { - testParse('parse ${example.description}', example.html, example.expectedNodes); +void testParseExample(ContentExample example, {Object? skip}) { + testParse('parse ${example.description}', example.html, example.expectedNodes, + skip: skip); } -void main() { +void main() async { // When writing test cases in this file: // // * Prefer to add a [ContentExample] static and use [testParseExample]. @@ -1187,6 +1516,8 @@ void main() { // // * To write the example, see comment at top of [ContentExample]. + TestZulipBinding.ensureInitialized(); + // // Inline content. // @@ -1211,10 +1542,7 @@ void main() { testParseExample(ContentExample.strong); - testParseInline('parse deleted/strike-through', - // "~~strike through~~" - '

    strike through

    ', - const DeletedNode(nodes: [TextNode('strike through')])); + testParseExample(ContentExample.deleted); testParseExample(ContentExample.emphasis); @@ -1298,6 +1626,7 @@ void main() { testParseExample(ContentExample.emojiZulipExtra); testParseExample(ContentExample.mathInline); + testParseExample(ContentExample.mathInlineUnknown); group('global times', () { testParseExample(ContentExample.globalTime); @@ -1382,7 +1711,7 @@ void main() { testParse('
      ', // "1. first\n2. then" '
        \n
      1. first
      2. \n
      3. then
      4. \n
      ', const [ - ListNode(ListStyle.ordered, [ + OrderedListNode(start: 1, [ [ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('first')])], [ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('then')])], ]), @@ -1391,7 +1720,7 @@ void main() { testParse('
        ', // "* something\n* another" '
          \n
        • something
        • \n
        • another
        • \n
        ', const [ - ListNode(ListStyle.unordered, [ + UnorderedListNode([ [ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('something')])], [ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('another')])], ]), @@ -1400,7 +1729,7 @@ void main() { testParse('implicit paragraph with internal
        ', // "* a\n b" '
          \n
        • a
          \n b
        • \n
        ', const [ - ListNode(ListStyle.unordered, [ + UnorderedListNode([ [ParagraphNode(wasImplicit: true, links: null, nodes: [ TextNode('a'), LineBreakInlineNode(), @@ -1412,13 +1741,16 @@ void main() { testParse('explicit paragraphs', // "* a\n\n b" '
          \n
        • \n

          a

          \n

          b

          \n
        • \n
        ', const [ - ListNode(ListStyle.unordered, [ + UnorderedListNode([ [ ParagraphNode(links: null, nodes: [TextNode('a')]), ParagraphNode(links: null, nodes: [TextNode('b')]), ], ]), ]); + + testParseExample(ContentExample.orderedListCustomStart); + testParseExample(ContentExample.orderedListLargeStart); }); testParseExample(ContentExample.spoilerDefaultHeader); @@ -1451,7 +1783,7 @@ void main() { testParse('link in list item', // "* [t](/u)" '
          \n
        • t
        • \n
        ', const [ - ListNode(ListStyle.unordered, [ + UnorderedListNode([ [ParagraphNode(links: null, wasImplicit: true, nodes: [ LinkNode(url: '/u', nodes: [TextNode('t')]), ])], @@ -1469,8 +1801,16 @@ void main() { testParseExample(ContentExample.codeBlockWithUnknownSpanType); testParseExample(ContentExample.codeBlockFollowedByMultipleLineBreaks); + // The math examples in this file are about how math blocks and spans fit + // into the context of a Zulip message. + // For tests going deeper inside KaTeX content, see katex_test.dart. testParseExample(ContentExample.mathBlock); + testParseExample(ContentExample.mathBlockUnknown); + + testParseExample(ContentExample.mathBlocksMultipleInParagraph); testParseExample(ContentExample.mathBlockInQuote); + testParseExample(ContentExample.mathBlocksMultipleInQuote); + testParseExample(ContentExample.mathBlockBetweenImages); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); @@ -1494,6 +1834,15 @@ void main() { testParseExample(ContentExample.videoInline); testParseExample(ContentExample.videoInlineClassesFlipped); + testParseExample(ContentExample.audioInline); + testParseExample(ContentExample.audioInlineNoTitle); + + testParseExample(ContentExample.websitePreviewSmoke); + testParseExample(ContentExample.websitePreviewWithoutTitle); + testParseExample(ContentExample.websitePreviewWithoutDescription); + testParseExample(ContentExample.websitePreviewWithoutTitleOrDescription); + testParseExample(ContentExample.legacyWebsitePreviewSmoke); + testParseExample(ContentExample.tableWithSingleRow); testParseExample(ContentExample.tableWithMultipleRows); testParseExample(ContentExample.tableWithBoldAndItalicHeaders); @@ -1509,10 +1858,10 @@ void main() { '
          \n
        1. \n
          \n
          two
          \n
            \n
          • three
          • \n' '
          \n
          \n
          '
                   'four\n
          \n\n
        2. \n
        ', const [ - ListNode(ListStyle.ordered, [[ + OrderedListNode(start: 1, [[ QuotationNode([ HeadingNode(level: HeadingLevel.h6, links: null, nodes: [TextNode('two')]), - ListNode(ListStyle.unordered, [[ + UnorderedListNode([[ ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('three')]), ]]), ]), @@ -1538,7 +1887,7 @@ void main() { r'^\s*static\s+(?:const|final)\s+(\w+)\s*=\s*ContentExample\s*(?:\.\s*inline\s*)?\(', ).allMatches(source).map((m) => m.group(1)); final testedExamples = RegExp(multiLine: true, - r'^\s*testParseExample\s*\(\s*ContentExample\s*\.\s*(\w+)\);', + r'^\s*testParseExample\s*\(\s*ContentExample\s*\.\s*(\w+)(?:,\s*skip:\s*true)?\s*\);', ).allMatches(source).map((m) => m.group(1)); check(testedExamples).unorderedEquals(declaredExamples); }, skip: Platform.isWindows, // [intended] purely analyzes source, so diff --git a/test/model/database_test.dart b/test/model/database_test.dart index cb3a7d299b..47f93c8e84 100644 --- a/test/model/database_test.dart +++ b/test/model/database_test.dart @@ -1,23 +1,189 @@ import 'package:checks/checks.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; +import 'package:drift_dev/api/migrations_common.dart' show ValidationOptions; import 'package:drift_dev/api/migrations_native.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/model/database.dart'; +import 'package:zulip/model/settings.dart'; import 'schemas/schema.dart'; import 'schemas/schema_v1.dart' as v1; import 'schemas/schema_v2.dart' as v2; +import 'schemas/schema_v3.dart' as v3; +import 'schemas/schema_v4.dart' as v4; +import 'schemas/schema_v5.dart' as v5; +import 'schemas/schema_v10.dart' as v10; +import 'schemas/schema_v11.dart' as v11; +import 'store_checks.dart'; void main() { group('non-migration tests', () { - late AppDatabase database; + late AppDatabase db; setUp(() { - database = AppDatabase(NativeDatabase.memory()); + db = AppDatabase(NativeDatabase.memory()); }); tearDown(() async { - await database.close(); + await db.close(); + }); + + test('initialize GlobalSettings with defaults', () async { + check(await db.getGlobalSettings()).themeSetting.isNull(); + }); + + test('does not crash if multiple global settings rows', () async { + await db.into(db.globalSettings) + .insert(const GlobalSettingsCompanion(themeSetting: Value(ThemeSetting.dark))); + + check(await db.select(db.globalSettings).get()).length.equals(2); + check(await db.getGlobalSettings()).themeSetting.isNull(); + }); + + test('GlobalSettings updates work', () async { + check(await db.getGlobalSettings()).themeSetting.isNull(); + + // As in doUpdateGlobalSettings. + await db.update(db.globalSettings) + .write(GlobalSettingsCompanion(themeSetting: Value(ThemeSetting.dark))); + check(await db.getGlobalSettings()).themeSetting.equals(ThemeSetting.dark); + }); + + group('BoolGlobalSettings', () { + test('get ignores unknown names', () async { + await db.into(db.boolGlobalSettings) + .insert(BoolGlobalSettingRow(name: 'nonsense', value: true)); + check(await db.getBoolGlobalSettings()).isEmpty(); + + final setting = BoolGlobalSetting.placeholderIgnore; + await db.into(db.boolGlobalSettings) + .insert(BoolGlobalSettingRow(name: setting.name, value: true)); + check(await db.getBoolGlobalSettings()) + .deepEquals({setting: true}); + }); + + test('insert, then get', () async { + check(await db.getBoolGlobalSettings()).isEmpty(); + + // As in doSetBoolGlobalSetting for `value` non-null. + final setting = BoolGlobalSetting.placeholderIgnore; + await db.into(db.boolGlobalSettings).insertOnConflictUpdate( + BoolGlobalSettingRow(name: setting.name, value: true)); + check(await db.getBoolGlobalSettings()) + .deepEquals({setting: true}); + check(await db.select(db.boolGlobalSettings).get()).length.equals(1); + }); + + test('delete, then get', () async { + final setting = BoolGlobalSetting.placeholderIgnore; + await db.into(db.boolGlobalSettings).insertOnConflictUpdate( + BoolGlobalSettingRow(name: setting.name, value: true)); + check(await db.getBoolGlobalSettings()) + .deepEquals({setting: true}); + + // As in doSetBoolGlobalSetting for `value` null. + final query = db.delete(db.boolGlobalSettings) + ..where((r) => r.name.equals(setting.name)); + await query.go(); + check(await db.getBoolGlobalSettings()).isEmpty(); + check(await db.select(db.boolGlobalSettings).get()).isEmpty(); + }); + + test('insert replaces', () async { + final setting = BoolGlobalSetting.placeholderIgnore; + await db.into(db.boolGlobalSettings).insertOnConflictUpdate( + BoolGlobalSettingRow(name: setting.name, value: true)); + check(await db.getBoolGlobalSettings()) + .deepEquals({setting: true}); + + // As in doSetBoolGlobalSetting for `value` non-null. + await db.into(db.boolGlobalSettings).insertOnConflictUpdate( + BoolGlobalSettingRow(name: setting.name, value: false)); + check(await db.getBoolGlobalSettings()) + .deepEquals({setting: false}); + check(await db.select(db.boolGlobalSettings).get()).length.equals(1); + }); + + test('delete is idempotent', () async { + check(await db.getBoolGlobalSettings()).isEmpty(); + + // As in doSetBoolGlobalSetting for `value` null. + final setting = BoolGlobalSetting.placeholderIgnore; + final query = db.delete(db.boolGlobalSettings) + ..where((r) => r.name.equals(setting.name)); + await query.go(); + // (No error occurred, even though there was nothing to delete.) + check(await db.getBoolGlobalSettings()).isEmpty(); + check(await db.select(db.boolGlobalSettings).get()).isEmpty(); + }); + }); + + group('IntGlobalSettings', () { + test('get ignores unknown names', () async { + await db.into(db.intGlobalSettings) + .insert(IntGlobalSettingRow(name: 'nonsense', value: 1)); + check(await db.getIntGlobalSettings()).isEmpty(); + + final setting = IntGlobalSetting.placeholderIgnore; + await db.into(db.intGlobalSettings) + .insert(IntGlobalSettingRow(name: setting.name, value: 1)); + check(await db.getIntGlobalSettings()) + .deepEquals({setting: 1}); + }); + + test('insert, then get', () async { + check(await db.getIntGlobalSettings()).isEmpty(); + + // As in doSetIntGlobalSetting for `value` non-null. + final setting = IntGlobalSetting.placeholderIgnore; + await db.into(db.intGlobalSettings).insertOnConflictUpdate( + IntGlobalSettingRow(name: setting.name, value: 1)); + check(await db.getIntGlobalSettings()) + .deepEquals({setting: 1}); + check(await db.select(db.intGlobalSettings).get()).length.equals(1); + }); + + test('delete, then get', () async { + final setting = IntGlobalSetting.placeholderIgnore; + await db.into(db.intGlobalSettings).insertOnConflictUpdate( + IntGlobalSettingRow(name: setting.name, value: 1)); + check(await db.getIntGlobalSettings()).deepEquals({setting: 1}); + + // As in doSetIntGlobalSetting for `value` null. + final query = db.delete(db.intGlobalSettings) + ..where((r) => r.name.equals(setting.name)); + await query.go(); + check(await db.getIntGlobalSettings()).isEmpty(); + check(await db.select(db.intGlobalSettings).get()).isEmpty(); + }); + + test('insert replaces', () async { + final setting = IntGlobalSetting.placeholderIgnore; + await db.into(db.intGlobalSettings).insertOnConflictUpdate( + IntGlobalSettingRow(name: setting.name, value: 1)); + check(await db.getIntGlobalSettings()) + .deepEquals({setting: 1}); + + // As in doSetIntGlobalSetting for `value` non-null. + await db.into(db.intGlobalSettings).insertOnConflictUpdate( + IntGlobalSettingRow(name: setting.name, value: 2)); + check(await db.getIntGlobalSettings()) + .deepEquals({setting: 2}); + check(await db.select(db.intGlobalSettings).get()).length.equals(1); + }); + + test('delete is idempotent', () async { + check(await db.getIntGlobalSettings()).isEmpty(); + + // As in doSetIntGlobalSetting for `value` null. + final setting = IntGlobalSetting.placeholderIgnore; + final query = db.delete(db.intGlobalSettings) + ..where((r) => r.name.equals(setting.name)); + await query.go(); + // (No error occurred, even though there was nothing to delete.) + check(await db.getIntGlobalSettings()).isEmpty(); + check(await db.select(db.intGlobalSettings).get()).isEmpty(); + }); }); test('create account', () async { @@ -30,8 +196,8 @@ void main() { zulipMergeBase: const Value('6.0'), zulipFeatureLevel: 42, ); - final accountId = await database.createAccount(accountData); - final account = await (database.select(database.accounts) + final accountId = await db.createAccount(accountData); + final account = await (db.select(db.accounts) ..where((a) => a.id.equals(accountId))) .watchSingle() .first; @@ -61,8 +227,8 @@ void main() { zulipMergeBase: const Value('6.0'), zulipFeatureLevel: 42, ); - await database.createAccount(accountData); - await check(database.createAccount(accountDataWithSameUserId)) + await db.createAccount(accountData); + await check(db.createAccount(accountDataWithSameUserId)) .throws(); }); @@ -85,8 +251,8 @@ void main() { zulipMergeBase: const Value('6.0'), zulipFeatureLevel: 42, ); - await database.createAccount(accountData); - await check(database.createAccount(accountDataWithSameEmail)) + await db.createAccount(accountData); + await check(db.createAccount(accountDataWithSameEmail)) .throws(); }); }); @@ -98,11 +264,58 @@ void main() { verifier = SchemaVerifier(GeneratedHelper()); }); - test('upgrade to v2, empty', () async { - final connection = await verifier.startAt(1); - final db = AppDatabase(connection); - await verifier.migrateAndValidate(db, 2); - await db.close(); + test('downgrading', () async { + final toVersion = AppDatabase.latestSchemaVersion; + final schema = await verifier.schemaAt(toVersion); + + // This simulates the scenario during development when running the app + // with a future schema version that has additional tables and columns. + final before = AppDatabase(schema.newConnection()); + await before.customStatement('CREATE TABLE test_extra (num int)'); + await before.customStatement('ALTER TABLE accounts ADD extra_column int'); + await check(verifier.migrateAndValidate(before, toVersion, + options: const ValidationOptions(validateDropped: true))).throws(); + // Override the schema version by modifying the underlying value + // drift internally keeps track of in the database. + // TODO(drift): Expose a better interface for testing this. + await before.customStatement('PRAGMA user_version = ${toVersion + 1};'); + await before.close(); + + // Simulate starting up the app, with an older schema version that + // does not have the extra tables and columns. + final after = AppDatabase(schema.newConnection()); + await verifier.migrateAndValidate(after, toVersion, + options: const ValidationOptions(validateDropped: true)); + // Check that a custom migration/setup step of ours got run too. + check(await after.getGlobalSettings()).themeSetting.isNull(); + await after.close(); + }); + + group('migrate without data', () { + const versions = GeneratedHelper.versions; + final latestVersion = versions.last; + + int prev = versions.first; + for (final toVersion in versions.skip(1)) { + final fromVersion = prev; + test('from v$fromVersion to v$toVersion', () async { + final connection = await verifier.startAt(fromVersion); + final db = AppDatabase(connection); + await verifier.migrateAndValidate(db, toVersion); + await db.close(); + }); + prev = toVersion; + } + + for (final fromVersion in versions) { + if (fromVersion == latestVersion) break; + test('from v$fromVersion to latest (v$latestVersion)', () async { + final connection = await verifier.startAt(fromVersion); + final db = AppDatabase(connection); + await verifier.migrateAndValidate(db, latestVersion); + await db.close(); + }); + } }); test('upgrade to v2, with data', () async { @@ -130,6 +343,116 @@ void main() { ...accountV1.toJson(), 'ackedPushToken': null, }); + await after.close(); + }); + + test('upgrade to v4, with data', () async { + final schema = await verifier.schemaAt(3); + final before = v3.DatabaseAtV3(schema.newConnection()); + await before.into(before.globalSettings).insert( + v3.GlobalSettingsCompanion.insert( + themeSetting: Value(ThemeSetting.light.name))); + await before.close(); + + final db = AppDatabase(schema.newConnection()); + await verifier.migrateAndValidate(db, 4); + await db.close(); + + final after = v4.DatabaseAtV4(schema.newConnection()); + final globalSettings = await after.select(after.globalSettings).getSingle(); + check(globalSettings.themeSetting).equals(ThemeSetting.light.name); + check(globalSettings.browserPreference).isNull(); + await after.close(); + }); + + test('upgrade to v5: with existing GlobalSettings row, do nothing', () async { + final schema = await verifier.schemaAt(4); + final before = v4.DatabaseAtV4(schema.newConnection()); + await before.into(before.globalSettings).insert( + v4.GlobalSettingsCompanion.insert( + themeSetting: Value(ThemeSetting.light.name))); + await before.close(); + + final db = AppDatabase(schema.newConnection()); + await verifier.migrateAndValidate(db, 5); + await db.close(); + + final after = v5.DatabaseAtV5(schema.newConnection()); + final globalSettings = await after.select(after.globalSettings).getSingle(); + check(globalSettings.themeSetting).equals(ThemeSetting.light.name); + check(globalSettings.browserPreference).isNull(); + await after.close(); + }); + + test('upgrade to v5: with no existing GlobalSettings row, insert one', () async { + final schema = await verifier.schemaAt(4); + final before = v4.DatabaseAtV4(schema.newConnection()); + check(await before.select(before.globalSettings).get()).isEmpty(); + await before.close(); + + final db = AppDatabase(schema.newConnection()); + await verifier.migrateAndValidate(db, 5); + await db.close(); + + final after = v5.DatabaseAtV5(schema.newConnection()); + final globalSettings = await after.select(after.globalSettings).getSingle(); + check(globalSettings.themeSetting).isNull(); + check(globalSettings.browserPreference).isNull(); + await after.close(); + }); + + // TODO(#1593) test upgrade to v9: legacyUpgradeState set to noLegacy + + test('upgrade to v11: with accounts available, ' + 'insert first account ID as the last-visited account ID', () async { + final schema = await verifier.schemaAt(10); + final before = v10.DatabaseAtV10(schema.newConnection()); + final firstAccountId = await before.into(before.accounts).insert( + v10.AccountsCompanion.insert( + realmUrl: 'https://chat.example/', + userId: 1, + email: 'asdf@example.org', + apiKey: '1234', + zulipVersion: '10.0', + zulipMergeBase: const Value('10.0'), + zulipFeatureLevel: 370, + )); + await before.into(before.accounts).insert( + v10.AccountsCompanion.insert( + realmUrl: 'https://example.com/', + userId: 2, + email: 'jkl@example.com', + apiKey: '4321', + zulipVersion: '11.0', + zulipMergeBase: const Value('11.0'), + zulipFeatureLevel: 420, + )); + await before.close(); + + final db = AppDatabase(schema.newConnection()); + await verifier.migrateAndValidate(db, 11); + await db.close(); + + final after = v11.DatabaseAtV11(schema.newConnection()); + final intGlobalSettings = await after.select(after.intGlobalSettings).getSingle(); + check(intGlobalSettings.name).equals(IntGlobalSetting.lastVisitedAccountId.name); + check(intGlobalSettings.value).equals(firstAccountId); + await after.close(); + }); + + test("upgrade to v11: with no accounts available, don't set last-visited account ID", () async { + final schema = await verifier.schemaAt(10); + final before = v10.DatabaseAtV10(schema.newConnection()); + check(await before.select(before.accounts).get()).isEmpty(); + await before.close(); + + final db = AppDatabase(schema.newConnection()); + await verifier.migrateAndValidate(db, 11); + await db.close(); + + final after = v11.DatabaseAtV11(schema.newConnection()); + check(await after.select(after.intGlobalSettings).get()).isEmpty(); + await after.close(); }); }); } diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index a55f2dc36b..6ce03854b7 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -10,12 +10,30 @@ import 'package:zulip/model/store.dart'; import '../example_data.dart' as eg; void main() { + PerAccountStore prepare({ + Map realmEmoji = const {}, + bool addServerDataForPopular = true, + Map>? unicodeEmoji, + }) { + final store = eg.store( + initialSnapshot: eg.initialSnapshot(realmEmoji: realmEmoji)); + + if (addServerDataForPopular || unicodeEmoji != null) { + final extraEmojiData = ServerEmojiData(codeToNames: unicodeEmoji ?? {}); + final emojiData = addServerDataForPopular + ? eg.serverEmojiDataPopularPlus(extraEmojiData) + : extraEmojiData; + store.setServerEmojiData(emojiData); + } + return store; + } + group('emojiDisplayFor', () { test('Unicode emoji', () { check(eg.store().emojiDisplayFor(emojiType: ReactionType.unicodeEmoji, - emojiCode: '1f642', emojiName: 'smile') + emojiCode: '1f642', emojiName: 'slight_smile') ).isA() - ..emojiName.equals('smile') + ..emojiName.equals('slight_smile') ..emojiUnicode.equals('🙂'); }); @@ -78,7 +96,10 @@ void main() { }); }); - final popularCandidates = EmojiStore.popularEmojiCandidates; + final popularCandidates = ( + eg.store()..setServerEmojiData(eg.serverEmojiDataPopular) + ).popularEmojiCandidates(); + assert(popularCandidates.length == 6); Condition isUnicodeCandidate(String? emojiCode, List? names) { return (it_) { @@ -116,30 +137,11 @@ void main() { group('allEmojiCandidates', () { // TODO test emojiDisplay of candidates matches emojiDisplayFor - PerAccountStore prepare({ - Map realmEmoji = const {}, - Map>? unicodeEmoji, - }) { - final store = eg.store( - initialSnapshot: eg.initialSnapshot(realmEmoji: realmEmoji)); - if (unicodeEmoji != null) { - store.setServerEmojiData(ServerEmojiData(codeToNames: unicodeEmoji)); - } - return store; - } - - test('popular emoji appear even when no server emoji data', () { - final store = prepare(unicodeEmoji: null); - check(store.allEmojiCandidates()).deepEquals([ - ...arePopularCandidates, - isZulipCandidate(), - ]); - }); - test('popular emoji appear in their canonical order', () { // In the server's emoji data, have the popular emoji in a permuted order, // and interspersed with other emoji. - final store = prepare(unicodeEmoji: { + assert(popularCandidates.length == 6); + final store = prepare(addServerDataForPopular: false, unicodeEmoji: { '1f603': ['smiley'], for (final candidate in popularCandidates.skip(3)) candidate.emojiCode: [candidate.emojiName, ...candidate.aliases], @@ -246,15 +248,16 @@ void main() { }); test('updates on setServerEmojiData', () { - final store = prepare(); + final store = prepare(unicodeEmoji: null, addServerDataForPopular: false); + check(store.debugServerEmojiData).isNull(); check(store.allEmojiCandidates()).deepEquals([ - ...arePopularCandidates, isZulipCandidate(), ]); - store.setServerEmojiData(ServerEmojiData(codeToNames: { - '1f516': ['bookmark'], - })); + store.setServerEmojiData(eg.serverEmojiDataPopularPlus( + ServerEmojiData(codeToNames: { + '1f516': ['bookmark'], + }))); check(store.allEmojiCandidates()).deepEquals([ ...arePopularCandidates, isUnicodeCandidate('1f516', ['bookmark']), @@ -262,14 +265,14 @@ void main() { ]); }); - test('updates on RealmEmojiUpdateEvent', () { + test('updates on RealmEmojiUpdateEvent', () async { final store = prepare(); check(store.allEmojiCandidates()).deepEquals([ ...arePopularCandidates, isZulipCandidate(), ]); - store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: { + await store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: { '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy'), })); check(store.allEmojiCandidates()).deepEquals([ @@ -290,6 +293,68 @@ void main() { }); }); + group('popularEmojiCandidates', () { + test('memoizes result, before setServerEmojiData', () { + final store = eg.store(); + check(store.debugServerEmojiData).isNull(); + final candidates = store.popularEmojiCandidates(); + check(store.popularEmojiCandidates()) + ..isEmpty()..identicalTo(candidates); + }); + + test('memoizes result, after setServerEmojiData', () { + final store = prepare(); + check(store.debugServerEmojiData).isNotNull(); + final candidates = store.popularEmojiCandidates(); + check(store.popularEmojiCandidates()) + ..isNotEmpty()..identicalTo(candidates); + }); + + test('updates on first and subsequent setServerEmojiData', () { + final store = eg.store(); + check(store.debugServerEmojiData).isNull(); + + final candidates1 = store.popularEmojiCandidates(); + check(candidates1).isEmpty(); + + store.setServerEmojiData(eg.serverEmojiDataPopularLegacy); + final candidates2 = store.popularEmojiCandidates(); + check(candidates2) + ..isNotEmpty() + ..not((it) => it.identicalTo(candidates1)); + + store.setServerEmojiData(eg.serverEmojiDataPopular); + final candidates3 = store.popularEmojiCandidates(); + check(candidates3) + ..isNotEmpty() + ..not((it) => it.identicalTo(candidates2)); + }); + }); + + group('getUnicodeEmojiNameByCode', () { + test('happy path', () { + final store = prepare(unicodeEmoji: { + '1f4c5': ['calendar'], + '1f34a': ['orange', 'tangerine', 'mandarin'], + }); + check(store.getUnicodeEmojiNameByCode('1f4c5')).equals('calendar'); + check(store.getUnicodeEmojiNameByCode('1f34a')).equals('orange'); + }); + + test('server emoji data present, emoji code not present', () { + final store = prepare(unicodeEmoji: { + '1f4c5': ['calendar'], + }); + check(store.getUnicodeEmojiNameByCode('1f34a')).isNull(); + }); + + test('server emoji data is not present', () { + final store = prepare(addServerDataForPopular: false); + check(store.debugServerEmojiData).isNull(); + check(store.getUnicodeEmojiNameByCode('1f516')).isNull(); + }); + }); + group('EmojiAutocompleteView', () { Condition isUnicodeResult({String? emojiCode, List? names}) { return (it) => it.isA().candidate.which( @@ -309,24 +374,11 @@ void main() { List> arePopularResults = popularCandidates.map( (c) => isUnicodeResult(emojiCode: c.emojiCode)).toList(); - PerAccountStore prepare({ - Map realmEmoji = const {}, - Map>? unicodeEmoji, - }) { - final store = eg.store( - initialSnapshot: eg.initialSnapshot(realmEmoji: { - for (final MapEntry(:key, :value) in realmEmoji.entries) - key: eg.realmEmojiItem(emojiCode: key, emojiName: value), - })); - if (unicodeEmoji != null) { - store.setServerEmojiData(ServerEmojiData(codeToNames: unicodeEmoji)); - } - return store; - } - test('results can include all three emoji types', () async { final store = prepare( - realmEmoji: {'1': 'happy'}, unicodeEmoji: {'1f516': ['bookmark']}); + realmEmoji: {'1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy')}, + unicodeEmoji: {'1f516': ['bookmark']}, + ); final view = EmojiAutocompleteView.init(store: store, query: EmojiAutocompleteQuery('')); bool done = false; @@ -343,7 +395,8 @@ void main() { test('results update after query change', () async { final store = prepare( - realmEmoji: {'1': 'happy'}, unicodeEmoji: {'1f642': ['smile']}); + realmEmoji: {'1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy')}, + unicodeEmoji: {'1f516': ['bookmark']}); final view = EmojiAutocompleteView.init(store: store, query: EmojiAutocompleteQuery('hap')); bool done = false; @@ -354,16 +407,16 @@ void main() { isRealmResult(emojiName: 'happy')); done = false; - view.query = EmojiAutocompleteQuery('sm'); + view.query = EmojiAutocompleteQuery('bo'); await Future(() {}); check(done).isTrue(); check(view.results).single.which( - isUnicodeResult(names: ['smile'])); + isUnicodeResult(names: ['bookmark'])); }); Future> resultsOf( String query, { - Map realmEmoji = const {}, + Map realmEmoji = const {}, Map>? unicodeEmoji, }) async { final store = prepare(realmEmoji: realmEmoji, unicodeEmoji: unicodeEmoji); @@ -389,7 +442,7 @@ void main() { check(await resultsOf('')).deepEquals([ isUnicodeResult(names: ['+1', 'thumbs_up', 'like']), isUnicodeResult(names: ['tada']), - isUnicodeResult(names: ['smile']), + isUnicodeResult(names: ['slight_smile']), isUnicodeResult(names: ['heart', 'love', 'love_you']), isUnicodeResult(names: ['working_on_it', 'hammer_and_wrench', 'tools']), isUnicodeResult(names: ['octopus']), @@ -402,6 +455,7 @@ void main() { isUnicodeResult(names: ['tada']), isUnicodeResult(names: ['working_on_it', 'hammer_and_wrench', 'tools']), // other + isUnicodeResult(names: ['slight_smile']), isUnicodeResult(names: ['heart', 'love', 'love_you']), isUnicodeResult(names: ['octopus']), ]); @@ -412,6 +466,7 @@ void main() { isUnicodeResult(names: ['working_on_it', 'hammer_and_wrench', 'tools']), // other isUnicodeResult(names: ['+1', 'thumbs_up', 'like']), + isUnicodeResult(names: ['slight_smile']), ]); }); @@ -503,8 +558,10 @@ void main() { check(matchOfName('blue dia', 'large_blue_diamond')).wordAligned; }); - test('query is lower-cased', () { + test('case-insensitive', () { check(matchOfName('Smi', 'smile')).prefix; + check(matchOfName('smi', 'SMILE')).prefix; + check(matchOfName('SmI', 'sMiLe')).prefix; }); test('query matches aliases same way as primary name', () { @@ -522,6 +579,8 @@ void main() { check(matchOfNames('blue_dia', ['x', 'large_blue_diamond'])).wordAligned; check(matchOfNames('Smi', ['x', 'smile'])).prefix; + check(matchOfNames('smi', ['x', 'SMILE'])).prefix; + check(matchOfNames('SmI', ['x', 'sMiLe'])).prefix; }); test('best match among name and aliases prevails', () { diff --git a/test/model/internal_link_test.dart b/test/model/internal_link_test.dart index eb91e35855..74345f9811 100644 --- a/test/model/internal_link_test.dart +++ b/test/model/internal_link_test.dart @@ -60,15 +60,16 @@ void main() { .equals(store.realmUrl.resolve('#narrow/is/starred/near/1')); }); - test('ChannelNarrow / TopicNarrow', () { + group('ChannelNarrow / TopicNarrow', () { void checkNarrow(String expectedFragment, { required int streamId, required String name, String? topic, int? nearMessageId, + int? zulipFeatureLevel = eg.futureZulipFeatureLevel, }) async { assert(expectedFragment.startsWith('#'), 'wrong-looking expectedFragment'); - final store = eg.store(); + final store = eg.store()..connection.zulipFeatureLevel = zulipFeatureLevel; await store.addStream(eg.stream(streamId: streamId, name: name)); final narrow = topic == null ? ChannelNarrow(streamId) @@ -77,22 +78,32 @@ void main() { .equals(store.realmUrl.resolve(expectedFragment)); } - checkNarrow(streamId: 1, name: 'announce', '#narrow/stream/1-announce'); - checkNarrow(streamId: 378, name: 'api design', '#narrow/stream/378-api-design'); - checkNarrow(streamId: 391, name: 'Outreachy', '#narrow/stream/391-Outreachy'); - checkNarrow(streamId: 415, name: 'chat.zulip.org', '#narrow/stream/415-chat.2Ezulip.2Eorg'); - checkNarrow(streamId: 419, name: 'français', '#narrow/stream/419-fran.C3.A7ais'); - checkNarrow(streamId: 403, name: 'Hshs[™~}(.', '#narrow/stream/403-Hshs.5B.E2.84.A2~.7D.28.2E'); - checkNarrow(streamId: 60, name: 'twitter', nearMessageId: 1570686, '#narrow/stream/60-twitter/near/1570686'); - - checkNarrow(streamId: 48, name: 'mobile', topic: 'Welcome screen UI', - '#narrow/stream/48-mobile/topic/Welcome.20screen.20UI'); - checkNarrow(streamId: 243, name: 'mobile-team', topic: 'Podfile.lock clash #F92', - '#narrow/stream/243-mobile-team/topic/Podfile.2Elock.20clash.20.23F92'); - checkNarrow(streamId: 377, name: 'translation/zh_tw', topic: '翻譯 "stream"', - '#narrow/stream/377-translation.2Fzh_tw/topic/.E7.BF.BB.E8.AD.AF.20.22stream.22'); - checkNarrow(streamId: 42, name: 'Outreachy 2016-2017', topic: '2017-18 Stream?', nearMessageId: 302690, - '#narrow/stream/42-Outreachy-2016-2017/topic/2017-18.20Stream.3F/near/302690'); + test('modern including "channel" operator', () { + checkNarrow(streamId: 1, name: 'announce', '#narrow/channel/1-announce'); + checkNarrow(streamId: 378, name: 'api design', '#narrow/channel/378-api-design'); + checkNarrow(streamId: 391, name: 'Outreachy', '#narrow/channel/391-Outreachy'); + checkNarrow(streamId: 415, name: 'chat.zulip.org', '#narrow/channel/415-chat.2Ezulip.2Eorg'); + checkNarrow(streamId: 419, name: 'français', '#narrow/channel/419-fran.C3.A7ais'); + checkNarrow(streamId: 403, name: 'Hshs[™~}(.', '#narrow/channel/403-Hshs.5B.E2.84.A2~.7D.28.2E'); + checkNarrow(streamId: 60, name: 'twitter', nearMessageId: 1570686, '#narrow/channel/60-twitter/near/1570686'); + + checkNarrow(streamId: 48, name: 'mobile', topic: 'Welcome screen UI', + '#narrow/channel/48-mobile/topic/Welcome.20screen.20UI'); + checkNarrow(streamId: 243, name: 'mobile-team', topic: 'Podfile.lock clash #F92', + '#narrow/channel/243-mobile-team/topic/Podfile.2Elock.20clash.20.23F92'); + checkNarrow(streamId: 377, name: 'translation/zh_tw', topic: '翻譯 "stream"', + '#narrow/channel/377-translation.2Fzh_tw/topic/.E7.BF.BB.E8.AD.AF.20.22stream.22'); + checkNarrow(streamId: 42, name: 'Outreachy 2016-2017', topic: '2017-18 Stream?', nearMessageId: 302690, + '#narrow/channel/42-Outreachy-2016-2017/topic/2017-18.20Stream.3F/near/302690'); + }); + + test('legacy including "stream" operator', () { + checkNarrow(streamId: 1, name: 'announce', zulipFeatureLevel: 249, + '#narrow/stream/1-announce'); + checkNarrow(streamId: 48, name: 'mobile-team', topic: 'Welcome screen UI', + zulipFeatureLevel: 249, + '#narrow/stream/48-mobile-team/topic/Welcome.20screen.20UI'); + }); }); test('DmNarrow', () { @@ -160,7 +171,14 @@ void main() { test(urlString, () async { final store = await setupStore(realmUrl: realmUrl, streams: streams, users: users); final url = store.tryResolveUrl(urlString)!; - check(parseInternalLink(url, store)).equals(expected); + final result = parseInternalLink(url, store); + if (expected == null) { + check(result).isNull(); + } else { + check(result).isA() + ..realmUrl.equals(realmUrl) + ..narrow.equals(expected); + } }); } } @@ -258,6 +276,9 @@ void main() { final url = store.tryResolveUrl(urlString)!; final result = parseInternalLink(url, store); check(result != null).equals(expected); + if (result != null) { + check(result).realmUrl.equals(realmUrl); + } }); } } @@ -284,13 +305,14 @@ void main() { 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/subject/topic/with/1', eg.topicNarrow(3, 'topic', with_: 1)), ('/#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/topic/topic/with/22', eg.topicNarrow(5, 'topic', with_: 22)), ('/#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/with/333', eg.topicNarrow(5, 'topic', with_: 333)), ('/#narrow/stream/stream/subject/topic', eg.topicNarrow(5, 'topic')), + ('/#narrow/stream/stream/subject/topic/with/asdf', null), // invalid `with` ]; testExpectedNarrows(testCases, streams: streams); }); @@ -313,7 +335,7 @@ void main() { final testCases = [ ('/#narrow/dm/1,2-group', expectedNarrow), ('/#narrow/dm/1,2-group/near/1', expectedNarrow), - ('/#narrow/dm/1,2-group/with/2', expectedNarrow), + ('/#narrow/dm/1,2-group/with/2', null), ('/#narrow/dm/a.40b.2Ecom.2Ec.2Ed.2Ecom/near/3', null), ('/#narrow/dm/a.40b.2Ecom.2Ec.2Ed.2Ecom/with/4', null), ]; @@ -326,7 +348,7 @@ void main() { final testCases = [ ('/#narrow/pm-with/1,2-group', expectedNarrow), ('/#narrow/pm-with/1,2-group/near/1', expectedNarrow), - ('/#narrow/pm-with/1,2-group/with/2', expectedNarrow), + ('/#narrow/pm-with/1,2-group/with/2', null), ('/#narrow/pm-with/a.40b.2Ecom.2Ec.2Ed.2Ecom/near/3', null), ('/#narrow/pm-with/a.40b.2Ecom.2Ec.2Ed.2Ecom/with/3', null), ]; @@ -342,7 +364,7 @@ void main() { ('/#narrow/is/$operand', narrow), ('/#narrow/is/$operand/is/$operand', narrow), ('/#narrow/is/$operand/near/1', narrow), - ('/#narrow/is/$operand/with/2', narrow), + ('/#narrow/is/$operand/with/2', null), ('/#narrow/channel/7-test-here/is/$operand', null), ('/#narrow/channel/check/topic/test/is/$operand', null), ('/#narrow/topic/test/is/$operand', null), @@ -369,6 +391,8 @@ void main() { } }); + // TODO(#1570): test parsing /near/ operator + group('unexpected link shapes are rejected', () { final testCases = [ ('/#narrow/stream/name/topic/', null), // missing operand @@ -563,3 +587,11 @@ void main() { }); }); } + +extension InternalLinkChecks on Subject { + Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); +} + +extension NarrowLinkChecks on Subject { + Subject get narrow => has((x) => x.narrow, 'narrow'); +} diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart new file mode 100644 index 0000000000..6ebc832e02 --- /dev/null +++ b/test/model/katex_test.dart @@ -0,0 +1,824 @@ +import 'dart:io'; + +import 'package:checks/checks.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test_api/scaffolding.dart'; +import 'package:zulip/model/content.dart'; +import 'package:zulip/model/katex.dart'; + +import 'binding.dart'; +import 'content_test.dart'; + +/// An example of KaTeX Zulip content for test cases. +/// +/// For guidance on writing examples, see comments on [ContentExample]. +class KatexExample extends ContentExample { + KatexExample.inline(String description, String texSource, String html, + List? expectedNodes) + : super.inline(description, '\$\$ $texSource \$\$', html, + MathInlineNode(texSource: texSource, nodes: expectedNodes)); + + KatexExample.block(String description, String texSource, String html, + List? expectedNodes) + : super(description, '```math\n$texSource\n```', html, + [MathBlockNode(texSource: texSource, nodes: expectedNodes)]); + + // The font sizes can be compared using the katex.css generated + // from katex.scss : + // https://unpkg.com/katex@0.16.21/dist/katex.css + static final sizing = KatexExample.block( + 'different font sizes', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2155476 + '\\Huge 1\n\\huge 2\n\\LARGE 3\n\\Large 4\n\\large 5\n\\normalsize 6\n\\small 7\n\\footnotesize 8\n\\scriptsize 9\n\\tiny 0', + '

        ' + '' + '1234567890' + '\\Huge 1\n\\huge 2\n\\LARGE 3\n\\Large 4\n\\large 5\n\\normalsize 6\n\\small 7\n\\footnotesize 8\n\\scriptsize 9\n\\tiny 0' + '

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        ' + '' + 'XnX_n' + '

        ', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.8333, verticalAlignEm: -0.15), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + marginRightEm: 0.07847, + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'X'), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.55 + 2.7, + node: KatexSpanNode(nodes: [ + KatexNegativeMarginNode(leftOffsetEm: -0.0785, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'n'), + ]), + ]), + ]), + ])), + ]), + ]), + ]), + ]), + ]); + + static final color = KatexExample.block( + r'\color: 3-digit hex color', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2232197 + r'\color{#f00} 0', + '

        ' + '' + '0\\color{#f00} 0' + '

        ', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(color: KatexSpanColor(255, 0, 0, 255)), + text: '0'), + ]), + ]); + + static final textColor = KatexExample.block( + r'\textcolor: CSS named color', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2232198 + r'\textcolor{red} 1', + '

        ' + '' + '1\\textcolor{red} 1' + '

        ', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(color: KatexSpanColor(255, 0, 0, 255)), + text: '1'), + ]), + ]); + + // KaTeX custom color macros, see https://github.com/KaTeX/KaTeX/blob/9fb63136e/src/macros.js#L977-L1033 + static final customColorMacro = KatexExample.block( + r'\red, custom KaTeX color macro: CSS 6-digit hex color', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2232199 + r'\red 2', + '

        ' + '' + '2\\red 2' + '

        ', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(color: KatexSpanColor(223, 0, 48, 255)), + text: '2'), + ]), + ]); + + static final phantom = KatexExample.block( + r'\phantom: span with "color: transparent"', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2229515 + r'\phantom{*}', + '

        ' + '' + '\\phantom{*}' + '

        ', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.4653, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(color: KatexSpanColor(0, 0, 0, 0)), + text: '∗'), + ]), + ]); + + static final bigOperators = KatexExample.block( + r'big operators: \int', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2240766 + r'\int', + '

        ' + '' + '\\int' + '

        ', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 2.2222, verticalAlignEm: -0.8622), + KatexSpanNode( + styles: KatexSpanStyles( + topEm: -0.0011, + marginRightEm: 0.44445, + fontFamily: 'KaTeX_Size2', + position: KatexSpanPosition.relative), + text: '∫'), + ]), + ]); + + static final colonEquals = KatexExample.block( + r'\colonequals relation', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2244936 + r'\colonequals', + '

        ' + '' + '\\colonequals' + '

        ', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), + KatexSpanNode(nodes: [ + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(topEm: -0.0347, position: KatexSpanPosition.relative), + text: ':'), + ]), + KatexSpanNode(nodes: [ + KatexSpanNode(nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.0667, nodes: []), + ]), + KatexSpanNode(text: '='), + ]), + ]), + ]); + + static final nulldelimiter = KatexExample.block( + r'null delimiters, like `\left.`', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2205534 + r'\left. a \middle. b \right.', + '

        ' + '' + 'a.b\\left. a \\middle. b \\right.' + '

        ', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), + KatexSpanNode(nodes: [ + KatexSpanNode(styles: KatexSpanStyles(widthEm: 0.12), nodes: []), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'a'), + KatexSpanNode(styles: KatexSpanStyles(widthEm: 0.12), nodes: []), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'b'), + KatexSpanNode(styles: KatexSpanStyles(widthEm: 0.12), nodes: []), + ]), + ]), + ]); +} + +void main() async { + TestZulipBinding.ensureInitialized(); + + testParseExample(KatexExample.sizing); + testParseExample(KatexExample.nestedSizing); + testParseExample(KatexExample.delimsizing); + testParseExample(KatexExample.spacing); + testParseExample(KatexExample.vlistSuperscript); + testParseExample(KatexExample.vlistSubscript); + testParseExample(KatexExample.vlistSubAndSuperscript); + testParseExample(KatexExample.vlistRaisebox); + testParseExample(KatexExample.negativeMargin); + testParseExample(KatexExample.katexLogo); + testParseExample(KatexExample.vlistNegativeMargin); + testParseExample(KatexExample.color); + testParseExample(KatexExample.textColor); + testParseExample(KatexExample.customColorMacro); + testParseExample(KatexExample.phantom); + testParseExample(KatexExample.bigOperators); + testParseExample(KatexExample.colonEquals); + testParseExample(KatexExample.nulldelimiter); + + group('parseCssHexColor', () { + const testCases = [ + ('#c0c0c0ff', KatexSpanColor(192, 192, 192, 255)), + ('#f00ba4', KatexSpanColor(240, 11, 164, 255)), + ('#cafe', KatexSpanColor(204, 170, 255, 238)), + + ('#ffffffff', KatexSpanColor(255, 255, 255, 255)), + ('#ffffff', KatexSpanColor(255, 255, 255, 255)), + ('#ffff', KatexSpanColor(255, 255, 255, 255)), + ('#fff', KatexSpanColor(255, 255, 255, 255)), + ('#00ffffff', KatexSpanColor(0, 255, 255, 255)), + ('#00ffff', KatexSpanColor(0, 255, 255, 255)), + ('#0fff', KatexSpanColor(0, 255, 255, 255)), + ('#0ff', KatexSpanColor(0, 255, 255, 255)), + ('#ff00ffff', KatexSpanColor(255, 0, 255, 255)), + ('#ff00ff', KatexSpanColor(255, 0, 255, 255)), + ('#f0ff', KatexSpanColor(255, 0, 255, 255)), + ('#f0f', KatexSpanColor(255, 0, 255, 255)), + ('#ffff00ff', KatexSpanColor(255, 255, 0, 255)), + ('#ffff00', KatexSpanColor(255, 255, 0, 255)), + ('#ff0f', KatexSpanColor(255, 255, 0, 255)), + ('#ff0', KatexSpanColor(255, 255, 0, 255)), + ('#ffffff00', KatexSpanColor(255, 255, 255, 0)), + ('#fff0', KatexSpanColor(255, 255, 255, 0)), + + ('#FF00FFFF', KatexSpanColor(255, 0, 255, 255)), + ('#FF00FF', KatexSpanColor(255, 0, 255, 255)), + + ('#ff00FFff', KatexSpanColor(255, 0, 255, 255)), + ('#ff00FF', KatexSpanColor(255, 0, 255, 255)), + + ('#F', null), + ('#FF', null), + ('#FFFFF', null), + ('#FFFFFFF', null), + ('FFF', null), + ]; + + for (final testCase in testCases) { + test(testCase.$1, () { + check(parseCssHexColor(testCase.$1)).equals(testCase.$2); + }); + } + }); + + test('all KaTeX content examples are tested', () { + // Check that every KatexExample defined above has a corresponding + // actual test case that runs on it. If you've added a new example + // and this test breaks, remember to add a `testParseExample` call for it. + + // This implementation is a bit of a hack; it'd be cleaner to get the + // actual Dart parse tree using package:analyzer. Unfortunately that + // approach takes several seconds just to load the parser library, enough + // to add noticeably to the runtime of our whole test suite. + final thisFilename = Trace.current().frames[0].uri.path; + final source = File(thisFilename).readAsStringSync(); + final declaredExamples = RegExp(multiLine: true, + r'^\s*static\s+(?:const|final)\s+(\w+)\s*=\s*KatexExample\s*(?:\.\s*(?:inline|block)\s*)?\(', + ).allMatches(source).map((m) => m.group(1)); + final testedExamples = RegExp(multiLine: true, + r'^\s*testParseExample\s*\(\s*KatexExample\s*\.\s*(\w+)(?:,\s*skip:\s*true)?\s*\);', + ).allMatches(source).map((m) => m.group(1)); + check(testedExamples).unorderedEquals(declaredExamples); + }, skip: Platform.isWindows, // [intended] purely analyzes source, so + // any one platform is enough; avoid dealing with Windows file paths + ); +} diff --git a/test/model/message_checks.dart b/test/model/message_checks.dart new file mode 100644 index 0000000000..b56cd89a79 --- /dev/null +++ b/test/model/message_checks.dart @@ -0,0 +1,9 @@ +import 'package:checks/checks.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/message.dart'; + +extension OutboxMessageChecks on Subject> { + Subject get localMessageId => has((x) => x.localMessageId, 'localMessageId'); + Subject get state => has((x) => x.state, 'state'); + Subject get hidden => has((x) => x.hidden, 'hidden'); +} diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 6a1d103c84..9cb4977a32 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -1,6 +1,11 @@ import 'dart:convert'; +import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/foundation.dart'; +import 'package:clock/clock.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/backoff.dart'; @@ -8,8 +13,10 @@ import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; +import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/algorithms.dart'; import 'package:zulip/model/content.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -19,14 +26,38 @@ import '../api/model/model_checks.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; import '../stdlib_checks.dart'; +import 'binding.dart'; import 'content_checks.dart'; +import 'message_checks.dart'; import 'recent_senders_test.dart' as recent_senders_test; import 'test_store.dart'; const newestResult = eg.newestGetMessagesResult; +const nearResult = eg.nearGetMessagesResult; const olderResult = eg.olderGetMessagesResult; +const newerResult = eg.newerGetMessagesResult; void main() { + // Arrange for errors caught within the Flutter framework to be printed + // unconditionally, rather than throttled as they normally are in an app. + // + // When using `testWidgets` from flutter_test, this is done automatically; + // compare the [FlutterError.dumpErrorToConsole] call sites, + // and [FlutterError.onError=] and [debugPrint=] call sites, in flutter_test. + // + // This test file is unusual in needing this manual arrangement; it's needed + // because these aren't widget tests, and yet do have some failures arise as + // exceptions that get caught by the framework: namely, when [checkInvariants] + // throws from within an `addListener` callback. Those exceptions get caught + // by [ChangeNotifier.notifyListeners] and reported there through + // [FlutterError.reportError]. + debugPrint = debugPrintSynchronously; + FlutterError.onError = (details) { + FlutterError.dumpErrorToConsole(details, forceReport: true); + }; + + TestZulipBinding.ensureInitialized(); + // These variables are the common state operated on by each test. // Each test case calls [prepare] to initialize them. late Subscription subscription; @@ -46,15 +77,25 @@ void main() { void checkNotifiedOnce() => checkNotified(count: 1); /// Initialize [model] and the rest of the test state. - Future prepare({Narrow narrow = const CombinedFeedNarrow()}) async { - final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); + Future prepare({ + Narrow narrow = const CombinedFeedNarrow(), + Anchor anchor = AnchorCode.newest, + ZulipStream? stream, + List? users, + List? mutedUserIds, + }) async { + stream ??= eg.stream(streamId: eg.defaultStreamMessageStreamId); subscription = eg.subscription(stream); store = eg.store(); await store.addStream(stream); await store.addSubscription(subscription); + await store.addUsers([...?users, eg.selfUser]); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } connection = store.connection as FakeApiConnection; notifiedCount = 0; - model = MessageListView.init(store: store, narrow: narrow) + model = MessageListView.init(store: store, narrow: narrow, anchor: anchor) ..addListener(() { checkInvariants(model); notifiedCount++; @@ -67,286 +108,840 @@ void main() { /// /// The test case must have already called [prepare] to initialize the state. Future prepareMessages({ - required bool foundOldest, + bool? foundOldest, + bool? foundNewest, + int? anchorMessageId, required List messages, }) async { - connection.prepare(json: - newestResult(foundOldest: foundOldest, messages: messages).toJson()); + final result = eg.getMessagesResult( + anchor: model.anchor == AnchorCode.firstUnread + ? NumericAnchor(anchorMessageId!) : model.anchor, + foundOldest: foundOldest, + foundNewest: foundNewest, + messages: messages); + connection.prepare(json: result.toJson()); await model.fetchInitial(); checkNotifiedOnce(); } + Future prepareOutboxMessages({ + required int count, + required ZulipStream stream, + String topic = 'some topic', + }) async { + for (int i = 0; i < count; i++) { + connection.prepare(json: SendMessageResult(id: 123).toJson()); + await store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t(topic)), + content: 'content'); + } + } + + Future prepareOutboxMessagesTo(List destinations) async { + for (final destination in destinations) { + connection.prepare(json: SendMessageResult(id: 123).toJson()); + await store.sendMessage(destination: destination, content: 'content'); + } + } + void checkLastRequest({ required ApiNarrow narrow, required String anchor, bool? includeAnchor, required int numBefore, required int numAfter, + required bool allowEmptyTopicName, }) { check(connection.lastRequest).isA() ..method.equals('GET') ..url.path.equals('/api/v1/messages') ..url.queryParameters.deepEquals({ - 'narrow': jsonEncode(narrow), + 'narrow': jsonEncode(resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!)), 'anchor': anchor, if (includeAnchor != null) 'include_anchor': includeAnchor.toString(), 'num_before': numBefore.toString(), 'num_after': numAfter.toString(), + 'allow_empty_topic_name': allowEmptyTopicName.toString(), }); } - test('fetchInitial', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - connection.prepare(json: newestResult( - foundOldest: false, - messages: List.generate(kMessageListFetchBatchSize, - (i) => eg.streamMessage()), - ).toJson()); - final fetchFuture = model.fetchInitial(); - check(model).fetched.isFalse(); + void checkHasMessageIds(Iterable messageIds) { + check(model.messages.map((m) => m.id)).deepEquals(messageIds); + } - checkNotNotified(); - await fetchFuture; - checkNotifiedOnce(); - check(model) - ..messages.length.equals(kMessageListFetchBatchSize) - ..haveOldest.isFalse(); - checkLastRequest( - narrow: narrow.apiEncode(), - anchor: 'newest', - numBefore: kMessageListFetchBatchSize, - numAfter: 0, - ); - }); + void checkHasMessages(Iterable messages) { + checkHasMessageIds(messages.map((e) => e.id)); + } - test('fetchInitial, short history', () async { - await prepare(); - connection.prepare(json: newestResult( - foundOldest: true, - messages: List.generate(30, (i) => eg.streamMessage()), - ).toJson()); - await model.fetchInitial(); - checkNotifiedOnce(); - check(model) - ..messages.length.equals(30) - ..haveOldest.isTrue(); - }); + group('fetchInitial', () { + final someChannel = eg.stream(); + const someTopic = 'some topic'; - test('fetchInitial, no messages found', () async { - await prepare(); - connection.prepare(json: newestResult( - foundOldest: true, - messages: [], - ).toJson()); - await model.fetchInitial(); - checkNotifiedOnce(); - check(model) - ..fetched.isTrue() - ..messages.isEmpty() - ..haveOldest.isTrue(); - }); + final otherChannel = eg.stream(); + const otherTopic = 'other topic'; - // TODO(#824): move this test - test('fetchInitial, recent senders track all the messages', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - final messages = [ - eg.streamMessage(), - // Not subscribed to the stream with id 10. - eg.streamMessage(stream: eg.stream(streamId: 10)), - ]; - connection.prepare(json: newestResult( - foundOldest: false, - messages: messages, - ).toJson()); - await model.fetchInitial(); + group('smoke', () { + Future smoke( + Narrow narrow, + Message Function(int i) generateMessages, + ) async { + await prepare(narrow: narrow); + connection.prepare(json: newestResult( + foundOldest: false, + messages: List.generate(kMessageListFetchBatchSize, generateMessages), + ).toJson()); + final fetchFuture = model.fetchInitial(); + check(model).fetched.isFalse(); - check(model).messages.length.equals(1); - recent_senders_test.checkMatchesMessages(store.recentSenders, messages); - }); + checkNotNotified(); + await fetchFuture; + checkNotifiedOnce(); + check(model) + ..messages.length.equals(kMessageListFetchBatchSize) + ..haveOldest.isFalse() + ..haveNewest.isTrue(); + checkLastRequest( + narrow: narrow.apiEncode(), + anchor: 'newest', + numBefore: kMessageListFetchBatchSize, + numAfter: kMessageListFetchBatchSize, + allowEmptyTopicName: true, + ); + } - test('fetchOlder', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - await prepareMessages(foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); + test('CombinedFeedNarrow', () async { + await smoke(const CombinedFeedNarrow(), (i) => eg.streamMessage()); + }); - connection.prepare(json: olderResult( - anchor: 1000, foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 900 + i)), - ).toJson()); - final fetchFuture = model.fetchOlder(); - checkNotifiedOnce(); - check(model).fetchingOlder.isTrue(); + test('TopicNarrow', () async { + await smoke(TopicNarrow(someChannel.streamId, eg.t(someTopic)), + (i) => eg.streamMessage(stream: someChannel, topic: someTopic)); + }); + }); - await fetchFuture; - checkNotifiedOnce(); - check(model) - ..fetchingOlder.isFalse() - ..messages.length.equals(200); - checkLastRequest( - narrow: narrow.apiEncode(), - anchor: '1000', - includeAnchor: false, - numBefore: kMessageListFetchBatchSize, - numAfter: 0, - ); - }); + test('short history', () async { + await prepare(); + connection.prepare(json: newestResult( + foundOldest: true, + messages: List.generate(30, (i) => eg.streamMessage()), + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(30) + ..haveOldest.isTrue() + ..haveNewest.isTrue(); + }); - test('fetchOlder nop when already fetching', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - await prepareMessages(foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); + test('early in history', () async { + await prepare(anchor: NumericAnchor(1000)); + connection.prepare(json: nearResult( + anchor: 1000, foundOldest: true, foundNewest: false, + messages: List.generate(111, (i) => eg.streamMessage(id: 990 + i)), + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(111) + ..haveOldest.isTrue() + ..haveNewest.isFalse(); + }); - connection.prepare(json: olderResult( - anchor: 1000, foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 900 + i)), - ).toJson()); - final fetchFuture = model.fetchOlder(); - checkNotifiedOnce(); - check(model).fetchingOlder.isTrue(); + test('no messages found', () async { + await prepare(); + connection.prepare(json: newestResult( + foundOldest: true, + messages: [], + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..fetched.isTrue() + ..messages.isEmpty() + ..haveOldest.isTrue() + ..haveNewest.isTrue(); + }); - // Don't prepare another response. - final fetchFuture2 = model.fetchOlder(); - checkNotNotified(); - check(model).fetchingOlder.isTrue(); + group('sends proper anchor', () { + Future checkFetchWithAnchor(Anchor anchor) async { + await prepare(anchor: anchor); + // This prepared response isn't entirely realistic, depending on the anchor. + // That's OK; these particular tests don't use the details of the response. + connection.prepare(json: + newestResult(foundOldest: true, messages: []).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(connection.lastRequest).isA() + .url.queryParameters['anchor'] + .equals(anchor.toJson()); + } - await fetchFuture; - await fetchFuture2; - // We must not have made another request, because we didn't - // prepare another response and didn't get an exception. - checkNotifiedOnce(); - check(model) - ..fetchingOlder.isFalse() - ..messages.length.equals(200); - }); + test('oldest', () => checkFetchWithAnchor(AnchorCode.oldest)); + test('firstUnread', () => checkFetchWithAnchor(AnchorCode.firstUnread)); + test('newest', () => checkFetchWithAnchor(AnchorCode.newest)); + test('numeric', () => checkFetchWithAnchor(NumericAnchor(12345))); + }); - test('fetchOlder nop when already haveOldest true', () async { - await prepare(narrow: const CombinedFeedNarrow()); - await prepareMessages(foundOldest: true, messages: - List.generate(30, (i) => eg.streamMessage())); - check(model) - ..haveOldest.isTrue() - ..messages.length.equals(30); + test('no messages found in fetch; outbox messages present', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); - await model.fetchOlder(); - // We must not have made a request, because we didn't - // prepare a response and didn't get an exception. - checkNotNotified(); - check(model) - ..haveOldest.isTrue() - ..messages.length.equals(30); - }); + await prepareOutboxMessages(count: 1, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); - test('fetchOlder nop during backoff', () => awaitFakeAsync((async) async { - final olderMessages = List.generate(5, (i) => eg.streamMessage()); - final initialMessages = List.generate(5, (i) => eg.streamMessage()); - await prepare(narrow: const CombinedFeedNarrow()); - await prepareMessages(foundOldest: false, messages: initialMessages); - check(connection.takeRequests()).single; - - connection.prepare(httpStatus: 400, json: { - 'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'}); - check(async.pendingTimers).isEmpty(); - await check(model.fetchOlder()).throws(); - checkNotified(count: 2); - check(model).fetchOlderCoolingDown.isTrue(); - check(connection.takeRequests()).single; + connection.prepare( + json: newestResult(foundOldest: true, messages: []).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..fetched.isTrue() + ..outboxMessages.length.equals(1); + })); - await model.fetchOlder(); - checkNotNotified(); - check(model).fetchOlderCoolingDown.isTrue(); - check(model).fetchingOlder.isFalse(); - check(connection.lastRequest).isNull(); + test('some messages found in fetch; outbox messages present', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); - // Wait long enough that a first backoff is sure to finish. - async.elapse(const Duration(seconds: 1)); - check(model).fetchOlderCoolingDown.isFalse(); - checkNotifiedOnce(); - check(connection.lastRequest).isNull(); + await prepareOutboxMessages(count: 1, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); - connection.prepare(json: olderResult( - anchor: 1000, foundOldest: false, messages: olderMessages).toJson()); - await model.fetchOlder(); - checkNotified(count: 2); - check(connection.takeRequests()).single; - })); + connection.prepare(json: newestResult(foundOldest: true, + messages: [eg.streamMessage(stream: stream, topic: 'topic')]).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..fetched.isTrue() + ..outboxMessages.length.equals(1); + })); - test('fetchOlder handles servers not understanding includeAnchor', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - await prepareMessages(foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); + test('outbox messages not added until haveNewest', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'topic'), + anchor: AnchorCode.firstUnread, + stream: stream); - // The old behavior is to include the anchor message regardless of includeAnchor. - connection.prepare(json: olderResult( - anchor: 1000, foundOldest: false, foundAnchor: true, - messages: List.generate(101, (i) => eg.streamMessage(id: 900 + i)), - ).toJson()); - await model.fetchOlder(); - checkNotified(count: 2); - check(model) - ..fetchingOlder.isFalse() - ..messages.length.equals(200); + await prepareOutboxMessages(count: 1, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model)..fetched.isFalse()..outboxMessages.isEmpty(); + + final message = eg.streamMessage(stream: stream, topic: 'topic'); + connection.prepare(json: nearResult( + anchor: message.id, + foundOldest: true, + foundNewest: false, + messages: [message]).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model)..fetched.isTrue()..haveNewest.isFalse()..outboxMessages.isEmpty(); + + connection.prepare(json: newerResult(anchor: message.id, foundNewest: true, + messages: [eg.streamMessage(stream: stream, topic: 'topic')]).toJson()); + final fetchFuture = model.fetchNewer(); + checkNotifiedOnce(); + await fetchFuture; + checkNotifiedOnce(); + check(model)..haveNewest.isTrue()..outboxMessages.length.equals(1); + })); + + test('ignore [OutboxMessage]s outside narrow or with `hidden: true`', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + final otherStream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await store.setUserTopic(stream, 'muted', UserTopicVisibilityPolicy.muted); + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t('topic')), + StreamDestination(stream.streamId, eg.t('muted')), + StreamDestination(otherStream.streamId, eg.t('topic')), + ]); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + + await prepareOutboxMessagesTo( + [StreamDestination(stream.streamId, eg.t('topic'))]); + assert(store.outboxMessages.values.last.hidden); + + connection.prepare(json: + newestResult(foundOldest: true, messages: []).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model).outboxMessages.single.isA().conversation + ..streamId.equals(stream.streamId) + ..topic.equals(eg.t('topic')); + })); + + // TODO(#824): move this test + test('recent senders track all the messages', () async { + const narrow = CombinedFeedNarrow(); + await prepare(narrow: narrow); + final messages = [ + eg.streamMessage(), + // Not subscribed to the stream with id 10. + eg.streamMessage(stream: eg.stream(streamId: 10)), + ]; + connection.prepare(json: newestResult( + foundOldest: false, + messages: messages, + ).toJson()); + await model.fetchInitial(); + + check(model).messages.length.equals(1); + recent_senders_test.checkMatchesMessages(store.recentSenders, messages); + }); + + group('topic permalinks', () { + test('if redirect, we follow it and remove "with" element', () async { + await prepare(narrow: TopicNarrow(someChannel.streamId, eg.t(someTopic), with_: 1)); + connection.prepare(json: newestResult( + foundOldest: false, + messages: [eg.streamMessage(id: 1, stream: otherChannel, topic: otherTopic)], + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model).narrow + .equals(TopicNarrow(otherChannel.streamId, eg.t(otherTopic))); + }); + + test('if no redirect, we still remove "with" element', () async { + await prepare(narrow: TopicNarrow(someChannel.streamId, eg.t(someTopic), with_: 1)); + connection.prepare(json: newestResult( + foundOldest: false, + messages: [eg.streamMessage(id: 1, stream: someChannel, topic: someTopic)], + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model).narrow + .equals(TopicNarrow(someChannel.streamId, eg.t(someTopic))); + }); + }); }); - // TODO(#824): move this test - test('fetchOlder, recent senders track all the messages', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - final initialMessages = List.generate(10, (i) => eg.streamMessage(id: 100 + i)); - await prepareMessages(foundOldest: false, messages: initialMessages); + group('fetching more', () { + test('fetchOlder smoke', () async { + const narrow = CombinedFeedNarrow(); + await prepare(narrow: narrow); + await prepareMessages(foundOldest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); - final oldMessages = List.generate(10, (i) => eg.streamMessage(id: 89 + i)) - // Not subscribed to the stream with id 10. - ..add(eg.streamMessage(id: 99, stream: eg.stream(streamId: 10))); - connection.prepare(json: olderResult( - anchor: 100, foundOldest: false, - messages: oldMessages, - ).toJson()); - await model.fetchOlder(); + connection.prepare(json: olderResult( + anchor: 1000, foundOldest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 900 + i)), + ).toJson()); + final fetchFuture = model.fetchOlder(); + checkNotifiedOnce(); + check(model).busyFetchingMore.isTrue(); + + await fetchFuture; + checkNotifiedOnce(); + check(model) + ..busyFetchingMore.isFalse() + ..messages.length.equals(200); + checkLastRequest( + narrow: narrow.apiEncode(), + anchor: '1000', + includeAnchor: false, + numBefore: kMessageListFetchBatchSize, + numAfter: 0, + allowEmptyTopicName: true, + ); + }); + + test('fetchNewer smoke', () async { + const narrow = CombinedFeedNarrow(); + await prepare(narrow: narrow, anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: true, foundNewest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); + + connection.prepare(json: newerResult( + anchor: 1099, foundNewest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 1100 + i)), + ).toJson()); + final fetchFuture = model.fetchNewer(); + checkNotifiedOnce(); + check(model).busyFetchingMore.isTrue(); + + await fetchFuture; + checkNotifiedOnce(); + check(model) + ..busyFetchingMore.isFalse() + ..messages.length.equals(200); + checkLastRequest( + narrow: narrow.apiEncode(), + anchor: '1099', + includeAnchor: false, + numBefore: 0, + numAfter: kMessageListFetchBatchSize, + allowEmptyTopicName: true, + ); + }); + + test('nop when already fetching older', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: false, foundNewest: false, + messages: List.generate(201, (i) => eg.streamMessage(id: 900 + i))); + + connection.prepare(json: olderResult( + anchor: 900, foundOldest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 800 + i)), + ).toJson()); + final fetchFuture = model.fetchOlder(); + checkNotifiedOnce(); + check(model).busyFetchingMore.isTrue(); + + // Don't prepare another response. + final fetchFuture2 = model.fetchOlder(); + checkNotNotified(); + check(model).busyFetchingMore.isTrue(); + final fetchFuture3 = model.fetchNewer(); + checkNotNotified(); + check(model)..busyFetchingMore.isTrue()..messages.length.equals(201); + + await fetchFuture; + await fetchFuture2; + await fetchFuture3; + // We must not have made another request, because we didn't + // prepare another response and didn't get an exception. + checkNotifiedOnce(); + check(model)..busyFetchingMore.isFalse()..messages.length.equals(301); + }); + + test('nop when already fetching newer', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: false, foundNewest: false, + messages: List.generate(201, (i) => eg.streamMessage(id: 900 + i))); + + connection.prepare(json: newerResult( + anchor: 1100, foundNewest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 1101 + i)), + ).toJson()); + final fetchFuture = model.fetchNewer(); + checkNotifiedOnce(); + check(model).busyFetchingMore.isTrue(); + + // Don't prepare another response. + final fetchFuture2 = model.fetchOlder(); + checkNotNotified(); + check(model).busyFetchingMore.isTrue(); + final fetchFuture3 = model.fetchNewer(); + checkNotNotified(); + check(model)..busyFetchingMore.isTrue()..messages.length.equals(201); + + await fetchFuture; + await fetchFuture2; + await fetchFuture3; + // We must not have made another request, because we didn't + // prepare another response and didn't get an exception. + checkNotifiedOnce(); + check(model)..busyFetchingMore.isFalse()..messages.length.equals(301); + }); + + test('fetchOlder nop when already haveOldest true', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: true, foundNewest: false, messages: + List.generate(151, (i) => eg.streamMessage(id: 950 + i))); + check(model) + ..haveOldest.isTrue() + ..messages.length.equals(151); + + await model.fetchOlder(); + // We must not have made a request, because we didn't + // prepare a response and didn't get an exception. + checkNotNotified(); + check(model) + ..haveOldest.isTrue() + ..messages.length.equals(151); + }); + + test('fetchNewer nop when already haveNewest true', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: false, foundNewest: true, messages: + List.generate(151, (i) => eg.streamMessage(id: 950 + i))); + check(model) + ..haveNewest.isTrue() + ..messages.length.equals(151); + + await model.fetchNewer(); + // We must not have made a request, because we didn't + // prepare a response and didn't get an exception. + checkNotNotified(); + check(model) + ..haveNewest.isTrue() + ..messages.length.equals(151); + }); + + test('nop during backoff', () => awaitFakeAsync((async) async { + final olderMessages = List.generate(5, (i) => eg.streamMessage()); + final initialMessages = List.generate(5, (i) => eg.streamMessage()); + final newerMessages = List.generate(5, (i) => eg.streamMessage()); + await prepare(anchor: NumericAnchor(initialMessages[2].id)); + await prepareMessages(foundOldest: false, foundNewest: false, + messages: initialMessages); + check(connection.takeRequests()).single; + + connection.prepare(apiException: eg.apiBadRequest()); + check(async.pendingTimers).isEmpty(); + await check(model.fetchOlder()).throws(); + checkNotified(count: 2); + check(model).busyFetchingMore.isTrue(); + check(connection.takeRequests()).single; + + await model.fetchOlder(); + checkNotNotified(); + check(model).busyFetchingMore.isTrue(); + check(connection.lastRequest).isNull(); + + await model.fetchNewer(); + checkNotNotified(); + check(model).busyFetchingMore.isTrue(); + check(connection.lastRequest).isNull(); + + // Wait long enough that a first backoff is sure to finish. + async.elapse(const Duration(seconds: 1)); + check(model).busyFetchingMore.isFalse(); + checkNotifiedOnce(); + check(connection.lastRequest).isNull(); + + connection.prepare(json: olderResult(anchor: initialMessages.first.id, + foundOldest: false, messages: olderMessages).toJson()); + await model.fetchOlder(); + checkNotified(count: 2); + check(connection.takeRequests()).single; - check(model).messages.length.equals(20); - recent_senders_test.checkMatchesMessages(store.recentSenders, - [...initialMessages, ...oldMessages]); + connection.prepare(json: newerResult(anchor: initialMessages.last.id, + foundNewest: false, messages: newerMessages).toJson()); + await model.fetchNewer(); + checkNotified(count: 2); + check(connection.takeRequests()).single; + })); + + test('fetchOlder handles servers not understanding includeAnchor', () async { + await prepare(); + await prepareMessages(foundOldest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); + + // The old behavior is to include the anchor message regardless of includeAnchor. + connection.prepare(json: olderResult( + anchor: 1000, foundOldest: false, foundAnchor: true, + messages: List.generate(101, (i) => eg.streamMessage(id: 900 + i)), + ).toJson()); + await model.fetchOlder(); + checkNotified(count: 2); + check(model) + ..busyFetchingMore.isFalse() + ..messages.length.equals(200); + }); + + test('fetchNewer handles servers not understanding includeAnchor', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: true, foundNewest: false, + messages: List.generate(101, (i) => eg.streamMessage(id: 1000 + i))); + + // The old behavior is to include the anchor message regardless of includeAnchor. + connection.prepare(json: newerResult( + anchor: 1100, foundNewest: false, foundAnchor: true, + messages: List.generate(101, (i) => eg.streamMessage(id: 1100 + i)), + ).toJson()); + await model.fetchNewer(); + checkNotified(count: 2); + check(model) + ..busyFetchingMore.isFalse() + ..messages.length.equals(201); + }); + + // TODO(#824): move this test + test('fetchOlder recent senders track all the messages', () async { + await prepare(); + final initialMessages = List.generate(10, (i) => eg.streamMessage(id: 100 + i)); + await prepareMessages(foundOldest: false, messages: initialMessages); + + final oldMessages = List.generate(10, (i) => eg.streamMessage(id: 89 + i)) + // Not subscribed to the stream with id 10. + ..add(eg.streamMessage(id: 99, stream: eg.stream(streamId: 10))); + connection.prepare(json: olderResult( + anchor: 100, foundOldest: false, + messages: oldMessages, + ).toJson()); + await model.fetchOlder(); + + check(model).messages.length.equals(20); + recent_senders_test.checkMatchesMessages(store.recentSenders, + [...initialMessages, ...oldMessages]); + }); + + // TODO(#824): move this test + test('TODO fetchNewer recent senders track all the messages', () async { + await prepare(anchor: NumericAnchor(100)); + final initialMessages = List.generate(10, (i) => eg.streamMessage(id: 100 + i)); + await prepareMessages(foundOldest: true, foundNewest: false, + messages: initialMessages); + + final newMessages = List.generate(10, (i) => eg.streamMessage(id: 110 + i)) + // Not subscribed to the stream with id 10. + ..add(eg.streamMessage(id: 120, stream: eg.stream(streamId: 10))); + connection.prepare(json: newerResult( + anchor: 100, foundNewest: false, + messages: newMessages, + ).toJson()); + await model.fetchNewer(); + + check(model).messages.length.equals(20); + recent_senders_test.checkMatchesMessages(store.recentSenders, + [...initialMessages, ...newMessages]); + }); }); - test('MessageEvent', () async { - final stream = eg.stream(); - await prepare(narrow: ChannelNarrow(stream.streamId)); - await prepareMessages(foundOldest: true, messages: - List.generate(30, (i) => eg.streamMessage(stream: stream))); + // TODO(#1569): test jumpToEnd - check(model).messages.length.equals(30); - await store.handleEvent(MessageEvent(id: 0, - message: eg.streamMessage(stream: stream))); - checkNotifiedOnce(); - check(model).messages.length.equals(31); + group('MessageEvent', () { + test('in narrow', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + + check(model).messages.length.equals(30); + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotifiedOnce(); + check(model).messages.length.equals(31); + }); + + test('not in narrow', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + + check(model).messages.length.equals(30); + final otherStream = eg.stream(); + await store.addMessage(eg.streamMessage(stream: otherStream)); + checkNotNotified(); + check(model).messages.length.equals(30); + }); + + test('while in mid-history', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId), + anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: true, foundNewest: false, messages: + List.generate(30, (i) => eg.streamMessage(id: 1000 + i, stream: stream))); + + check(model).messages.length.equals(30); + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotNotified(); + check(model).messages.length.equals(30); + }); + + test('before fetch', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotNotified(); + check(model).fetched.isFalse(); + }); + + test('when there are outbox messages', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + + await prepareOutboxMessages(count: 5, stream: stream); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + check(model) + ..messages.length.equals(30) + ..outboxMessages.length.equals(5); + + await store.handleEvent(eg.messageEvent(eg.streamMessage(stream: stream))); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(31) + ..outboxMessages.length.equals(5); + })); + + test('from another client (localMessageId present but unrecognized)', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic')); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + + check(model) + ..messages.length.equals(30) + ..outboxMessages.isEmpty(); + + await store.handleEvent(eg.messageEvent( + eg.streamMessage(stream: stream, topic: 'topic'), + localMessageId: 1234)); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(31) + ..outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + })); + + test('for an OutboxMessage in the narrow', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + + await prepareOutboxMessages(count: 5, stream: stream); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + final localMessageId = store.outboxMessages.keys.first; + check(model) + ..messages.length.equals(30) + ..outboxMessages.length.equals(5) + ..outboxMessages.any((message) => + message.localMessageId.equals(localMessageId)); + + await store.handleEvent(eg.messageEvent(eg.streamMessage(stream: stream), + localMessageId: localMessageId)); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(31) + ..outboxMessages.length.equals(4) + ..outboxMessages.every((message) => + message.localMessageId.not((m) => m.equals(localMessageId))); + })); + + test('for an OutboxMessage outside the narrow', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic')); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + + await prepareOutboxMessages(count: 5, stream: stream, topic: 'other'); + final localMessageId = store.outboxMessages.keys.first; + check(model) + ..messages.length.equals(30) + ..outboxMessages.isEmpty(); + + await store.handleEvent(eg.messageEvent( + eg.streamMessage(stream: stream, topic: 'other'), + localMessageId: localMessageId)); + checkNotNotified(); + check(model) + ..messages.length.equals(30) + ..outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + })); }); - test('MessageEvent, not in narrow', () async { + group('addOutboxMessage', () { final stream = eg.stream(); - await prepare(narrow: ChannelNarrow(stream.streamId)); - await prepareMessages(foundOldest: true, messages: - List.generate(30, (i) => eg.streamMessage(stream: stream))); - check(model).messages.length.equals(30); - final otherStream = eg.stream(); - await store.handleEvent(MessageEvent(id: 0, - message: eg.streamMessage(stream: otherStream))); - checkNotNotified(); - check(model).messages.length.equals(30); + test('in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + await prepareOutboxMessages(count: 5, stream: stream); + check(model).outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + check(model).outboxMessages.length.equals(5); + })); + + test('not in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + await prepareOutboxMessages(count: 5, stream: stream, topic: 'other topic'); + check(model).outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model).outboxMessages.isEmpty(); + })); + + test('before fetch', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareOutboxMessages(count: 5, stream: stream); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); + })); }); - test('MessageEvent, before fetch', () async { + group('removeOutboxMessage', () { final stream = eg.stream(); - await prepare(narrow: ChannelNarrow(stream.streamId)); - await store.handleEvent(MessageEvent(id: 0, - message: eg.streamMessage(stream: stream))); - checkNotNotified(); - check(model).fetched.isFalse(); + + Future prepareFailedOutboxMessages(FakeAsync async, { + required int count, + required ZulipStream stream, + String topic = 'some topic', + }) async { + for (int i = 0; i < count; i++) { + connection.prepare(httpException: SocketException('failed')); + await check(store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t(topic)), + content: 'content')).throws(); + } + } + + test('in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + await prepareFailedOutboxMessages(async, + count: 5, stream: stream); + check(model).outboxMessages.length.equals(5); + checkNotified(count: 5); + + store.takeOutboxMessage(store.outboxMessages.keys.first); + checkNotifiedOnce(); + check(model).outboxMessages.length.equals(4); + })); + + test('not in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + await prepareFailedOutboxMessages(async, + count: 5, stream: stream, topic: 'other topic'); + check(model).outboxMessages.isEmpty(); + checkNotNotified(); + + store.takeOutboxMessage(store.outboxMessages.keys.first); + check(model).outboxMessages.isEmpty(); + checkNotNotified(); + })); + + test('removed outbox message is the only message in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream); + await prepareMessages(foundOldest: true, messages: []); + await prepareFailedOutboxMessages(async, + count: 1, stream: stream); + check(model).outboxMessages.single; + checkNotified(count: 1); + + store.takeOutboxMessage(store.outboxMessages.keys.first); + check(model).outboxMessages.isEmpty(); + checkNotifiedOnce(); + })); }); group('UserTopicEvent', () { @@ -370,11 +965,7 @@ void main() { await setVisibility(policy); } - void checkHasMessageIds(Iterable messageIds) { - check(model.messages.map((m) => m.id)).deepEquals(messageIds); - } - - test('mute a visible topic', () async { + test('mute a visible topic', () => awaitFakeAsync((async) async { await prepare(narrow: const CombinedFeedNarrow()); await prepareMutes(); final otherStream = eg.stream(); @@ -388,10 +979,49 @@ void main() { ]); checkHasMessageIds([1, 2, 3, 4]); + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t(topic)), + StreamDestination(stream.streamId, eg.t('elsewhere')), + DmDestination(userIds: [eg.selfUser.userId]), + ]); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 3); + check(model).outboxMessages.deepEquals(>[ + (it) => it.isA() + .conversation.topic.equals(eg.t(topic)), + (it) => it.isA() + .conversation.topic.equals(eg.t('elsewhere')), + (it) => it.isA() + .conversation.allRecipientIds.deepEquals([eg.selfUser.userId]), + ]); + await setVisibility(UserTopicVisibilityPolicy.muted); checkNotifiedOnce(); checkHasMessageIds([1, 3, 4]); - }); + check(model).outboxMessages.deepEquals(>[ + (it) => it.isA() + .conversation.topic.equals(eg.t('elsewhere')), + (it) => it.isA() + .conversation.allRecipientIds.deepEquals([eg.selfUser.userId]), + ]); + })); + + test('mute a visible topic containing only outbox messages', () => awaitFakeAsync((async) async { + await prepare(narrow: const CombinedFeedNarrow()); + await prepareMutes(); + await prepareMessages(foundOldest: true, messages: []); + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t(topic)), + StreamDestination(stream.streamId, eg.t(topic)), + ]); + async.elapse(kLocalEchoDebounceDuration); + check(model).outboxMessages.length.equals(2); + checkNotified(count: 2); + + await setVisibility(UserTopicVisibilityPolicy.muted); + check(model).outboxMessages.isEmpty(); + checkNotifiedOnce(); + })); test('in CombinedFeedNarrow, use combined-feed visibility', () async { // Compare the parallel ChannelNarrow test below. @@ -466,7 +1096,7 @@ void main() { checkHasMessageIds([1]); }); - test('no affected messages -> no notification', () async { + test('no affected messages -> no notification', () => awaitFakeAsync((async) async { await prepare(narrow: const CombinedFeedNarrow()); await prepareMutes(); await prepareMessages(foundOldest: true, messages: [ @@ -474,20 +1104,34 @@ void main() { ]); checkHasMessageIds([1]); + await prepareOutboxMessagesTo( + [StreamDestination(stream.streamId, eg.t('bar'))]); + async.elapse(kLocalEchoDebounceDuration); + final outboxMessage = model.outboxMessages.single; + checkNotifiedOnce(); + await setVisibility(UserTopicVisibilityPolicy.muted); checkNotNotified(); checkHasMessageIds([1]); - }); + check(model).outboxMessages.single.equals(outboxMessage); + })); test('unmute a topic -> refetch from scratch', () => awaitFakeAsync((async) async { await prepare(narrow: const CombinedFeedNarrow()); await prepareMutes(true); - final messages = [ + final messages = [ eg.dmMessage(id: 1, from: eg.otherUser, to: [eg.selfUser]), eg.streamMessage(id: 2, stream: stream, topic: topic), ]; await prepareMessages(foundOldest: true, messages: messages); + await store.setUserTopic(stream, 'muted', UserTopicVisibilityPolicy.muted); + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t(topic)), + StreamDestination(stream.streamId, eg.t('muted')), + ]); + async.elapse(kLocalEchoDebounceDuration); checkHasMessageIds([1]); + check(model).outboxMessages.isEmpty(); connection.prepare( json: newestResult(foundOldest: true, messages: messages).toJson()); @@ -495,10 +1139,14 @@ void main() { checkNotifiedOnce(); check(model).fetched.isFalse(); checkHasMessageIds([]); + check(model).outboxMessages.isEmpty(); async.elapse(Duration.zero); checkNotifiedOnce(); checkHasMessageIds([1, 2]); + check(model).outboxMessages.single.isA().conversation + ..streamId.equals(stream.streamId) + ..topic.equals(eg.t(topic)); })); test('unmute a topic before initial fetch completes -> do nothing', () => awaitFakeAsync((async) async { @@ -511,14 +1159,152 @@ void main() { connection.prepare( json: newestResult(foundOldest: true, messages: messages).toJson()); final fetchFuture = model.fetchInitial(); - - await setVisibility(UserTopicVisibilityPolicy.unmuted); + + await setVisibility(UserTopicVisibilityPolicy.unmuted); + checkNotNotified(); + + // The new policy does get applied when the fetch eventually completes. + await fetchFuture; + checkNotifiedOnce(); + checkHasMessageIds([1]); + })); + }); + + group('MutedUsersEvent', () { + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final user3 = eg.user(userId: 3); + final users = [user1, user2, user3]; + + test('CombinedFeedNarrow', () async { + await prepare(narrow: CombinedFeedNarrow(), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + eg.dmMessage(id: 2, from: eg.selfUser, to: [user1, user2]), + eg.dmMessage(id: 3, from: eg.selfUser, to: [user2, user3]), + eg.dmMessage(id: 4, from: eg.selfUser, to: []), + eg.streamMessage(id: 5), + ]); + checkHasMessageIds([1, 2, 3, 4, 5]); + + await store.setMutedUsers([user1.userId]); + checkNotifiedOnce(); + checkHasMessageIds([2, 3, 4, 5]); + + await store.setMutedUsers([user1.userId, user2.userId]); + checkNotifiedOnce(); + checkHasMessageIds([3, 4, 5]); + }); + + test('MentionsNarrow', () async { + await prepare(narrow: MentionsNarrow(), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1], + flags: [MessageFlag.mentioned]), + eg.dmMessage(id: 2, from: eg.selfUser, to: [user2], + flags: [MessageFlag.mentioned]), + eg.streamMessage(id: 3, flags: [MessageFlag.mentioned]), + ]); + checkHasMessageIds([1, 2, 3]); + + await store.setMutedUsers([user1.userId]); + checkNotifiedOnce(); + checkHasMessageIds([2, 3]); + }); + + test('StarredMessagesNarrow', () async { + await prepare(narrow: StarredMessagesNarrow(), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1], + flags: [MessageFlag.starred]), + eg.dmMessage(id: 2, from: eg.selfUser, to: [user2], + flags: [MessageFlag.starred]), + eg.streamMessage(id: 3, flags: [MessageFlag.starred]), + ]); + checkHasMessageIds([1, 2, 3]); + + await store.setMutedUsers([user1.userId]); + checkNotifiedOnce(); + checkHasMessageIds([2, 3]); + }); + + test('ChannelNarrow -> do nothing', () async { + await prepare(narrow: ChannelNarrow(eg.defaultStreamMessageStreamId), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.streamMessage(id: 1), + ]); + checkHasMessageIds([1]); + + await store.setMutedUsers([user1.userId]); + checkNotNotified(); + checkHasMessageIds([1]); + }); + + test('TopicNarrow -> do nothing', () async { + await prepare(narrow: TopicNarrow(eg.defaultStreamMessageStreamId, + TopicName('topic')), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.streamMessage(id: 1, topic: 'topic'), + ]); + checkHasMessageIds([1]); + + await store.setMutedUsers([user1.userId]); + checkNotNotified(); + checkHasMessageIds([1]); + }); + + test('DmNarrow -> do nothing', () async { + await prepare( + narrow: DmNarrow.withUser(user1.userId, selfUserId: eg.selfUser.userId), + users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + ]); + checkHasMessageIds([1]); + + await store.setMutedUsers([user1.userId]); + checkNotNotified(); + checkHasMessageIds([1]); + }); + + test('unmute a user -> refetch from scratch', () => awaitFakeAsync((async) async { + await prepare(narrow: CombinedFeedNarrow(), users: users, + mutedUserIds: [user1.userId]); + final messages = [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + eg.streamMessage(id: 2), + ]; + await prepareMessages(foundOldest: true, messages: messages); + checkHasMessageIds([2]); + + connection.prepare( + json: newestResult(foundOldest: true, messages: messages).toJson()); + await store.setMutedUsers([]); + checkNotifiedOnce(); + check(model).fetched.isFalse(); + checkHasMessageIds([]); + + async.elapse(Duration.zero); + checkNotifiedOnce(); + checkHasMessageIds([1, 2]); + })); + + test('unmute a user before initial fetch completes -> do nothing', () => awaitFakeAsync((async) async { + await prepare(narrow: CombinedFeedNarrow(), users: users, + mutedUserIds: [user1.userId]); + final messages = [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + eg.streamMessage(id: 2), + ]; + connection.prepare( + json: newestResult(foundOldest: true, messages: messages).toJson()); + final fetchFuture = model.fetchInitial(); + await store.setMutedUsers([]); checkNotNotified(); - // The new policy does get applied when the fetch eventually completes. await fetchFuture; checkNotifiedOnce(); - checkHasMessageIds([1]); + checkHasMessageIds([1, 2]); })); }); @@ -574,11 +1360,11 @@ void main() { check(model).messages.length.equals(30); await store.handleEvent(eg.deleteMessageEvent(messagesToDelete)); checkNotifiedOnce(); - check(model.messages.map((message) => message.id)).deepEquals([ + checkHasMessages([ ...messages.sublist(0, 2), ...messages.sublist(5, 10), ...messages.sublist(15), - ].map((message) => message.id)); + ]); }); }); @@ -644,6 +1430,38 @@ void main() { }); }); + group('notifyListenersIfOutboxMessagePresent', () { + final stream = eg.stream(); + + test('message present', () => awaitFakeAsync((async) async { + await prepare(narrow: const CombinedFeedNarrow(), stream: stream); + await prepareMessages(foundOldest: true, messages: []); + await prepareOutboxMessages(count: 5, stream: stream); + + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + + model.notifyListenersIfOutboxMessagePresent( + store.outboxMessages.keys.first); + checkNotifiedOnce(); + })); + + test('message not present', () => awaitFakeAsync((async) async { + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'some topic'), stream: stream); + await prepareMessages(foundOldest: true, messages: []); + await prepareOutboxMessages(count: 5, + stream: stream, topic: 'other topic'); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + + model.notifyListenersIfOutboxMessagePresent( + store.outboxMessages.keys.first); + checkNotNotified(); + })); + }); + group('messageContentChanged', () { test('message present', () async { await prepare(narrow: const CombinedFeedNarrow()); @@ -681,10 +1499,6 @@ void main() { final stream = eg.stream(); final otherStream = eg.stream(); - void checkHasMessages(Iterable messages) { - check(model.messages.map((e) => e.id)).deepEquals(messages.map((e) => e.id)); - } - Future prepareNarrow(Narrow narrow, List? messages) async { await prepare(narrow: narrow); for (final streamToAdd in [stream, otherStream]) { @@ -777,12 +1591,35 @@ void main() { checkNotifiedOnce(); }); + test('channel -> new channel (with outbox messages): remove moved messages; outbox messages unaffected', () => awaitFakeAsync((async) async { + final narrow = ChannelNarrow(stream.streamId); + await prepareNarrow(narrow, initialMessages + movedMessages); + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await prepareOutboxMessages(count: 5, stream: stream); + + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + final outboxMessagesCopy = model.outboxMessages.toList(); + + await store.handleEvent(eg.updateMessageEventMoveFrom( + origMessages: movedMessages, + newTopicStr: 'new', + newStreamId: otherStream.streamId, + )); + checkHasMessages(initialMessages); + check(model).outboxMessages.deepEquals(outboxMessagesCopy); + checkNotifiedOnce(); + })); + test('unrelated channel -> new channel: unaffected', () async { + final thirdStream = eg.stream(); await prepareNarrow(narrow, initialMessages); + await store.addStream(thirdStream); + await store.addSubscription(eg.subscription(thirdStream)); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: otherChannelMovedMessages, - newStreamId: otherStream.streamId, + newStreamId: thirdStream.streamId, )); checkHasMessages(initialMessages); checkNotNotified(); @@ -929,7 +1766,13 @@ void main() { newStreamId: otherStream.streamId, propagateMode: propagateMode, )); - checkNotifiedOnce(); + switch (propagateMode) { + case PropagateMode.changeOne: + checkNotifiedOnce(); + case PropagateMode.changeLater: + case PropagateMode.changeAll: + checkNotified(count: 2); + } async.elapse(const Duration(seconds: 1)); }); @@ -995,7 +1838,7 @@ void main() { messages: olderMessages, ).toJson()); final fetchFuture = model.fetchOlder(); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkHasMessages(initialMessages); checkNotifiedOnce(); @@ -1008,7 +1851,7 @@ void main() { origStreamId: otherStream.streamId, newMessages: movedMessages, )); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkHasMessages([]); checkNotifiedOnce(); @@ -1031,7 +1874,7 @@ void main() { ).toJson()); final fetchFuture = model.fetchOlder(); checkHasMessages(initialMessages); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkNotifiedOnce(); connection.prepare(delay: const Duration(seconds: 1), json: newestResult( @@ -1044,7 +1887,7 @@ void main() { newMessages: movedMessages, )); checkHasMessages([]); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); async.elapse(const Duration(seconds: 1)); @@ -1061,12 +1904,11 @@ void main() { addTearDown(() => BackoffMachine.debugDuration = null); await prepareNarrow(narrow, initialMessages); - connection.prepare(httpStatus: 400, json: { - 'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'}); + connection.prepare(apiException: eg.apiBadRequest()); BackoffMachine.debugDuration = const Duration(seconds: 1); await check(model.fetchOlder()).throws(); final backoffTimerA = async.pendingTimers.single; - check(model).fetchOlderCoolingDown.isTrue(); + check(model).busyFetchingMore.isTrue(); check(model).fetched.isTrue(); checkHasMessages(initialMessages); checkNotified(count: 2); @@ -1084,37 +1926,36 @@ void main() { check(model).fetched.isFalse(); checkHasMessages([]); checkNotifiedOnce(); - check(model).fetchOlderCoolingDown.isFalse(); + check(model).busyFetchingMore.isFalse(); check(backoffTimerA.isActive).isTrue(); async.elapse(Duration.zero); check(model).fetched.isTrue(); checkHasMessages(initialMessages + movedMessages); checkNotifiedOnce(); - check(model).fetchOlderCoolingDown.isFalse(); + check(model).busyFetchingMore.isFalse(); check(backoffTimerA.isActive).isTrue(); - connection.prepare(httpStatus: 400, json: { - 'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'}); + connection.prepare(apiException: eg.apiBadRequest()); BackoffMachine.debugDuration = const Duration(seconds: 2); await check(model.fetchOlder()).throws(); final backoffTimerB = async.pendingTimers.last; - check(model).fetchOlderCoolingDown.isTrue(); + check(model).busyFetchingMore.isTrue(); check(backoffTimerA.isActive).isTrue(); check(backoffTimerB.isActive).isTrue(); checkNotified(count: 2); - // When `backoffTimerA` ends, `fetchOlderCoolingDown` remains `true` + // When `backoffTimerA` ends, `busyFetchingMore` remains `true` // because the backoff was from a previous generation. async.elapse(const Duration(seconds: 1)); - check(model).fetchOlderCoolingDown.isTrue(); + check(model).busyFetchingMore.isTrue(); check(backoffTimerA.isActive).isFalse(); check(backoffTimerB.isActive).isTrue(); checkNotNotified(); - // When `backoffTimerB` ends, `fetchOlderCoolingDown` gets reset. + // When `backoffTimerB` ends, `busyFetchingMore` gets reset. async.elapse(const Duration(seconds: 1)); - check(model).fetchOlderCoolingDown.isFalse(); + check(model).busyFetchingMore.isFalse(); check(backoffTimerA.isActive).isFalse(); check(backoffTimerB.isActive).isFalse(); checkNotifiedOnce(); @@ -1196,7 +2037,7 @@ void main() { ).toJson()); final fetchFuture1 = model.fetchOlder(); checkHasMessages(initialMessages); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkNotifiedOnce(); connection.prepare(delay: const Duration(seconds: 1), json: newestResult( @@ -1209,7 +2050,7 @@ void main() { newMessages: movedMessages, )); checkHasMessages([]); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); async.elapse(const Duration(seconds: 1)); @@ -1222,19 +2063,19 @@ void main() { ).toJson()); final fetchFuture2 = model.fetchOlder(); checkHasMessages(initialMessages + movedMessages); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkNotifiedOnce(); await fetchFuture1; checkHasMessages(initialMessages + movedMessages); // The older fetchOlder call should not override fetchingOlder set by // the new fetchOlder call, nor should it notify the listeners. - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkNotNotified(); await fetchFuture2; checkHasMessages(olderMessages + initialMessages + movedMessages); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); })); }); @@ -1250,12 +2091,14 @@ void main() { int notifiedCount1 = 0; final model1 = MessageListView.init(store: store, - narrow: ChannelNarrow(stream.streamId)) + narrow: ChannelNarrow(stream.streamId), + anchor: AnchorCode.newest) ..addListener(() => notifiedCount1++); int notifiedCount2 = 0; final model2 = MessageListView.init(store: store, - narrow: eg.topicNarrow(stream.streamId, 'hello')) + narrow: eg.topicNarrow(stream.streamId, 'hello'), + anchor: AnchorCode.newest) ..addListener(() => notifiedCount2++); for (final m in [model1, model2]) { @@ -1266,7 +2109,7 @@ void main() { } final message = eg.streamMessage(stream: stream, topic: 'hello'); - await store.handleEvent(MessageEvent(id: 0, message: message)); + await store.addMessage(message); await store.handleEvent( eg.reactionEvent(eg.unicodeEmojiReaction, ReactionOp.add, message.id)); @@ -1295,7 +2138,8 @@ void main() { await store.handleEvent(mkEvent(message)); // init msglist *after* event was handled - model = MessageListView.init(store: store, narrow: const CombinedFeedNarrow()); + model = MessageListView.init(store: store, + narrow: const CombinedFeedNarrow(), anchor: AnchorCode.newest); checkInvariants(model); connection.prepare(json: @@ -1348,8 +2192,7 @@ void main() { await prepare(narrow: ChannelNarrow(stream.streamId)); await prepareMessages(foundOldest: true, messages: List.generate(30, (i) => eg.streamMessage(stream: stream))); - await store.handleEvent(MessageEvent(id: 0, - message: eg.streamMessage(stream: stream))); + await store.addMessage(eg.streamMessage(stream: stream)); checkNotifiedOnce(); check(model).messages.length.equals(31); @@ -1375,9 +2218,9 @@ void main() { await prepare(narrow: const CombinedFeedNarrow()); await store.addStreams([stream1, stream2]); await store.addSubscription(eg.subscription(stream1)); - await store.addUserTopic(stream1, 'B', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream1, 'B', UserTopicVisibilityPolicy.muted); await store.addSubscription(eg.subscription(stream2, isMuted: true)); - await store.addUserTopic(stream2, 'C', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream2, 'C', UserTopicVisibilityPolicy.unmuted); // Check filtering on fetchInitial… await prepareMessages(foundOldest: false, messages: [ @@ -1388,8 +2231,7 @@ void main() { eg.dmMessage( id: 205, from: eg.otherUser, to: [eg.selfUser]), ]); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201, 203, 205])); + checkHasMessageIds(expected..addAll([201, 203, 205])); // … and on fetchOlder… connection.prepare(json: olderResult( @@ -1402,34 +2244,33 @@ void main() { ]).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101, 103, 105])); + checkHasMessageIds(expected..insertAll(0, [101, 103, 105])); // … and on MessageEvent. - await store.handleEvent(MessageEvent(id: 0, - message: eg.streamMessage(id: 301, stream: stream1, topic: 'A'))); + await store.addMessage( + eg.streamMessage(id: 301, stream: stream1, topic: 'A')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301)); + checkHasMessageIds(expected..add(301)); - await store.handleEvent(MessageEvent(id: 0, - message: eg.streamMessage(id: 302, stream: stream1, topic: 'B'))); + await store.addMessage( + eg.streamMessage(id: 302, stream: stream1, topic: 'B')); checkNotNotified(); - check(model.messages.map((m) => m.id)).deepEquals(expected); + checkHasMessageIds(expected); - await store.handleEvent(MessageEvent(id: 0, - message: eg.streamMessage(id: 303, stream: stream2, topic: 'C'))); + await store.addMessage( + eg.streamMessage(id: 303, stream: stream2, topic: 'C')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(303)); + checkHasMessageIds(expected..add(303)); - await store.handleEvent(MessageEvent(id: 0, - message: eg.streamMessage(id: 304, stream: stream2, topic: 'D'))); + await store.addMessage( + eg.streamMessage(id: 304, stream: stream2, topic: 'D')); checkNotNotified(); - check(model.messages.map((m) => m.id)).deepEquals(expected); + checkHasMessageIds(expected); - await store.handleEvent(MessageEvent(id: 0, - message: eg.dmMessage(id: 305, from: eg.otherUser, to: [eg.selfUser]))); + await store.addMessage( + eg.dmMessage(id: 305, from: eg.otherUser, to: [eg.selfUser])); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(305)); + checkHasMessageIds(expected..add(305)); }); test('in ChannelNarrow', () async { @@ -1437,8 +2278,8 @@ void main() { await prepare(narrow: ChannelNarrow(stream.streamId)); await store.addStream(stream); await store.addSubscription(eg.subscription(stream, isMuted: true)); - await store.addUserTopic(stream, 'A', UserTopicVisibilityPolicy.unmuted); - await store.addUserTopic(stream, 'C', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream, 'A', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream, 'C', UserTopicVisibilityPolicy.muted); // Check filtering on fetchInitial… await prepareMessages(foundOldest: false, messages: [ @@ -1447,8 +2288,7 @@ void main() { eg.streamMessage(id: 203, stream: stream, topic: 'C'), ]); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201, 202])); + checkHasMessageIds(expected..addAll([201, 202])); // … and on fetchOlder… connection.prepare(json: olderResult( @@ -1459,40 +2299,71 @@ void main() { ]).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101, 102])); + checkHasMessageIds(expected..insertAll(0, [101, 102])); // … and on MessageEvent. - await store.handleEvent(MessageEvent(id: 0, - message: eg.streamMessage(id: 301, stream: stream, topic: 'A'))); + await store.addMessage( + eg.streamMessage(id: 301, stream: stream, topic: 'A')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301)); + checkHasMessageIds(expected..add(301)); - await store.handleEvent(MessageEvent(id: 0, - message: eg.streamMessage(id: 302, stream: stream, topic: 'B'))); + await store.addMessage( + eg.streamMessage(id: 302, stream: stream, topic: 'B')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(302)); + checkHasMessageIds(expected..add(302)); - await store.handleEvent(MessageEvent(id: 0, - message: eg.streamMessage(id: 303, stream: stream, topic: 'C'))); + await store.addMessage( + eg.streamMessage(id: 303, stream: stream, topic: 'C')); checkNotNotified(); - check(model.messages.map((m) => m.id)).deepEquals(expected); + checkHasMessageIds(expected); }); + test('handle outbox messages', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await store.setUserTopic(stream, 'muted', UserTopicVisibilityPolicy.muted); + await prepareMessages(foundOldest: true, messages: []); + + // Check filtering on sent messages… + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t('not muted')), + StreamDestination(stream.streamId, eg.t('muted')), + ]); + async.elapse(kLocalEchoDebounceDuration); + checkNotifiedOnce(); + check(model.outboxMessages).single.isA() + .conversation.topic.equals(eg.t('not muted')); + + final messages = [eg.streamMessage(stream: stream)]; + connection.prepare(json: newestResult( + foundOldest: true, messages: messages).toJson()); + // Check filtering on fetchInitial… + await store.handleEvent(eg.updateMessageEventMoveTo( + newMessages: messages, + origStreamId: eg.stream().streamId)); + checkNotifiedOnce(); + check(model).fetched.isFalse(); + async.elapse(Duration.zero); + check(model).fetched.isTrue(); + check(model.outboxMessages).single.isA() + .conversation.topic.equals(eg.t('not muted')); + })); + test('in TopicNarrow', () async { final stream = eg.stream(); await prepare(narrow: eg.topicNarrow(stream.streamId, 'A')); await store.addStream(stream); await store.addSubscription(eg.subscription(stream, isMuted: true)); - await store.addUserTopic(stream, 'A', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream, 'A', UserTopicVisibilityPolicy.muted); // Check filtering on fetchInitial… await prepareMessages(foundOldest: false, messages: [ eg.streamMessage(id: 201, stream: stream, topic: 'A'), ]); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201])); + checkHasMessageIds(expected..addAll([201])); // … and on fetchOlder… connection.prepare(json: olderResult( @@ -1501,14 +2372,13 @@ void main() { ]).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101])); + checkHasMessageIds(expected..insertAll(0, [101])); // … and on MessageEvent. - await store.handleEvent(MessageEvent(id: 0, - message: eg.streamMessage(id: 301, stream: stream, topic: 'A'))); + await store.addMessage( + eg.streamMessage(id: 301, stream: stream, topic: 'A')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301)); + checkHasMessageIds(expected..add(301)); }); test('in MentionsNarrow', () async { @@ -1516,7 +2386,7 @@ void main() { const mutedTopic = 'muted'; await prepare(narrow: const MentionsNarrow()); await store.addStream(stream); - await store.addUserTopic(stream, mutedTopic, UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream, mutedTopic, UserTopicVisibilityPolicy.muted); await store.addSubscription(eg.subscription(stream, isMuted: true)); List getMessages(int startingId) => [ @@ -1531,23 +2401,21 @@ void main() { // Check filtering on fetchInitial… await prepareMessages(foundOldest: false, messages: getMessages(201)); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201, 202, 203])); + checkHasMessageIds(expected..addAll([201, 202, 203])); // … and on fetchOlder… connection.prepare(json: olderResult( anchor: 201, foundOldest: true, messages: getMessages(101)).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101, 102, 103])); + checkHasMessageIds(expected..insertAll(0, [101, 102, 103])); // … and on MessageEvent. final messages = getMessages(301); for (var i = 0; i < 3; i += 1) { - await store.handleEvent(MessageEvent(id: 0, message: messages[i])); + await store.addMessage(messages[i]); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301 + i)); + checkHasMessageIds(expected..add(301 + i)); } }); @@ -1556,7 +2424,7 @@ void main() { const mutedTopic = 'muted'; await prepare(narrow: const StarredMessagesNarrow()); await store.addStream(stream); - await store.addUserTopic(stream, mutedTopic, UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream, mutedTopic, UserTopicVisibilityPolicy.muted); await store.addSubscription(eg.subscription(stream, isMuted: true)); List getMessages(int startingId) => [ @@ -1569,24 +2437,259 @@ void main() { // Check filtering on fetchInitial… await prepareMessages(foundOldest: false, messages: getMessages(201)); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201, 202])); + checkHasMessageIds(expected..addAll([201, 202])); // … and on fetchOlder… connection.prepare(json: olderResult( anchor: 201, foundOldest: true, messages: getMessages(101)).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101, 102])); + checkHasMessageIds(expected..insertAll(0, [101, 102])); // … and on MessageEvent. final messages = getMessages(301); for (var i = 0; i < 2; i += 1) { - await store.handleEvent(MessageEvent(id: 0, message: messages[i])); + await store.addMessage(messages[i]); + checkNotifiedOnce(); + checkHasMessageIds(expected..add(301 + i)); + } + }); + }); + + group('middleMessage maintained', () { + // In [checkInvariants] we verify that messages don't move from the + // top to the bottom slice or vice versa. + // Most of these test cases rely on that for all the checks they need. + + test('on fetchInitial empty', () async { + await prepare(narrow: const CombinedFeedNarrow()); + await prepareMessages(foundOldest: true, messages: []); + check(model)..messages.isEmpty() + ..middleMessage.equals(0); + }); + + test('on fetchInitial empty due to muting', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream, isMuted: true)); + await prepareMessages(foundOldest: true, messages: [ + eg.streamMessage(stream: stream), + ]); + check(model)..messages.isEmpty() + ..middleMessage.equals(0); + }); + + test('on fetchInitial, anchor past end', () async { + await prepare(narrow: const CombinedFeedNarrow(), + anchor: AnchorCode.newest); + final stream1 = eg.stream(); + final stream2 = eg.stream(); + await store.addStreams([stream1, stream2]); + await store.addSubscription(eg.subscription(stream1)); + await store.addSubscription(eg.subscription(stream2, isMuted: true)); + final messages = [ + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + ]; + await prepareMessages(foundOldest: true, messages: messages); + // The anchor message is the last visible message… + check(model) + ..messages.length.equals(5) + ..middleMessage.equals(model.messages.length - 1) + // … even though that's not the last message that was in the response. + ..messages[model.middleMessage].id + .equals(messages[messages.length - 2].id); + }); + + test('on fetchInitial, anchor in middle', () async { + final s1 = eg.stream(); + final s2 = eg.stream(); + final messages = [ + eg.streamMessage(id: 1, stream: s1), eg.streamMessage(id: 2, stream: s2), + eg.streamMessage(id: 3, stream: s1), eg.streamMessage(id: 4, stream: s2), + eg.streamMessage(id: 5, stream: s1), eg.streamMessage(id: 6, stream: s2), + eg.streamMessage(id: 7, stream: s1), eg.streamMessage(id: 8, stream: s2), + ]; + final anchorId = 4; + + await prepare(narrow: const CombinedFeedNarrow(), + anchor: NumericAnchor(anchorId)); + await store.addStreams([s1, s2]); + await store.addSubscription(eg.subscription(s1)); + await store.addSubscription(eg.subscription(s2, isMuted: true)); + await prepareMessages(foundOldest: true, foundNewest: true, + messages: messages); + // The anchor message is the first visible message with ID at least anchorId… + check(model) + ..messages[model.middleMessage - 1].id.isLessThan(anchorId) + ..messages[model.middleMessage].id.isGreaterOrEqual(anchorId); + // … even though a non-visible message actually had anchorId itself. + check(messages[3].id) + ..equals(anchorId) + ..isLessThan(model.messages[model.middleMessage].id); + }); + + /// Like [prepareMessages], but arrange for the given top and bottom slices. + Future prepareMessageSplit(List top, List bottom, { + bool foundOldest = true, + }) async { + assert(bottom.isNotEmpty); // could handle this too if necessary + await prepareMessages(foundOldest: foundOldest, messages: [ + ...top, + bottom.first, + ]); + if (bottom.length > 1) { + await store.addMessages(bottom.skip(1)); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301 + i)); } + check(model) + ..messages.length.equals(top.length + bottom.length) + ..middleMessage.equals(top.length); + } + + test('on fetchOlder', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit(foundOldest: false, + [eg.streamMessage(id: 100, stream: stream)], + [eg.streamMessage(id: 101, stream: stream)]); + + connection.prepare(json: olderResult(anchor: 100, foundOldest: true, + messages: List.generate(5, (i) => + eg.streamMessage(id: 95 + i, stream: stream))).toJson()); + await model.fetchOlder(); + checkNotified(count: 2); + }); + + test('on fetchOlder, from top empty', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit(foundOldest: false, + [], [eg.streamMessage(id: 100, stream: stream)]); + + connection.prepare(json: olderResult(anchor: 100, foundOldest: true, + messages: List.generate(5, (i) => + eg.streamMessage(id: 95 + i, stream: stream))).toJson()); + await model.fetchOlder(); + checkNotified(count: 2); + // The messages from fetchOlder should go in the top sliver, always. + check(model).middleMessage.equals(5); + }); + + test('on MessageEvent', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit(foundOldest: false, + [eg.streamMessage(stream: stream)], + [eg.streamMessage(stream: stream)]); + + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotifiedOnce(); + }); + + test('on messages muted, including anchor', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit([ + eg.streamMessage(stream: stream, topic: 'foo'), + eg.streamMessage(stream: stream, topic: 'bar'), + ], [ + eg.streamMessage(stream: stream, topic: 'bar'), + eg.streamMessage(stream: stream, topic: 'foo'), + ]); + + await store.handleEvent(eg.userTopicEvent( + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); + checkNotifiedOnce(); + }); + + test('on messages muted, not including anchor', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit([ + eg.streamMessage(stream: stream, topic: 'foo'), + eg.streamMessage(stream: stream, topic: 'bar'), + ], [ + eg.streamMessage(stream: stream, topic: 'foo'), + ]); + + await store.handleEvent(eg.userTopicEvent( + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); + checkNotifiedOnce(); + }); + + test('on messages muted, bottom empty', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit([ + eg.streamMessage(stream: stream, topic: 'foo'), + eg.streamMessage(stream: stream, topic: 'bar'), + ], [ + eg.streamMessage(stream: stream, topic: 'third'), + ]); + + await store.handleEvent(eg.deleteMessageEvent([ + model.messages.last as StreamMessage])); + checkNotifiedOnce(); + check(model).middleMessage.equals(model.messages.length); + + await store.handleEvent(eg.userTopicEvent( + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); + checkNotifiedOnce(); + }); + + test('on messages deleted', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + final messages = [ + eg.streamMessage(id: 1, stream: stream), + eg.streamMessage(id: 2, stream: stream), + eg.streamMessage(id: 3, stream: stream), + eg.streamMessage(id: 4, stream: stream), + ]; + await prepareMessageSplit(messages.sublist(0, 2), messages.sublist(2)); + + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(1, 3))); + checkNotifiedOnce(); + }); + + test('on messages deleted, bottom empty', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + final messages = [ + eg.streamMessage(id: 1, stream: stream), + eg.streamMessage(id: 2, stream: stream), + eg.streamMessage(id: 3, stream: stream), + eg.streamMessage(id: 4, stream: stream), + ]; + await prepareMessageSplit(messages.sublist(0, 3), messages.sublist(3)); + + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(3))); + checkNotifiedOnce(); + check(model).middleMessage.equals(model.messages.length); + + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(1, 2))); + checkNotifiedOnce(); }); }); @@ -1596,8 +2699,7 @@ void main() { await prepare(narrow: ChannelNarrow(stream.streamId)); await prepareMessages(foundOldest: true, messages: []); - await store.handleEvent(MessageEvent(id: 0, - message: eg.streamMessage(stream: stream))); + await store.addMessage(eg.streamMessage(stream: stream)); // Each [checkNotifiedOnce] call ensures there's been a [checkInvariants] // call, where the [ContentNode] gets checked. The additional checks to // make this test explicit. @@ -1611,13 +2713,13 @@ void main() { await prepare(narrow: ChannelNarrow(stream.streamId)); await prepareMessages(foundOldest: true, messages: []); - await store.handleEvent(MessageEvent(id: 0, message: eg.streamMessage( + await store.addMessage(eg.streamMessage( stream: stream, sender: eg.selfUser, submessages: [ eg.submessage(senderId: eg.selfUser.userId, content: eg.pollWidgetData(question: 'question', options: ['A'])), - ]))); + ])); // Each [checkNotifiedOnce] call ensures there's been a [checkInvariants] // call, where the value of the [Poll] gets checked. The additional // checks make this test explicit. @@ -1627,7 +2729,55 @@ void main() { }); }); - test('recipient headers are maintained consistently', () async { + group('findItemWithMessageId', () { + test('has MessageListDateSeparatorItem with null message ID', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream, topic: 'topic', + timestamp: eg.utcTimestamp(clock.daysAgo(1))); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: [message]); + + // `findItemWithMessageId` uses binary search. Set up just enough + // outbox message items, so that a [MessageListDateSeparatorItem] for + // the outbox messages is right in the middle. + await prepareOutboxMessages(count: 2, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 2); + check(model.items).deepEquals(>[ + (it) => it.isA(), + (it) => it.isA(), + (it) => it.isA().message.id.isNull(), + (it) => it.isA(), + (it) => it.isA(), + ]); + check(model.findItemWithMessageId(message.id)).equals(1); + })); + + test('has MessageListOutboxMessageItem', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream, topic: 'topic', + timestamp: eg.utcTimestamp(clock.now())); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: [message]); + + // `findItemWithMessageId` uses binary search. Set up just enough + // outbox message items, so that a [MessageListOutboxMessageItem] + // is right in the middle. + await prepareOutboxMessages(count: 3, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 3); + check(model.items).deepEquals(>[ + (it) => it.isA(), + (it) => it.isA(), + (it) => it.isA(), + (it) => it.isA(), + (it) => it.isA(), + ]); + check(model.findItemWithMessageId(message.id)).equals(1); + })); + }); + + test('recipient headers are maintained consistently (Combined feed)', () => awaitFakeAsync((async) async { // TODO test date separators are maintained consistently too // This tests the code that maintains the invariant that recipient headers // are present just where they're required. @@ -1640,7 +2790,7 @@ void main() { // just needs messages that have the same recipient, and that don't, and // doesn't need to exercise the different reasons that messages don't. - const timestamp = 1693602618; + final timestamp = eg.utcTimestamp(clock.now()); final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); Message streamMessage(int id) => eg.streamMessage(id: id, stream: stream, topic: 'foo', timestamp: timestamp); @@ -1648,7 +2798,7 @@ void main() { eg.dmMessage(id: id, from: eg.selfUser, to: [], timestamp: timestamp); // First, test fetchInitial, where some headers are needed and others not. - await prepare(); + await prepare(narrow: CombinedFeedNarrow()); connection.prepare(json: newestResult( foundOldest: false, messages: [streamMessage(10), streamMessage(11), dmMessage(12)], @@ -1675,11 +2825,11 @@ void main() { checkNotified(count: 2); // Then test MessageEvent, where a new header is needed… - await store.handleEvent(MessageEvent(id: 0, message: streamMessage(13))); + await store.addMessage(streamMessage(13)); checkNotifiedOnce(); // … and where it's not. - await store.handleEvent(MessageEvent(id: 0, message: streamMessage(14))); + await store.addMessage(streamMessage(14)); checkNotifiedOnce(); // Then test UpdateMessageEvent edits, where a header is and remains needed… @@ -1699,6 +2849,20 @@ void main() { model.reassemble(); checkNotifiedOnce(); + // Then test outbox message, where a new header is needed… + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage( + destination: DmDestination(userIds: [eg.selfUser.userId]), content: 'hi'); + async.elapse(kLocalEchoDebounceDuration); + checkNotifiedOnce(); + + // … and where it's not. + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage( + destination: DmDestination(userIds: [eg.selfUser.userId]), content: 'hi'); + async.elapse(kLocalEchoDebounceDuration); + checkNotifiedOnce(); + // Have a new fetchOlder reach the oldest, so that a history-start marker appears… connection.prepare(json: olderResult( anchor: model.messages[0].id, @@ -1711,48 +2875,132 @@ void main() { // … and then test reassemble again. model.reassemble(); checkNotifiedOnce(); + + final outboxMessageIds = store.outboxMessages.keys.toList(); + // Then test removing the first outbox message… + await store.handleEvent(eg.messageEvent( + dmMessage(15), localMessageId: outboxMessageIds.first)); + checkNotifiedOnce(); + + // … and handling a new non-outbox message… + await store.handleEvent(eg.messageEvent(streamMessage(16))); + checkNotifiedOnce(); + + // … and removing the second outbox message. + await store.handleEvent(eg.messageEvent( + dmMessage(17), localMessageId: outboxMessageIds.last)); + checkNotifiedOnce(); + })); + + group('one message per block?', () { + final channelId = 1; + final topic = 'some topic'; + void doTest({required Narrow narrow, required bool expected}) { + test('$narrow: ${expected ? 'yes' : 'no'}', () => awaitFakeAsync((async) async { + final sender = eg.user(); + final channel = eg.stream(streamId: channelId); + final message1 = eg.streamMessage( + sender: sender, + stream: channel, + topic: topic, + flags: [MessageFlag.starred, MessageFlag.mentioned], + ); + final message2 = eg.streamMessage( + sender: sender, + stream: channel, + topic: topic, + flags: [MessageFlag.starred, MessageFlag.mentioned], + ); + + await prepare( + narrow: narrow, + stream: channel, + ); + connection.prepare(json: newestResult( + foundOldest: false, + messages: [message1, message2], + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + + check(model).items.deepEquals(>[ + (it) => it.isA(), + (it) => it.isA(), + if (expected) (it) => it.isA(), + (it) => it.isA(), + ]); + })); + } + + doTest(narrow: CombinedFeedNarrow(), expected: false); + doTest(narrow: ChannelNarrow(channelId), expected: false); + doTest(narrow: TopicNarrow(channelId, eg.t(topic)), expected: false); + doTest(narrow: StarredMessagesNarrow(), expected: true); + doTest(narrow: MentionsNarrow(), expected: true); }); - test('showSender is maintained correctly', () async { + test('showSender is maintained correctly', () => awaitFakeAsync((async) async { // TODO(#150): This will get more complicated with message moves. // Until then, we always compute this sequentially from oldest to newest. // So we just need to exercise the different cases of the logic for // whether the sender should be shown, but the difference between // fetchInitial and handleMessageEvent etc. doesn't matter. - const t1 = 1693602618; - const t2 = t1 + 86400; + // Elapse test's clock to a specific time, to avoid any flaky-ness + // that may be caused by a specific local time of the day. + final initialTime = DateTime(2035, 8, 21); + async.elapse(initialTime.difference(clock.now())); + + final now = clock.now(); + final t1 = eg.utcTimestamp(now.subtract(Duration(days: 1))); + final t2 = t1 + Duration(minutes: 1).inSeconds; + final t3 = t2 + Duration(minutes: 10, seconds: 1).inSeconds; + final t4 = eg.utcTimestamp(now); final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); - Message streamMessage(int id, int timestamp, User sender) => - eg.streamMessage(id: id, sender: sender, + Message streamMessage(int timestamp, User sender) => + eg.streamMessage(sender: sender, stream: stream, topic: 'foo', timestamp: timestamp); - Message dmMessage(int id, int timestamp, User sender) => - eg.dmMessage(id: id, from: sender, timestamp: timestamp, + Message dmMessage(int timestamp, User sender) => + eg.dmMessage(from: sender, timestamp: timestamp, to: [sender.userId == eg.selfUser.userId ? eg.otherUser : eg.selfUser]); + DmDestination dmDestination(List users) => + DmDestination(userIds: users.map((user) => user.userId).toList()); await prepare(); await prepareMessages(foundOldest: true, messages: [ - streamMessage(1, t1, eg.selfUser), // first message, so show sender - streamMessage(2, t1, eg.selfUser), // hide sender - streamMessage(3, t1, eg.otherUser), // no recipient header, but new sender - dmMessage(4, t1, eg.otherUser), // same sender, but new recipient - dmMessage(5, t2, eg.otherUser), // same sender/recipient, but new day + streamMessage(t1, eg.selfUser), // first message, so show sender + streamMessage(t1, eg.selfUser), // hide sender + streamMessage(t1, eg.otherUser), // no recipient header, but new sender + streamMessage(t2, eg.otherUser), // same sender, and within 10 mins of last message + streamMessage(t3, eg.otherUser), // same sender, but after 10 mins from last message + dmMessage( t3, eg.otherUser), // same sender, but new recipient + dmMessage( t4, eg.otherUser), // same sender/recipient, but new day + ]); + await prepareOutboxMessagesTo([ + dmDestination([eg.selfUser, eg.otherUser]), // same day, but new sender + dmDestination([eg.selfUser, eg.otherUser]), // hide sender ]); + assert( + store.outboxMessages.values.every((message) => message.timestamp == t4)); + async.elapse(kLocalEchoDebounceDuration); // We check showSender has the right values in [checkInvariants], // but to make this test explicit: check(model.items).deepEquals()>[ - (it) => it.isA(), (it) => it.isA(), (it) => it.isA().showSender.isTrue(), (it) => it.isA().showSender.isFalse(), (it) => it.isA().showSender.isTrue(), + (it) => it.isA().showSender.isFalse(), + (it) => it.isA().showSender.isTrue(), (it) => it.isA(), (it) => it.isA().showSender.isTrue(), (it) => it.isA(), (it) => it.isA().showSender.isTrue(), + (it) => it.isA().showSender.isTrue(), + (it) => it.isA().showSender.isFalse(), ]); - }); + })); group('haveSameRecipient', () { test('stream messages vs DMs, no match', () { @@ -1823,16 +3071,26 @@ void main() { doTest('same letters, different diacritics', 'ma', 'mǎ', false); doTest('having different CJK characters', '嗎', '馬', false); }); + + test('outbox messages', () { + final stream = eg.stream(); + final streamMessage1 = eg.streamOutboxMessage(stream: stream, topic: 'foo'); + final streamMessage2 = eg.streamOutboxMessage(stream: stream, topic: 'bar'); + final dmMessage = eg.dmOutboxMessage(from: eg.selfUser, to: [eg.otherUser]); + check(haveSameRecipient(streamMessage1, streamMessage1)).isTrue(); + check(haveSameRecipient(streamMessage1, streamMessage2)).isFalse(); + check(haveSameRecipient(streamMessage1, dmMessage)).isFalse(); + }); }); - test('messagesSameDay', () { - // These timestamps will differ depending on the timezone of the - // environment where the tests are run, in order to give the same results - // in the code under test which is also based on the ambient timezone. - // TODO(dart): It'd be great if tests could control the ambient timezone, - // so as to exercise cases like where local time falls back across midnight. - int timestampFromLocalTime(String date) => DateTime.parse(date).millisecondsSinceEpoch ~/ 1000; + // These timestamps will differ depending on the timezone of the + // environment where the tests are run, in order to give the same results + // in the code under test which is also based on the ambient timezone. + // TODO(dart): It'd be great if tests could control the ambient timezone, + // so as to exercise cases like where local time falls back across midnight. + int timestampFromLocalTime(String date) => DateTime.parse(date).millisecondsSinceEpoch ~/ 1000; + test('messagesSameDay', () { const t111a = '2021-01-01 00:00:00'; const t111b = '2021-01-01 12:00:00'; const t111c = '2021-01-01 23:59:58'; @@ -1858,50 +3116,151 @@ void main() { eg.dmMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time0)), eg.dmMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time1)), )).equals(i0 == i1); + check(because: 'times $time0, $time1', messagesSameDay( + eg.streamOutboxMessage(timestamp: timestampFromLocalTime(time0)), + eg.streamOutboxMessage(timestamp: timestampFromLocalTime(time1)), + )).equals(i0 == i1); + check(because: 'times $time0, $time1', messagesSameDay( + eg.dmOutboxMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time0)), + eg.dmOutboxMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time1)), + )).equals(i0 == i1); } } } } }); + + group('messagesCloseInTime', () { + final stream = eg.stream(); + void doTest(String time0, String time1, bool expected) { + test('$time0 vs $time1 -> $expected', () { + check(messagesCloseInTime( + eg.streamMessage(stream: stream, topic: 'foo', timestamp: timestampFromLocalTime(time0)), + eg.streamMessage(stream: stream, topic: 'foo', timestamp: timestampFromLocalTime(time1)), + )).equals(expected); + check(messagesCloseInTime( + eg.dmMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time0)), + eg.dmMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time1)), + )).equals(expected); + check(messagesCloseInTime( + eg.streamOutboxMessage(timestamp: timestampFromLocalTime(time0)), + eg.streamOutboxMessage(timestamp: timestampFromLocalTime(time1)), + )).equals(expected); + check(messagesCloseInTime( + eg.dmOutboxMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time0)), + eg.dmOutboxMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time1)), + )).equals(expected); + }); + } + + const time = '2021-01-01 00:30:00'; + + doTest('2021-01-01 00:19:59', time, false); + doTest('2021-01-01 00:20:00', time, true); + doTest('2021-01-01 00:29:59', time, true); + doTest('2021-01-01 00:30:00', time, true); + + doTest(time, '2021-01-01 00:30:01', true); + doTest(time, '2021-01-01 00:39:59', true); + doTest(time, '2021-01-01 00:40:00', true); + doTest(time, '2021-01-01 00:40:01', false); + + doTest(time, '2022-01-01 00:30:00', false); + doTest(time, '2021-02-01 00:30:00', false); + doTest(time, '2021-01-02 00:30:00', false); + doTest(time, '2021-01-01 01:30:00', false); + }); } +MessageListView? _lastModel; +List? _lastMessages; +int? _lastMiddleMessage; + void checkInvariants(MessageListView model) { if (!model.fetched) { check(model) ..messages.isEmpty() + ..outboxMessages.isEmpty() ..haveOldest.isFalse() - ..fetchingOlder.isFalse() - ..fetchOlderCoolingDown.isFalse(); - } - if (model.haveOldest) { - check(model).fetchingOlder.isFalse(); - check(model).fetchOlderCoolingDown.isFalse(); + ..haveNewest.isFalse() + ..busyFetchingMore.isFalse(); } - if (model.fetchingOlder) { - check(model).fetchOlderCoolingDown.isFalse(); + if (model.haveOldest && model.haveNewest) { + check(model).busyFetchingMore.isFalse(); } for (final message in model.messages) { check(model.store.messages)[message.id].isNotNull().identicalTo(message); - check(model.narrow.containsMessage(message)).isTrue(); - - if (message is! StreamMessage) continue; - switch (model.narrow) { - case CombinedFeedNarrow(): - check(model.store.isTopicVisible(message.streamId, message.topic)) - .isTrue(); - case ChannelNarrow(): - check(model.store.isTopicVisibleInStream(message.streamId, message.topic)) - .isTrue(); - case TopicNarrow(): - case DmNarrow(): - case MentionsNarrow(): - case StarredMessagesNarrow(): + } + if (model.outboxMessages.isNotEmpty) { + check(model.haveNewest).isTrue(); + } + for (final message in model.outboxMessages) { + check(message).hidden.isFalse(); + check(model.store.outboxMessages)[message.localMessageId].isNotNull().identicalTo(message); + } + + final allMessages = [...model.messages, ...model.outboxMessages]; + + for (final message in allMessages) { + check(model.narrow.containsMessage(message)).anyOf(>[ + (it) => it.isNull(), + (it) => it.isNotNull().isTrue(), + ]); + + if (message is MessageBase) { + final conversation = message.conversation; + switch (model.narrow) { + case CombinedFeedNarrow(): + check(model.store.isTopicVisible(conversation.streamId, conversation.topic)) + .isTrue(); + case ChannelNarrow(): + check(model.store.isTopicVisibleInStream(conversation.streamId, conversation.topic)) + .isTrue(); + case TopicNarrow(): + case DmNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + } + } else if (message is DmMessage) { + final narrow = DmNarrow.ofMessage(message, selfUserId: model.store.selfUserId); + switch (model.narrow) { + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + check(model.store.shouldMuteDmConversation(narrow)).isFalse(); + case ChannelNarrow(): + case TopicNarrow(): + case DmNarrow(): + } } } check(isSortedWithoutDuplicates(model.messages.map((m) => m.id).toList())) .isTrue(); + check(isSortedWithoutDuplicates(model.outboxMessages.map((m) => m.localMessageId).toList())) + .isTrue(); + + check(model).middleMessage + ..isGreaterOrEqual(0) + ..isLessOrEqual(model.messages.length); + + if (identical(model, _lastModel) + && model.generation == _lastModel!.generation) { + // All messages that were present, and still are, should be on the same side + // of `middleMessage` (still top or bottom slice respectively) as they were. + _checkNoIntersection(ListSlice(model.messages, 0, model.middleMessage), + ListSlice(_lastMessages!, _lastMiddleMessage!, _lastMessages!.length), + because: 'messages moved from bottom slice to top slice'); + _checkNoIntersection(ListSlice(_lastMessages!, 0, _lastMiddleMessage!), + ListSlice(model.messages, model.middleMessage, model.messages.length), + because: 'messages moved from top slice to bottom slice'); + } + _lastModel = model; + _lastMessages = model.messages.toList(); + _lastMiddleMessage = model.middleMessage; check(model).contents.length.equals(model.messages.length); for (int i = 0; i < model.contents.length; i++) { @@ -1915,64 +3274,101 @@ void checkInvariants(MessageListView model) { } int i = 0; - if (model.haveOldest) { - check(model.items[i++]).isA(); - } - if (model.fetchingOlder || model.fetchOlderCoolingDown) { - check(model.items[i++]).isA(); - } - for (int j = 0; j < model.messages.length; j++) { - bool forcedShowSender = false; + for (int j = 0; j < allMessages.length; j++) { + final bool showSender; if (j == 0 - || !haveSameRecipient(model.messages[j-1], model.messages[j])) { + || model.oneMessagePerBlock + || !haveSameRecipient(allMessages[j-1], allMessages[j])) { check(model.items[i++]).isA() - .message.identicalTo(model.messages[j]); - forcedShowSender = true; - } else if (!messagesSameDay(model.messages[j-1], model.messages[j])) { + .message.identicalTo(allMessages[j]); + showSender = true; + } else if (!messagesSameDay(allMessages[j-1], allMessages[j])) { check(model.items[i++]).isA() - .message.identicalTo(model.messages[j]); - forcedShowSender = true; + .message.identicalTo(allMessages[j]); + showSender = true; + } else if (allMessages[j-1].senderId == allMessages[j].senderId) { + showSender = !messagesCloseInTime(allMessages[j-1], allMessages[j]); + } else { + showSender = true; } - check(model.items[i++]).isA() - ..message.identicalTo(model.messages[j]) - ..content.identicalTo(model.contents[j]) - ..showSender.equals( - forcedShowSender || model.messages[j].senderId != model.messages[j-1].senderId) + if (j < model.messages.length) { + check(model.items[i]).isA() + ..message.identicalTo(model.messages[j]) + ..content.identicalTo(model.contents[j]); + } else { + check(model.items[i]).isA() + .message.identicalTo(model.outboxMessages[j-model.messages.length]); + } + check(model.items[i++]).isA() + ..showSender.equals(showSender) ..isLastInBlock.equals( i == model.items.length || switch (model.items[i]) { MessageListMessageItem() + || MessageListOutboxMessageItem() || MessageListDateSeparatorItem() => false, - MessageListRecipientHeaderItem() - || MessageListHistoryStartItem() - || MessageListLoadingItem() => true, + MessageListRecipientHeaderItem() => true, }); } check(model.items).length.equals(i); + + check(model).middleItem + ..isGreaterOrEqual(0) + ..isLessOrEqual(model.items.length); + if (model.middleMessage == model.messages.length) { + if (model.outboxMessages.isEmpty) { + // the bottom slice of `model.messages` is empty + check(model).middleItem.equals(model.items.length); + } else { + check(model.items[model.middleItem]).isA() + .message.identicalTo(model.outboxMessages.first); + } + } else { + check(model.items[model.middleItem]).isA() + .message.identicalTo(model.messages[model.middleMessage]); + } +} + +void _checkNoIntersection(List xs, List ys, {String? because}) { + // Both lists are sorted by ID. As an optimization, bet on all or nearly all + // of the first list having smaller IDs than all or nearly all of the other. + if (xs.isEmpty || ys.isEmpty) return; + if (xs.last.id < ys.first.id) return; + final yCandidates = Set.of(ys.takeWhile((m) => m.id <= xs.last.id)); + final intersection = xs.reversed.takeWhile((m) => ys.first.id <= m.id) + .where(yCandidates.contains); + check(intersection, because: because).isEmpty(); } extension MessageListRecipientHeaderItemChecks on Subject { - Subject get message => has((x) => x.message, 'message'); + Subject get message => has((x) => x.message, 'message'); } extension MessageListDateSeparatorItemChecks on Subject { - Subject get message => has((x) => x.message, 'message'); + Subject get message => has((x) => x.message, 'message'); } -extension MessageListMessageItemChecks on Subject { - Subject get message => has((x) => x.message, 'message'); +extension MessageListMessageBaseItemChecks on Subject { + Subject get message => has((x) => x.message, 'message'); Subject get content => has((x) => x.content, 'content'); Subject get showSender => has((x) => x.showSender, 'showSender'); Subject get isLastInBlock => has((x) => x.isLastInBlock, 'isLastInBlock'); } +extension MessageListMessageItemChecks on Subject { + Subject get message => has((x) => x.message, 'message'); +} + extension MessageListViewChecks on Subject { Subject get store => has((x) => x.store, 'store'); Subject get narrow => has((x) => x.narrow, 'narrow'); Subject> get messages => has((x) => x.messages, 'messages'); + Subject> get outboxMessages => has((x) => x.outboxMessages, 'outboxMessages'); + Subject get middleMessage => has((x) => x.middleMessage, 'middleMessage'); Subject> get contents => has((x) => x.contents, 'contents'); Subject> get items => has((x) => x.items, 'items'); + Subject get middleItem => has((x) => x.middleItem, 'middleItem'); Subject get fetched => has((x) => x.fetched, 'fetched'); Subject get haveOldest => has((x) => x.haveOldest, 'haveOldest'); - Subject get fetchingOlder => has((x) => x.fetchingOlder, 'fetchingOlder'); - Subject get fetchOlderCoolingDown => has((x) => x.fetchOlderCoolingDown, 'fetchOlderCoolingDown'); + Subject get haveNewest => has((x) => x.haveNewest, 'haveNewest'); + Subject get busyFetchingMore => has((x) => x.busyFetchingMore, 'busyFetchingMore'); } diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 43f17be61a..7dbf28a47d 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -1,10 +1,19 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:crypto/crypto.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; +import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/submessage.dart'; +import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -13,12 +22,18 @@ import '../api/fake_api.dart'; import '../api/model/model_checks.dart'; import '../api/model/submessage_checks.dart'; import '../example_data.dart' as eg; +import '../fake_async.dart'; +import '../fake_async_checks.dart'; import '../stdlib_checks.dart'; +import 'binding.dart'; +import 'message_checks.dart'; import 'message_list_test.dart'; import 'store_checks.dart'; import 'test_store.dart'; void main() { + TestZulipBinding.ensureInitialized(); + // These "late" variables are the common state operated on by each test. // Each test case calls [prepare] to initialize them. late Subscription subscription; @@ -37,33 +52,40 @@ void main() { void checkNotifiedOnce() => checkNotified(count: 1); /// Initialize [store] and the rest of the test state. - Future prepare({Narrow narrow = const CombinedFeedNarrow()}) async { - final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); + Future prepare({ + ZulipStream? stream, + int? zulipFeatureLevel, + }) async { + stream ??= eg.stream(streamId: eg.defaultStreamMessageStreamId); subscription = eg.subscription(stream); - store = eg.store(); + final selfAccount = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + store = eg.store(account: selfAccount, + initialSnapshot: eg.initialSnapshot(zulipFeatureLevel: zulipFeatureLevel)); await store.addStream(stream); await store.addSubscription(subscription); connection = store.connection as FakeApiConnection; notifiedCount = 0; - messageList = MessageListView.init(store: store, narrow: narrow) + messageList = MessageListView.init(store: store, + narrow: const CombinedFeedNarrow(), + anchor: AnchorCode.newest) ..addListener(() { notifiedCount++; }); + addTearDown(messageList.dispose); check(messageList).fetched.isFalse(); checkNotNotified(); + + // This cleans up possibly pending timers from [MessageStoreImpl]. + addTearDown(store.dispose); } /// Perform the initial message fetch for [messageList]. /// /// The test case must have already called [prepare] to initialize the state. - /// - /// This does not support submessages. Use [prepareMessageWithSubmessages] - /// instead if needed. Future prepareMessages( List messages, { bool foundOldest = false, }) async { - assert(messages.every((message) => message.poll == null)); connection.prepare(json: eg.newestGetMessagesResult(foundOldest: foundOldest, messages: messages).toJson()); await messageList.fetchInitial(); @@ -71,22 +93,408 @@ void main() { } Future addMessages(Iterable messages) async { - for (final m in messages) { - await store.handleEvent(MessageEvent(id: 0, message: m)); - } + await store.addMessages(messages); checkNotified(count: messageList.fetched ? messages.length : 0); } - test('disposing multiple registered MessageListView instances', () async { - // Regression test for: https://github.com/zulip/zulip-flutter/issues/810 - await prepare(narrow: const MentionsNarrow()); - MessageListView.init(store: store, narrow: const StarredMessagesNarrow()); - check(store.debugMessageListViews).length.equals(2); + test('dispose cancels pending timers', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + final store = eg.store(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + + (store.connection as FakeApiConnection).prepare( + json: SendMessageResult(id: 1).toJson(), + delay: const Duration(seconds: 1)); + unawaited(store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t('topic')), + content: 'content')); + check(async.pendingTimers).deepEquals(>[ + (it) => it.isA().duration.equals(kLocalEchoDebounceDuration), + (it) => it.isA().duration.equals(kSendMessageOfferRestoreWaitPeriod), + (it) => it.isA().duration.equals(const Duration(seconds: 1)), + ]); - // When disposing, the [MessageListView]s are expected to unregister - // themselves from the message store. store.dispose(); - check(store.debugMessageListViews).isEmpty(); + check(async.pendingTimers).single.duration.equals(const Duration(seconds: 1)); + })); + + group('sendMessage', () { + test('smoke', () async { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + queueId: 'fb67bf8a-c031-47cc-84cf-ed80accacda8')); + final connection = store.connection as FakeApiConnection; + final stream = eg.stream(); + connection.prepare(json: SendMessageResult(id: 12345).toJson()); + await store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t('world')), + content: 'hello'); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages') + ..bodyFields.deepEquals({ + 'type': 'stream', + 'to': stream.streamId.toString(), + 'topic': 'world', + 'content': 'hello', + 'read_by_sender': 'true', + 'queue_id': 'fb67bf8a-c031-47cc-84cf-ed80accacda8', + 'local_id': store.outboxMessages.keys.single.toString(), + }); + }); + + final stream = eg.stream(); + final streamDestination = StreamDestination(stream.streamId, eg.t('some topic')); + late StreamMessage message; + + test('outbox messages get unique localMessageId', () async { + await prepare(stream: stream); + await prepareMessages([]); + + for (int i = 0; i < 10; i++) { + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage(destination: streamDestination, content: 'content'); + } + // [store.outboxMessages] has the same number of keys (localMessageId) + // as the number of sent messages, which are guaranteed to be distinct. + check(store.outboxMessages).keys.length.equals(10); + }); + + Subject checkState() => + check(store.outboxMessages).values.single.state; + + Future prepareOutboxMessage({ + MessageDestination? destination, + int? zulipFeatureLevel, + }) async { + message = eg.streamMessage(stream: stream); + await prepare(stream: stream, zulipFeatureLevel: zulipFeatureLevel); + await prepareMessages([eg.streamMessage(stream: stream)]); + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage( + destination: destination ?? streamDestination, content: 'content'); + } + + late Future outboxMessageFailFuture; + Future prepareOutboxMessageToFailAfterDelay(Duration delay) async { + message = eg.streamMessage(stream: stream); + await prepare(stream: stream); + await prepareMessages([eg.streamMessage(stream: stream)]); + connection.prepare(httpException: SocketException('failed'), delay: delay); + outboxMessageFailFuture = store.sendMessage( + destination: streamDestination, content: 'content'); + } + + Future receiveMessage([Message? messageReceived]) async { + await store.handleEvent(eg.messageEvent(messageReceived ?? message, + localMessageId: store.outboxMessages.keys.single)); + } + + test('smoke DM: hidden -> waiting -> (delete)', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(destination: DmDestination( + userIds: [eg.selfUser.userId, eg.otherUser.userId])); + checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); + + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + await receiveMessage(eg.dmMessage(from: eg.selfUser, to: [eg.otherUser])); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + + test('smoke stream message: hidden -> waiting -> (delete)', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(destination: StreamDestination( + stream.streamId, eg.t('foo'))); + checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); + + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + await receiveMessage(eg.streamMessage(stream: stream, topic: 'foo')); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + + test('hidden -> waiting and never transition to waitPeriodExpired', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(); + checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); + + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after + // the send request was initiated. + async.elapse( + kSendMessageOfferRestoreWaitPeriod - kLocalEchoDebounceDuration); + async.flushTimers(); + // The outbox message should stay in the waiting state; + // it should not transition to waitPeriodExpired. + checkState().equals(OutboxMessageState.waiting); + checkNotNotified(); + })); + + test('waiting -> waitPeriodExpired', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + async.elapse(kSendMessageOfferRestoreWaitPeriod - kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waitPeriodExpired); + checkNotifiedOnce(); + + await check(outboxMessageFailFuture).throws(); + })); + + test('waiting -> waitPeriodExpired -> waiting and never return to waitPeriodExpired', () => awaitFakeAsync((async) async { + await prepare(stream: stream); + await prepareMessages([eg.streamMessage(stream: stream)]); + // Set up a [sendMessage] request that succeeds after enough delay, + // for the outbox message to reach the waitPeriodExpired state. + // TODO extract helper to add prepare an outbox message with a delayed + // successful [sendMessage] request if we have more tests like this + connection.prepare(json: SendMessageResult(id: 1).toJson(), + delay: kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + final future = store.sendMessage( + destination: streamDestination, content: 'content'); + async.elapse(kSendMessageOfferRestoreWaitPeriod); + checkState().equals(OutboxMessageState.waitPeriodExpired); + checkNotified(count: 2); + + // Wait till the [sendMessage] request succeeds. + await future; + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after + // returning to the waiting state. + async.elapse(kSendMessageOfferRestoreWaitPeriod); + async.flushTimers(); + // The outbox message should stay in the waiting state; + // it should not transition to waitPeriodExpired. + checkState().equals(OutboxMessageState.waiting); + checkNotNotified(); + })); + + group('… -> failed', () { + test('hidden -> failed', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay(Duration.zero); + checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); + + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + checkNotifiedOnce(); + + // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after + // the send request was initiated. + async.elapse(kSendMessageOfferRestoreWaitPeriod); + async.flushTimers(); + // The outbox message should stay in the failed state; + // it should not transition to waitPeriodExpired. + checkState().equals(OutboxMessageState.failed); + checkNotNotified(); + })); + + test('waiting -> failed', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay( + kLocalEchoDebounceDuration + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + checkNotifiedOnce(); + })); + + test('waitPeriodExpired -> failed', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kSendMessageOfferRestoreWaitPeriod); + checkState().equals(OutboxMessageState.waitPeriodExpired); + checkNotified(count: 2); + + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + checkNotifiedOnce(); + })); + }); + + group('… -> (delete)', () { + test('hidden -> (delete) because event received', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(); + checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); + + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + + test('hidden -> (delete) when event arrives before send request fails', () => awaitFakeAsync((async) async { + // Set up an error to fail `sendMessage` with a delay, leaving time for + // the message event to arrive. + await prepareOutboxMessageToFailAfterDelay(const Duration(seconds: 1)); + checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); + + // Received the message event while the message is being sent. + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + + // Complete the send request. There should be no error despite + // the send request failure, because the outbox message is not + // in the store any more. + await check(outboxMessageFailFuture).completes(); + async.elapse(const Duration(seconds: 1)); + checkNotNotified(); + })); + + test('waiting -> (delete) because event received', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + + test('waiting -> (delete) when event arrives before send request fails', () => awaitFakeAsync((async) async { + // Set up an error to fail `sendMessage` with a delay, leaving time for + // the message event to arrive. + await prepareOutboxMessageToFailAfterDelay( + kLocalEchoDebounceDuration + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + checkNotifiedOnce(); + + // Received the message event while the message is being sent. + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + + // Complete the send request. There should be no error despite + // the send request failure, because the outbox message is not + // in the store any more. + await check(outboxMessageFailFuture).completes(); + checkNotNotified(); + })); + + test('waitPeriodExpired -> (delete) when event arrives before send request fails', () => awaitFakeAsync((async) async { + // Set up an error to fail `sendMessage` with a delay, leaving time for + // the message event to arrive. + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kSendMessageOfferRestoreWaitPeriod); + checkState().equals(OutboxMessageState.waitPeriodExpired); + checkNotified(count: 2); + + // Received the message event while the message is being sent. + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + + // Complete the send request. There should be no error despite + // the send request failure, because the outbox message is not + // in the store any more. + await check(outboxMessageFailFuture).completes(); + checkNotNotified(); + })); + + test('waitPeriodExpired -> (delete) because outbox message was taken', () => awaitFakeAsync((async) async { + // Set up an error to fail `sendMessage` with a delay, leaving time for + // the outbox message to be taken (by the user, presumably). + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kSendMessageOfferRestoreWaitPeriod); + checkState().equals(OutboxMessageState.waitPeriodExpired); + checkNotified(count: 2); + + store.takeOutboxMessage(store.outboxMessages.keys.single); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + + test('failed -> (delete) because event received', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay(Duration.zero); + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + checkNotifiedOnce(); + + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + + test('failed -> (delete) because outbox message was taken', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay(Duration.zero); + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + checkNotifiedOnce(); + + store.takeOutboxMessage(store.outboxMessages.keys.single); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + })); + }); + + test('when sending to "(no topic)", process topic like the server does when creating outbox message', () => awaitFakeAsync((async) async { + await prepareOutboxMessage( + destination: StreamDestination(stream.streamId, TopicName('(no topic)')), + zulipFeatureLevel: 370); + async.elapse(kLocalEchoDebounceDuration); + check(store.outboxMessages).values.single + .conversation.isA().topic.equals(eg.t('')); + })); + + test('legacy: when sending to "(no topic)", process topic like the server does when creating outbox message', () => awaitFakeAsync((async) async { + await prepareOutboxMessage( + destination: StreamDestination(stream.streamId, TopicName('(no topic)')), + zulipFeatureLevel: 369); + async.elapse(kLocalEchoDebounceDuration); + check(store.outboxMessages).values.single + .conversation.isA().topic.equals(eg.t('(no topic)')); + })); + + test('set timestamp to now when creating outbox messages', () => awaitFakeAsync( + initialTime: eg.timeInPast, + (async) async { + await prepareOutboxMessage(); + check(store.outboxMessages).values.single + .timestamp.equals(eg.utcTimestamp(eg.timeInPast)); + }, + )); + }); + + test('takeOutboxMessage', () async { + final stream = eg.stream(); + await prepare(stream: stream); + await prepareMessages([]); + + for (int i = 0; i < 10; i++) { + connection.prepare(apiException: eg.apiBadRequest()); + await check(store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t('topic')), + content: 'content')).throws(); + checkNotifiedOnce(); + } + + final localMessageIds = store.outboxMessages.keys.toList(); + store.takeOutboxMessage(localMessageIds.removeAt(5)); + check(store.outboxMessages).keys.deepEquals(localMessageIds); + checkNotifiedOnce(); }); group('reconcileMessages', () { @@ -96,7 +504,7 @@ void main() { final message1 = eg.streamMessage(); final message2 = eg.streamMessage(); final message3 = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); - final messages = [message1, message2, message3]; + final messages = [message1, message2, message3]; store.reconcileMessages(messages); check(messages).deepEquals( [message1, message2, message3] @@ -111,7 +519,7 @@ void main() { final message1 = eg.streamMessage(); final message2 = eg.streamMessage(); final message3 = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); - final messages = [message1, message2, message3]; + final messages = [message1, message2, message3]; await addMessages(messages); final newMessage = eg.streamMessage(); store.reconcileMessages([newMessage]); @@ -135,6 +543,651 @@ void main() { check(messages).single.identicalTo(message); check(store.messages).deepEquals({1: message}); }); + + test('matchContent and matchTopic are removed', () async { + await prepare(); + final message1 = eg.streamMessage(id: 1, content: '

        foo

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

        foo

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

        bar

        ', + matchContent: 'some highlighted content', + matchTopic: 'some highlighted topic'); + final messages = [otherMessage1, message2]; + store.reconcileMessages(messages); + + Condition conditionIdenticalAndNullMatchFields(Message message) { + return (it) => it.isA() + ..identicalTo(message) + ..matchContent.isNull()..matchTopic.isNull(); + } + + check(messages).deepEquals([ + conditionIdenticalAndNullMatchFields(message1), + conditionIdenticalAndNullMatchFields(message2), + ]); + + check(store.messages).deepEquals({ + 1: conditionIdenticalAndNullMatchFields(message1), + 2: conditionIdenticalAndNullMatchFields(message2), + }); + }); + }); + + group('edit-message methods', () { + late StreamMessage message; + Future prepareEditMessage() async { + await prepare(); + message = eg.streamMessage(); + await prepareMessages([message]); + check(connection.takeRequests()).length.equals(1); // message-list fetchInitial + } + + void checkRequest(int messageId, { + required String prevContent, + required String content, + }) { + final prevContentSha256 = sha256.convert(utf8.encode(prevContent)).toString(); + check(connection.takeRequests()).single.isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/messages/$messageId') + ..bodyFields.deepEquals({ + 'prev_content_sha256': prevContentSha256, + 'content': content, + }); + } + + test('smoke', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + unawaited(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content')); + checkRequest(message.id, + prevContent: 'old content', + content: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Mid-request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + + async.elapse(Duration(milliseconds: 500)); + // Request has succeeded; event hasn't arrived + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + checkNotNotified(); + + await store.handleEvent(eg.updateMessageEditEvent(message)); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + })); + + test('concurrent edits on different messages', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + final otherMessage = eg.streamMessage(); + await store.addMessage(otherMessage); + checkNotifiedOnce(); + + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + unawaited(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content')); + checkRequest(message.id, + prevContent: 'old content', + content: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Mid-first request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + check(store.getEditMessageErrorStatus(otherMessage.id)).isNull(); + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + unawaited(store.editMessage(messageId: otherMessage.id, + originalRawContent: 'other message old content', newContent: 'other message new content')); + checkRequest(otherMessage.id, + prevContent: 'other message old content', + content: 'other message new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // First request has succeeded; event hasn't arrived + // Mid-second request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + check(store.getEditMessageErrorStatus(otherMessage.id)).isNotNull().isFalse(); + checkNotNotified(); + + // First event arrives + await store.handleEvent(eg.updateMessageEditEvent(message)); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Second request has succeeded; event hasn't arrived + check(store.getEditMessageErrorStatus(otherMessage.id)).isNotNull().isFalse(); + checkNotNotified(); + + // Second event arrives + await store.handleEvent(eg.updateMessageEditEvent(otherMessage)); + check(store.getEditMessageErrorStatus(otherMessage.id)).isNull(); + checkNotifiedOnce(); + })); + + test('request fails', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + unawaited(check(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content')).throws()); + checkNotifiedOnce(); + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + })); + + test('request fails; take failed edit', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + unawaited(check(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content')).throws()); + checkNotifiedOnce(); + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + + check(store.takeFailedMessageEdit(message.id).newContent).equals('new content'); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + })); + + test('takeFailedMessageEdit throws StateError when nothing to take', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + check(() => store.takeFailedMessageEdit(message.id)).throws(); + })); + + test('editMessage throws StateError if editMessage already in progress for same message', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + unawaited(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content')); + async.elapse(Duration(milliseconds: 500)); + check(connection.takeRequests()).length.equals(1); + checkNotifiedOnce(); + + await check(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'newer content')) + .isA>().throws(); + check(connection.takeRequests()).isEmpty(); + })); + + test('event arrives, then request fails', () => awaitFakeAsync((async) async { + // This can happen with network issues. + + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + httpException: const SocketException('failed'), delay: Duration(seconds: 1)); + unawaited(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content')); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + await store.handleEvent(eg.updateMessageEditEvent(message)); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + + async.flushTimers(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotNotified(); + })); + + test('request fails, then event arrives', () => awaitFakeAsync((async) async { + // This can happen with network issues. + + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + httpException: const SocketException('failed'), delay: Duration(seconds: 1)); + unawaited(check(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content')).throws()); + checkNotifiedOnce(); + + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + + await store.handleEvent(eg.updateMessageEditEvent(message)); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + })); + + test('request fails, then event arrives; take failed edit in between', () => awaitFakeAsync((async) async { + // This can happen with network issues. + + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + httpException: const SocketException('failed'), delay: Duration(seconds: 1)); + unawaited(check(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content')).throws()); + checkNotifiedOnce(); + + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + check(store.takeFailedMessageEdit(message.id).newContent).equals('new content'); + checkNotifiedOnce(); + + await store.handleEvent(eg.updateMessageEditEvent(message)); // no error + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); // content updated + })); + + test('request fails, then message deleted', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + unawaited(check(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content')).throws()); + checkNotifiedOnce(); + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + + await store.handleEvent(eg.deleteMessageEvent([message])); // no error + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + })); + + test('message deleted while request in progress; we get failure response', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + unawaited(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content')); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Mid-request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + checkNotNotified(); + + await store.handleEvent(eg.deleteMessageEvent([message])); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Request failure, but status has already been cleared + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotNotified(); + })); + + test('message deleted while request in progress but we get success response', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + unawaited(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content')); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Mid-request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + checkNotNotified(); + + await store.handleEvent(eg.deleteMessageEvent([message])); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Request success + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotNotified(); + })); + }); + + group('selfCanDeleteMessage', () { + /// Call the method, with setup from [params]. + Future evaluate(CanDeleteMessageParams params) async { + final selfUser = eg.selfUser; + final botUserOwnedBySelf = eg.user(isBot: true, botOwnerId: selfUser.userId); + final botUserNotOwnedBySelf = eg.user(isBot: true, botOwnerId: eg.otherUser.userId); + + final groupWithSelf = eg.userGroup(members: [selfUser.userId]); + final groupWithoutSelf = eg.userGroup(members: [eg.otherUser.userId]); + final groupSettingWithSelf = GroupSettingValueNamed(groupWithSelf.id); + final groupSettingWithoutSelf = GroupSettingValueNamed(groupWithoutSelf.id); + + final GroupSettingValue? realmCanDeleteAnyMessageGroup; + final GroupSettingValue? realmCanDeleteOwnMessageGroup; + final RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy; + + if (params.inRealmCanDeleteAnyMessageGroup != null) { + realmCanDeleteAnyMessageGroup = params.inRealmCanDeleteAnyMessageGroup! + ? groupSettingWithSelf : groupSettingWithoutSelf; + } else { + realmCanDeleteAnyMessageGroup = null; + } + + if (params.inRealmCanDeleteOwnMessageGroup != null) { + assert(params.inRealmCanDeleteAnyMessageGroup != null); // TODO(server-10) + assert(params.realmDeleteOwnMessagePolicy == null); + realmCanDeleteOwnMessageGroup = params.inRealmCanDeleteOwnMessageGroup! + ? groupSettingWithSelf : groupSettingWithoutSelf; + } else { + realmCanDeleteOwnMessageGroup = null; + } + + if (params.realmDeleteOwnMessagePolicy != null) { + assert(params.inRealmCanDeleteOwnMessageGroup == null); + realmDeleteOwnMessagePolicy = params.realmDeleteOwnMessagePolicy!; + } else { + realmDeleteOwnMessagePolicy = null; + } + + final sender = switch (params.senderConfig) { + CanDeleteMessageSenderConfig.unknown => eg.user(), + CanDeleteMessageSenderConfig.self => selfUser, + CanDeleteMessageSenderConfig.otherHuman => eg.otherUser, + CanDeleteMessageSenderConfig.botOwnedBySelf => botUserOwnedBySelf, + CanDeleteMessageSenderConfig.botNotOwnedBySelf => botUserNotOwnedBySelf, + }; + + final channel = eg.stream(); + + final now = testBinding.utcNow(); + final timestamp = (now.millisecondsSinceEpoch ~/ 1000) - 60; + final Message message; + if (params.isChannelArchived != null) { + // testing with a channel message + message = eg.streamMessage(sender: sender, stream: channel, timestamp: timestamp); + channel.isArchived = params.isChannelArchived!; + if ( + params.inChannelCanDeleteAnyMessageGroup != null + && params.inChannelCanDeleteOwnMessageGroup != null + ) { + channel.canDeleteAnyMessageGroup = params.inChannelCanDeleteAnyMessageGroup! + ? groupSettingWithSelf : groupSettingWithoutSelf; + channel.canDeleteOwnMessageGroup = params.inChannelCanDeleteOwnMessageGroup! + ? groupSettingWithSelf : groupSettingWithoutSelf; + } else { + assert(params.inChannelCanDeleteAnyMessageGroup == null); + assert(params.inChannelCanDeleteOwnMessageGroup == null); + channel.canDeleteAnyMessageGroup = null; + channel.canDeleteOwnMessageGroup = null; + } + } else { + // testing with a DM message + final to = sender == selfUser ? [] : [selfUser]; + message = eg.dmMessage(from: sender, to: to, timestamp: timestamp); + } + + final realmMessageContentDeleteLimitSeconds = switch (params.timeLimitConfig) { + CanDeleteMessageTimeLimitConfig.notLimited => null, + CanDeleteMessageTimeLimitConfig.insideLimit => 24 * 60 * 60, + CanDeleteMessageTimeLimitConfig.outsideLimit => 1, + }; + + final store = eg.store( + selfUser: selfUser, + initialSnapshot: eg.initialSnapshot( + realmUsers: [selfUser, eg.otherUser, botUserOwnedBySelf, botUserNotOwnedBySelf], + streams: [channel], + realmUserGroups: [groupWithSelf, groupWithoutSelf], + realmCanDeleteAnyMessageGroup: realmCanDeleteAnyMessageGroup, + realmCanDeleteOwnMessageGroup: realmCanDeleteOwnMessageGroup, + realmMessageContentDeleteLimitSeconds: realmMessageContentDeleteLimitSeconds, + realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy)); + + await store.addMessage(message); + + return store.selfCanDeleteMessage(message.id, atDate: now); + } + + void doTest(bool expected, CanDeleteMessageParams params) { + test('params: ${params.describe()}', () async { + check(await evaluate(params)).equals(expected); + }); + } + + group('channel message', () { + doTest(true, CanDeleteMessageParams.permissiveForChannelMessageExcept()); + doTest(false, CanDeleteMessageParams.restrictiveForChannelMessageExcept()); + + group('denial conditions', () { + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + isChannelArchived: true)); + + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inChannelCanDeleteAnyMessageGroup: false, + senderConfig: CanDeleteMessageSenderConfig.unknown)); + + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inChannelCanDeleteAnyMessageGroup: false, + senderConfig: CanDeleteMessageSenderConfig.otherHuman)); + + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inChannelCanDeleteAnyMessageGroup: false, + senderConfig: CanDeleteMessageSenderConfig.botNotOwnedBySelf)); + + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inChannelCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: false, + inChannelCanDeleteOwnMessageGroup: false)); + + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inChannelCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: false, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.outsideLimit)); + + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inChannelCanDeleteAnyMessageGroup: false, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.outsideLimit)); + }); + + group('approval conditions', () { + doTest(true, CanDeleteMessageParams.restrictiveForChannelMessageExcept( + isChannelArchived: false, + inRealmCanDeleteAnyMessageGroup: true)); + + doTest(true, CanDeleteMessageParams.restrictiveForChannelMessageExcept( + isChannelArchived: false, + inChannelCanDeleteAnyMessageGroup: true)); + + doTest(true, CanDeleteMessageParams.restrictiveForChannelMessageExcept( + isChannelArchived: false, + senderConfig: CanDeleteMessageSenderConfig.self, + inRealmCanDeleteOwnMessageGroup: true, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited)); + + doTest(true, CanDeleteMessageParams.restrictiveForChannelMessageExcept( + isChannelArchived: false, + senderConfig: CanDeleteMessageSenderConfig.botOwnedBySelf, + inChannelCanDeleteOwnMessageGroup: true, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.insideLimit)); + }); + }); + + group('dm message', () { + doTest(true, CanDeleteMessageParams.permissiveForDmMessageExcept()); + doTest(false, CanDeleteMessageParams.restrictiveForDmMessageExcept()); + + group('denial conditions', () { + doTest(false, CanDeleteMessageParams.permissiveForDmMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + senderConfig: CanDeleteMessageSenderConfig.unknown)); + + doTest(false, CanDeleteMessageParams.permissiveForDmMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + senderConfig: CanDeleteMessageSenderConfig.otherHuman)); + + doTest(false, CanDeleteMessageParams.permissiveForDmMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + senderConfig: CanDeleteMessageSenderConfig.botNotOwnedBySelf)); + + doTest(false, CanDeleteMessageParams.permissiveForDmMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: false)); + + doTest(false, CanDeleteMessageParams.permissiveForDmMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.outsideLimit)); + }); + + group('approval conditions', () { + doTest(true, CanDeleteMessageParams.restrictiveForDmMessageExcept( + inRealmCanDeleteAnyMessageGroup: true)); + + doTest(true, CanDeleteMessageParams.restrictiveForDmMessageExcept( + senderConfig: CanDeleteMessageSenderConfig.self, + inRealmCanDeleteOwnMessageGroup: true, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited)); + + doTest(true, CanDeleteMessageParams.restrictiveForDmMessageExcept( + senderConfig: CanDeleteMessageSenderConfig.self, + inRealmCanDeleteOwnMessageGroup: true, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.insideLimit)); + }); + }); + + group('legacy behavior', () { + group('pre-407', () { + // The channel-level group permissions don't exist, + // so we act as though they were present and denied, + // notably by not throwing. + + test('denial is not forced just because one of the permissions is absent (the any-message one)', () async { + check(await evaluate( + CanDeleteMessageParams.pre407( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: true, + isChannelArchived: false, + )))..equals(await evaluate( + CanDeleteMessageParams.modern( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: true, + isChannelArchived: false, + inChannelCanDeleteAnyMessageGroup: false, + inChannelCanDeleteOwnMessageGroup: false))) + ..isTrue(); + }); + + test('exercise both existence checks', () async { + check(await evaluate( + CanDeleteMessageParams.pre407( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: false, + isChannelArchived: false, + )))..equals(await evaluate( + CanDeleteMessageParams.modern( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: false, + isChannelArchived: false, + inChannelCanDeleteAnyMessageGroup: false, + inChannelCanDeleteOwnMessageGroup: false))) + ..isFalse(); + }); + }); + + group('pre-291', () { + // The realm-level can-delete-own-message group permission + // doesn't exist, so we follow realmDeleteOwnMessagePolicy instead, + // and we don't error. + + test('allowed', () async { + check(await evaluate( + CanDeleteMessageParams.pre291( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + isChannelArchived: false, + realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + ))) + ..equals(await evaluate( + CanDeleteMessageParams.pre407( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: true, + isChannelArchived: false))) + ..isTrue(); + }); + + test('denied', () async { + check(await evaluate( + CanDeleteMessageParams.pre291( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + isChannelArchived: false, + realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.admins, + )))..equals(await evaluate( + CanDeleteMessageParams.pre407( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: false, + isChannelArchived: false))) + ..isFalse(); + }); + }); + + group('pre-281', () { + // The realm-level can-delete-any-message permission + // doesn't exist, so we act as though that's present and denied, + // notably by not throwing. + + test('denied', () async { + check(await evaluate( + CanDeleteMessageParams.pre281( + senderConfig: CanDeleteMessageSenderConfig.otherHuman, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + isChannelArchived: false, + realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + )))..equals(await evaluate( + CanDeleteMessageParams.pre291( + senderConfig: CanDeleteMessageSenderConfig.otherHuman, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + isChannelArchived: false, + realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone))) + ..isFalse(); + }); + }); + }); }); group('handleMessageEvent', () { @@ -143,7 +1196,7 @@ void main() { check(store.messages).isEmpty(); final newMessage = eg.streamMessage(); - await store.handleEvent(MessageEvent(id: 1, message: newMessage)); + await store.addMessage(newMessage); check(store.messages).deepEquals({ newMessage.id: newMessage, }); @@ -151,7 +1204,7 @@ void main() { test('from not-empty', () async { await prepare(); - final messages = [ + final messages = [ eg.streamMessage(), eg.streamMessage(), eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]), @@ -162,7 +1215,7 @@ void main() { }); final newMessage = eg.streamMessage(); - await store.handleEvent(MessageEvent(id: 1, message: newMessage)); + await store.addMessage(newMessage); check(store.messages).deepEquals({ for (final m in messages) m.id: m, newMessage.id: newMessage, @@ -176,7 +1229,7 @@ void main() { check(store.messages).deepEquals({1: message}); final newMessage = eg.streamMessage(id: 1, content: '

        bar

        '); - await store.handleEvent(MessageEvent(id: 1, message: newMessage)); + await store.addMessage(newMessage); check(store.messages).deepEquals({1: newMessage}); }); }); @@ -258,15 +1311,14 @@ void main() { ..content.not((it) => it.equals(updateEvent.renderedContent!)); }); - // TODO(server-5): Cut legacy case for rendering-only message update - Future checkRenderingOnly({required bool legacy}) async { + test('rendering-only update does not change timestamp', () async { final originalMessage = eg.streamMessage( lastEditTimestamp: 78492, content: "

        Hello, world

        "); final updateEvent = eg.updateMessageEditEvent(originalMessage, renderedContent: "

        Hello, world

        Some link preview
        ", editTimestamp: 99999, - renderingOnly: legacy ? null : true, + renderingOnly: true, userId: null, ); await prepare(); @@ -282,14 +1334,6 @@ void main() { // ... edit timestamp is not. ..lastEditTimestamp.equals(originalMessage.lastEditTimestamp) ..lastEditTimestamp.not((it) => it.equals(updateEvent.editTimestamp)); - } - - test('rendering-only update does not change timestamp', () async { - await checkRenderingOnly(legacy: false); - }); - - test('rendering-only update does not change timestamp (for old server versions)', () async { - await checkRenderingOnly(legacy: true); }); group('Handle message edit state update', () { @@ -873,7 +1917,7 @@ void main() { ]); await prepare(); - await store.handleEvent(MessageEvent(id: 0, message: message)); + await store.addMessage(message); } test('smoke', () async { @@ -944,9 +1988,189 @@ void main() { ), ]); await prepare(); - await store.handleEvent(MessageEvent(id: 0, message: message)); + await store.addMessage(message); check(store.messages[message.id]).isNotNull().poll.isNull(); }); }); }); } + +/// Params for testing the logic for +/// whether the self-user has permission to delete a message. +class CanDeleteMessageParams { + final CanDeleteMessageSenderConfig senderConfig; + final CanDeleteMessageTimeLimitConfig timeLimitConfig; + final bool? inRealmCanDeleteAnyMessageGroup; + final bool? inRealmCanDeleteOwnMessageGroup; + final bool? isChannelArchived; + final bool? inChannelCanDeleteAnyMessageGroup; + final bool? inChannelCanDeleteOwnMessageGroup; + final RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy; + + CanDeleteMessageParams._({ + required this.senderConfig, + required this.timeLimitConfig, + required this.inRealmCanDeleteAnyMessageGroup, + required this.inRealmCanDeleteOwnMessageGroup, + required this.isChannelArchived, + required this.inChannelCanDeleteAnyMessageGroup, + required this.inChannelCanDeleteOwnMessageGroup, + required this.realmDeleteOwnMessagePolicy, + }); + + CanDeleteMessageParams.modern({ + required this.senderConfig, + required this.timeLimitConfig, + required this.inRealmCanDeleteAnyMessageGroup, + required this.inRealmCanDeleteOwnMessageGroup, + required this.isChannelArchived, + required this.inChannelCanDeleteAnyMessageGroup, + required this.inChannelCanDeleteOwnMessageGroup, + }) : realmDeleteOwnMessagePolicy = null; + + factory CanDeleteMessageParams.restrictiveForChannelMessageExcept({ + CanDeleteMessageSenderConfig? senderConfig, + CanDeleteMessageTimeLimitConfig? timeLimitConfig, + bool? inRealmCanDeleteAnyMessageGroup, + bool? inRealmCanDeleteOwnMessageGroup, + bool? isChannelArchived, + bool? inChannelCanDeleteAnyMessageGroup, + bool? inChannelCanDeleteOwnMessageGroup, + }) => CanDeleteMessageParams.modern( + senderConfig: senderConfig ?? CanDeleteMessageSenderConfig.unknown, + timeLimitConfig: timeLimitConfig ?? CanDeleteMessageTimeLimitConfig.outsideLimit, + inRealmCanDeleteAnyMessageGroup: inRealmCanDeleteAnyMessageGroup ?? false, + inRealmCanDeleteOwnMessageGroup: inRealmCanDeleteOwnMessageGroup ?? false, + isChannelArchived: isChannelArchived ?? true, + inChannelCanDeleteAnyMessageGroup: inChannelCanDeleteAnyMessageGroup ?? false, + inChannelCanDeleteOwnMessageGroup: inChannelCanDeleteOwnMessageGroup ?? false, + ); + + factory CanDeleteMessageParams.permissiveForChannelMessageExcept({ + CanDeleteMessageSenderConfig? senderConfig, + CanDeleteMessageTimeLimitConfig? timeLimitConfig, + bool? inRealmCanDeleteAnyMessageGroup, + bool? inRealmCanDeleteOwnMessageGroup, + bool? isChannelArchived, + bool? inChannelCanDeleteAnyMessageGroup, + bool? inChannelCanDeleteOwnMessageGroup, + }) => CanDeleteMessageParams.modern( + senderConfig: senderConfig ?? CanDeleteMessageSenderConfig.self, + timeLimitConfig: timeLimitConfig ?? CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: inRealmCanDeleteAnyMessageGroup ?? true, + inRealmCanDeleteOwnMessageGroup: inRealmCanDeleteOwnMessageGroup ?? true, + isChannelArchived: isChannelArchived ?? false, + inChannelCanDeleteAnyMessageGroup: inChannelCanDeleteAnyMessageGroup ?? true, + inChannelCanDeleteOwnMessageGroup: inChannelCanDeleteOwnMessageGroup ?? true, + ); + + factory CanDeleteMessageParams.restrictiveForDmMessageExcept({ + CanDeleteMessageSenderConfig? senderConfig, + CanDeleteMessageTimeLimitConfig? timeLimitConfig, + bool? inRealmCanDeleteAnyMessageGroup, + bool? inRealmCanDeleteOwnMessageGroup, + }) => CanDeleteMessageParams.modern( + senderConfig: senderConfig ?? CanDeleteMessageSenderConfig.unknown, + timeLimitConfig: timeLimitConfig ?? CanDeleteMessageTimeLimitConfig.outsideLimit, + inRealmCanDeleteAnyMessageGroup: inRealmCanDeleteAnyMessageGroup ?? false, + inRealmCanDeleteOwnMessageGroup: inRealmCanDeleteOwnMessageGroup ?? false, + isChannelArchived: null, + inChannelCanDeleteAnyMessageGroup: null, + inChannelCanDeleteOwnMessageGroup: null, + ); + + factory CanDeleteMessageParams.permissiveForDmMessageExcept({ + CanDeleteMessageSenderConfig? senderConfig, + CanDeleteMessageTimeLimitConfig? timeLimitConfig, + bool? inRealmCanDeleteAnyMessageGroup, + bool? inRealmCanDeleteOwnMessageGroup, + }) => CanDeleteMessageParams.modern( + senderConfig: senderConfig ?? CanDeleteMessageSenderConfig.self, + timeLimitConfig: timeLimitConfig ?? CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: inRealmCanDeleteAnyMessageGroup ?? true, + inRealmCanDeleteOwnMessageGroup: inRealmCanDeleteOwnMessageGroup ?? true, + isChannelArchived: null, + inChannelCanDeleteAnyMessageGroup: null, + inChannelCanDeleteOwnMessageGroup: null, + ); + + // TODO(server-11) delete + factory CanDeleteMessageParams.pre407({ + required CanDeleteMessageSenderConfig senderConfig, + required CanDeleteMessageTimeLimitConfig timeLimitConfig, + required bool inRealmCanDeleteAnyMessageGroup, + required bool inRealmCanDeleteOwnMessageGroup, + required bool? isChannelArchived, + }) => CanDeleteMessageParams._( + senderConfig: senderConfig, + timeLimitConfig: timeLimitConfig, + inRealmCanDeleteAnyMessageGroup: inRealmCanDeleteAnyMessageGroup, + inRealmCanDeleteOwnMessageGroup: inRealmCanDeleteOwnMessageGroup, + isChannelArchived: isChannelArchived, + inChannelCanDeleteAnyMessageGroup: null, + inChannelCanDeleteOwnMessageGroup: null, + realmDeleteOwnMessagePolicy: null, + ); + + // TODO(server-10) delete + factory CanDeleteMessageParams.pre291({ + required CanDeleteMessageSenderConfig senderConfig, + required CanDeleteMessageTimeLimitConfig timeLimitConfig, + required bool inRealmCanDeleteAnyMessageGroup, + required bool? isChannelArchived, + required RealmDeleteOwnMessagePolicy realmDeleteOwnMessagePolicy, + }) => CanDeleteMessageParams._( + senderConfig: senderConfig, + timeLimitConfig: timeLimitConfig, + inRealmCanDeleteAnyMessageGroup: inRealmCanDeleteAnyMessageGroup, + inRealmCanDeleteOwnMessageGroup: null, + isChannelArchived: isChannelArchived, + inChannelCanDeleteAnyMessageGroup: null, + inChannelCanDeleteOwnMessageGroup: null, + realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy, + ); + + // TODO(server-10) delete + factory CanDeleteMessageParams.pre281({ + required CanDeleteMessageSenderConfig senderConfig, + required CanDeleteMessageTimeLimitConfig timeLimitConfig, + required bool? isChannelArchived, + required RealmDeleteOwnMessagePolicy realmDeleteOwnMessagePolicy, + }) => CanDeleteMessageParams._( + senderConfig: senderConfig, + timeLimitConfig: timeLimitConfig, + inRealmCanDeleteAnyMessageGroup: null, + inRealmCanDeleteOwnMessageGroup: null, + isChannelArchived: isChannelArchived, + inChannelCanDeleteAnyMessageGroup: null, + inChannelCanDeleteOwnMessageGroup: null, + realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy, + ); + + String describe() { + return [ + 'sender: ${senderConfig.name}', + 'time limit: ${timeLimitConfig.name}', + 'in realmCanDeleteAnyMessageGroup?: ${inRealmCanDeleteAnyMessageGroup ?? 'N/A'}', + 'in realmCanDeleteOwnMessageGroup?: ${inRealmCanDeleteOwnMessageGroup ?? 'N/A'}', + 'channel is archived?: ${isChannelArchived ?? 'N/A'}', + 'in channel.canDeleteAnyMessageGroup?: ${inChannelCanDeleteAnyMessageGroup ?? 'N/A'}', + 'in channel.canDeleteOwnMessageGroup?: ${inChannelCanDeleteOwnMessageGroup ?? 'N/A'}', + 'realmDeleteOwnMessagePolicy: ${realmDeleteOwnMessagePolicy ?? 'N/A'}', + ].join(', '); + } +} + +enum CanDeleteMessageSenderConfig { + unknown, + self, + otherHuman, + botOwnedBySelf, + botNotOwnedBySelf, +} + +enum CanDeleteMessageTimeLimitConfig { + notLimited, + insideLimit, + outsideLimit, +} diff --git a/test/model/narrow_checks.dart b/test/model/narrow_checks.dart index ce65de854d..df141654dd 100644 --- a/test/model/narrow_checks.dart +++ b/test/model/narrow_checks.dart @@ -16,4 +16,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 with_ => has((x) => x.with_, 'with_'); } diff --git a/test/model/narrow_test.dart b/test/model/narrow_test.dart index 9d676a531b..c62c56438c 100644 --- a/test/model/narrow_test.dart +++ b/test/model/narrow_test.dart @@ -22,6 +22,27 @@ void main() { }); }); + group('ChannelNarrow', () { + test('containsMessage', () { + final stream = eg.stream(); + final otherStream = eg.stream(); + final narrow = ChannelNarrow(stream.streamId); + check(narrow.containsMessage( + eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]))).isFalse(); + check(narrow.containsMessage( + eg.streamMessage(stream: otherStream, topic: 'topic'))).isFalse(); + check(narrow.containsMessage( + eg.streamMessage(stream: stream, topic: 'topic'))).isTrue(); + + check(narrow.containsMessage( + eg.dmOutboxMessage(from: eg.selfUser, to: [eg.otherUser]))).isFalse(); + check(narrow.containsMessage( + eg.streamOutboxMessage(stream: otherStream, topic: 'topic'))).isFalse(); + check(narrow.containsMessage( + eg.streamOutboxMessage(stream: stream, topic: 'topic'))).isTrue(); + }); + }); + group('TopicNarrow', () { test('ofMessage', () { final stream = eg.stream(); @@ -29,6 +50,29 @@ void main() { final actual = TopicNarrow.ofMessage(message); check(actual).equals(TopicNarrow(stream.streamId, message.topic)); }); + + test('containsMessage', () { + final stream = eg.stream(); + final otherStream = eg.stream(); + final narrow = eg.topicNarrow(stream.streamId, 'topic'); + check(narrow.containsMessage( + eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]))).isFalse(); + check(narrow.containsMessage( + eg.streamMessage(stream: otherStream, topic: 'topic'))).isFalse(); + check(narrow.containsMessage( + eg.streamMessage(stream: stream, topic: 'topic2'))).isFalse(); + check(narrow.containsMessage( + eg.streamMessage(stream: stream, topic: 'topic'))).isTrue(); + + check(narrow.containsMessage( + eg.dmOutboxMessage(from: eg.selfUser, to: [eg.otherUser]))).isFalse(); + check(narrow.containsMessage( + eg.streamOutboxMessage(stream: otherStream, topic: 'topic'))).isFalse(); + check(narrow.containsMessage( + eg.streamOutboxMessage(stream: stream, topic: 'topic2'))).isFalse(); + check(narrow.containsMessage( + eg.streamOutboxMessage(stream: stream, topic: 'topic'))).isTrue(); + }); }); group('DmNarrow', () { @@ -148,6 +192,22 @@ void main() { check(narrow123.containsMessage(dm(user2, [user1, user3]))).isTrue(); check(narrow123.containsMessage(dm(user3, [user1, user2]))).isTrue(); }); + + test('containsMessage with non-Message', () { + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final user3 = eg.user(userId: 3); + final narrow = DmNarrow(allRecipientIds: [1, 2], selfUserId: 2); + + check(narrow.containsMessage( + eg.streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + check(narrow.containsMessage( + eg.dmOutboxMessage(from: user2, to: []))).isFalse(); + check(narrow.containsMessage( + eg.dmOutboxMessage(from: user2, to: [user3]))).isFalse(); + check(narrow.containsMessage( + eg.dmOutboxMessage(from: user2, to: [user1]))).isTrue(); + }); }); group('MentionsNarrow', () { @@ -160,6 +220,11 @@ void main() { eg.streamMessage(flags:[MessageFlag.mentioned]))).isTrue(); check(narrow.containsMessage( eg.streamMessage(flags: [MessageFlag.wildcardMentioned]))).isTrue(); + + check(narrow.containsMessage( + eg.streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + check(narrow.containsMessage( + eg.dmOutboxMessage(from: eg.selfUser, to: []))).isFalse(); }); }); @@ -171,6 +236,11 @@ void main() { eg.streamMessage(flags: []))).isFalse(); check(narrow.containsMessage( eg.streamMessage(flags:[MessageFlag.starred]))).isTrue(); + + check(narrow.containsMessage( + eg.streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + check(narrow.containsMessage( + eg.dmOutboxMessage(from: eg.selfUser, to: []))).isFalse(); }); }); } diff --git a/test/model/realm_test.dart b/test/model/realm_test.dart new file mode 100644 index 0000000000..04ceb28855 --- /dev/null +++ b/test/model/realm_test.dart @@ -0,0 +1,128 @@ +import 'package:checks/checks.dart'; +import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/realm.dart'; + +import '../example_data.dart' as eg; + +void main() { + test('processTopicLikeServer', () { + final emptyTopicDisplayName = eg.defaultRealmEmptyTopicDisplayName; + + TopicName process(TopicName topic, int zulipFeatureLevel) { + final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + final store = eg.store(account: account, initialSnapshot: eg.initialSnapshot( + zulipFeatureLevel: zulipFeatureLevel, + realmEmptyTopicDisplayName: emptyTopicDisplayName)); + return store.processTopicLikeServer(topic); + } + + void doCheck(TopicName topic, TopicName expected, int zulipFeatureLevel) { + check(process(topic, zulipFeatureLevel)).equals(expected); + } + + check(() => process(eg.t(''), 333)).throws(); + doCheck(eg.t('(no topic)'), eg.t('(no topic)'), 333); + doCheck(eg.t(emptyTopicDisplayName), eg.t(emptyTopicDisplayName), 333); + doCheck(eg.t('other topic'), eg.t('other topic'), 333); + + doCheck(eg.t(''), eg.t(''), 334); + doCheck(eg.t('(no topic)'), eg.t('(no topic)'), 334); + doCheck(eg.t(emptyTopicDisplayName), eg.t(''), 334); + doCheck(eg.t('other topic'), eg.t('other topic'), 334); + + doCheck(eg.t('(no topic)'), eg.t(''), 370); + }); + + group('selfHasPermissionForGroupSetting', () { + // Most of the implementation of this is in [UserGroupStore.selfInGroupSetting], + // and is tested in more detail in user_group_test.dart . + + bool hasPermission(User selfUser, UserGroup group, String permissionName) { + final store = eg.store(selfUser: selfUser, + initialSnapshot: eg.initialSnapshot( + realmUsers: [selfUser], realmUserGroups: [group])); + return store.selfHasPermissionForGroupSetting( + GroupSettingValueNamed(group.id), + GroupSettingType.stream, permissionName); + } + + test('not in group -> no permission', () { + final selfUser = eg.user(); + final group = eg.userGroup(members: []); + check(hasPermission(selfUser, group, 'can_subscribe_group')) + .isFalse(); + }); + + test('in group -> has permission', () { + final selfUser = eg.user(); + final group = eg.userGroup(members: [selfUser.userId]); + check(hasPermission(selfUser, group, 'can_subscribe_group')) + .isTrue(); + }); + + test('guest -> no permission, despite group', () { + final selfUser = eg.user(role: UserRole.guest); + final group = eg.userGroup(members: [selfUser.userId]); + check(hasPermission(selfUser, group, 'can_subscribe_group')) + .isFalse(); + }); + + test('guest -> still has permission, if allowEveryoneGroup', () { + final selfUser = eg.user(role: UserRole.guest); + final group = eg.userGroup(members: [selfUser.userId]); + check(hasPermission(selfUser, group, 'can_send_message_group')) + .isTrue(); + }); + + test('guest not in group -> no permission, even if allowEveryoneGroup', () { + final selfUser = eg.user(role: UserRole.guest); + final group = eg.userGroup(members: []); + check(hasPermission(selfUser, group, 'can_send_message_group')) + .isFalse(); + }); + }); + + group('customProfileFields', () { + test('update clobbers old list', () async { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + customProfileFields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(1, CustomProfileFieldType.shortText), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([0, 1]); + + await store.handleEvent(CustomProfileFieldsEvent(id: 0, fields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(2, CustomProfileFieldType.shortText), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([0, 2]); + }); + + test('sorts by displayInProfile', () async { + // Sorts both the data from the initial snapshot… + final store = eg.store(initialSnapshot: eg.initialSnapshot( + customProfileFields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + eg.customProfileField(1, CustomProfileFieldType.shortText, + displayInProfileSummary: true), + eg.customProfileField(2, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([1, 0, 2]); + + // … and from an event. + await store.handleEvent(CustomProfileFieldsEvent(id: 0, fields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + eg.customProfileField(1, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + eg.customProfileField(2, CustomProfileFieldType.shortText, + displayInProfileSummary: true), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([2, 0, 1]); + }); + }); +} diff --git a/test/model/recent_dm_conversations_test.dart b/test/model/recent_dm_conversations_test.dart index f66b38c633..8905460e66 100644 --- a/test/model/recent_dm_conversations_test.dart +++ b/test/model/recent_dm_conversations_test.dart @@ -1,12 +1,12 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; -import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/recent_dm_conversations.dart'; import '../example_data.dart' as eg; import 'recent_dm_conversations_checks.dart'; +import 'store_checks.dart'; void main() { group('RecentDmConversationsView', () { @@ -19,18 +19,19 @@ void main() { } test('construct from initial data', () { - check(RecentDmConversationsView(selfUserId: eg.selfUser.userId, - initial: [])) + check(eg.store(initialSnapshot: eg.initialSnapshot( + recentPrivateConversations: [], + ))).recentDmConversationsView ..map.isEmpty() ..sorted.isEmpty() ..latestMessagesByRecipient.isEmpty(); - check(RecentDmConversationsView(selfUserId: eg.selfUser.userId, - initial: [ + check(eg.store(initialSnapshot: eg.initialSnapshot( + recentPrivateConversations: [ RecentDmConversation(userIds: [], maxMessageId: 200), RecentDmConversation(userIds: [1], maxMessageId: 100), RecentDmConversation(userIds: [2, 1], maxMessageId: 300), // userIds out of order - ])) + ]))).recentDmConversationsView ..map.deepEquals({ key([1, 2]): 300, key([]): 200, @@ -42,11 +43,11 @@ void main() { group('message event (new message)', () { RecentDmConversationsView setupView() { - return RecentDmConversationsView(selfUserId: eg.selfUser.userId, - initial: [ + return eg.store(initialSnapshot: eg.initialSnapshot( + recentPrivateConversations: [ RecentDmConversation(userIds: [1], maxMessageId: 200), RecentDmConversation(userIds: [1, 2], maxMessageId: 100), - ]); + ])).recentDmConversationsView; } test('(check base state)', () { @@ -66,7 +67,7 @@ void main() { final expected = setupView(); check(setupView() ..addListener(() { listenersNotified = true; }) - ..handleMessageEvent(MessageEvent(id: 1, message: eg.streamMessage())) + ..handleMessageEvent(eg.messageEvent(eg.streamMessage())) ) ..map.deepEquals(expected.map) ..sorted.deepEquals(expected.sorted) ..latestMessagesByRecipient.deepEquals(expected.latestMessagesByRecipient); @@ -78,7 +79,7 @@ void main() { final message = eg.dmMessage(id: 300, from: eg.selfUser, to: [eg.user(userId: 2)]); check(setupView() ..addListener(() { listenersNotified = true; }) - ..handleMessageEvent(MessageEvent(id: 1, message: message)) + ..handleMessageEvent(eg.messageEvent(message)) ) ..map.deepEquals({ key([2]): 300, key([1]): 200, @@ -94,7 +95,7 @@ void main() { final message = eg.dmMessage(id: 150, from: eg.selfUser, to: [eg.user(userId: 2)]); check(setupView() ..addListener(() { listenersNotified = true; }) - ..handleMessageEvent(MessageEvent(id: 1, message: message)) + ..handleMessageEvent(eg.messageEvent(message)) ) ..map.deepEquals({ key([1]): 200, key([2]): 150, @@ -111,7 +112,7 @@ void main() { to: [eg.user(userId: 1), eg.user(userId: 2)]); check(setupView() ..addListener(() { listenersNotified = true; }) - ..handleMessageEvent(MessageEvent(id: 1, message: message)) + ..handleMessageEvent(eg.messageEvent(message)) ) ..map.deepEquals({ key([1, 2]): 300, key([1]): 200, @@ -126,7 +127,7 @@ void main() { final message = eg.dmMessage(id: 300, from: eg.selfUser, to: [eg.user(userId: 1)]); check(setupView() ..addListener(() { listenersNotified = true; }) - ..handleMessageEvent(MessageEvent(id: 1, message: message)) + ..handleMessageEvent(eg.messageEvent(message)) ) ..map.deepEquals({ key([1]): 300, key([1, 2]): 100, @@ -143,7 +144,7 @@ void main() { final expected = setupView(); check(setupView() // ..addListener(() { listenersNotified = true; }) - ..handleMessageEvent(MessageEvent(id: 1, message: message)) + ..handleMessageEvent(eg.messageEvent(message)) ) ..map.deepEquals(expected.map) ..sorted.deepEquals(expected.sorted) ..latestMessagesByRecipient.deepEquals(expected.latestMessagesByRecipient); @@ -157,7 +158,7 @@ void main() { to: [eg.user(userId: 1), eg.user(userId: 3)]); check(setupView() ..addListener(() { listenersNotified = true; }) - ..handleMessageEvent(MessageEvent(id: 1, message: message)) + ..handleMessageEvent(eg.messageEvent(message)) ) ..map.deepEquals({ key([1, 3]): 300, key([1]): 200, @@ -174,7 +175,7 @@ void main() { to: [eg.user(userId: 1), eg.user(userId: 3)]); check(setupView() ..addListener(() { listenersNotified = true; }) - ..handleMessageEvent(MessageEvent(id: 1, message: message)) + ..handleMessageEvent(eg.messageEvent(message)) ) ..map.deepEquals({ key([1]): 200, key([1, 3]): 150, diff --git a/test/model/recent_senders_test.dart b/test/model/recent_senders_test.dart index 602362cbcc..990811d216 100644 --- a/test/model/recent_senders_test.dart +++ b/test/model/recent_senders_test.dart @@ -1,13 +1,16 @@ import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/channel.dart'; import 'package:zulip/model/recent_senders.dart'; import '../example_data.dart' as eg; /// [messages] should be sorted by [id] ascending. void checkMatchesMessages(RecentSenders model, List messages) { final Map>> messagesByUserInStream = {}; - final Map>>> messagesByUserInTopic = {}; + final Map>>> messagesByUserInTopic = {}; for (final message in messages) { if (message is! StreamMessage) { throw UnsupportedError('Message of type ${message.runtimeType} is not expected.'); @@ -17,7 +20,7 @@ void checkMatchesMessages(RecentSenders model, List messages) { ((messagesByUserInStream[streamId] ??= {}) [senderId] ??= {}).add(messageId); - (((messagesByUserInTopic[streamId] ??= {})[topic] ??= {}) + (((messagesByUserInTopic[streamId] ??= makeTopicKeyedMap())[topic] ??= {}) [senderId] ??= {}).add(messageId); } @@ -125,6 +128,16 @@ void main() { [eg.streamMessage(stream: streamA, topic: 'other', sender: userX)]); }); + test('case-insensitive topics', () { + checkHandleMessages( + [eg.streamMessage(stream: streamA, topic: 'thing', sender: userX)], + [eg.streamMessage(stream: streamA, topic: 'ThInG', sender: userX)]); + check(model.topicSenders).values.single.deepEquals( + {eg.t('thing'): + {userX.userId: (Subject it) => + it.isA().ids.length.equals(2)}}); + }); + test('add new stream', () { checkHandleMessages( [eg.streamMessage(stream: streamA, topic: 'thing', sender: userX)], @@ -161,6 +174,16 @@ void main() { Map.fromEntries(messages.map((msg) => MapEntry(msg.id, msg)))); checkMatchesMessages(model, [messages[1]]); + + // check case-insensitivity + model.handleDeleteMessageEvent(DeleteMessageEvent( + id: 0, + messageIds: [messages[1].id], + messageType: MessageType.stream, + streamId: stream.streamId, + topic: eg.t('oThEr'), + ), {messages[1].id: messages[1]}); + checkMatchesMessages(model, []); }); test('RecentSenders.latestMessageIdOfSenderInStream', () { @@ -200,6 +223,9 @@ void main() { check(model.latestMessageIdOfSenderInTopic(streamId: 1, topic: eg.t('a'), senderId: 10)).equals(300); + // case-insensitivity + check(model.latestMessageIdOfSenderInTopic(streamId: 1, + topic: eg.t('A'), senderId: 10)).equals(300); // No message of user 20 in topic "a". check(model.latestMessageIdOfSenderInTopic(streamId: 1, topic: eg.t('a'), senderId: 20)).equals(null); @@ -211,3 +237,7 @@ void main() { topic: eg.t('a'), senderId: 10)).equals(null); }); } + +extension MessageIdTrackerChecks on Subject { + Subject> get ids => has((x) => x.ids, 'ids'); +} diff --git a/test/model/saved_snippet_test.dart b/test/model/saved_snippet_test.dart new file mode 100644 index 0000000000..3c6756f977 --- /dev/null +++ b/test/model/saved_snippet_test.dart @@ -0,0 +1,44 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; + +import '../api/model/model_checks.dart'; +import '../example_data.dart' as eg; +import 'store_checks.dart'; + +void main() { + test('handleSavedSnippetsEvent', () async { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + savedSnippets: [eg.savedSnippet(id: 101)])); + check(store).savedSnippets.values.single.id.equals(101); + + await store.handleEvent(SavedSnippetsAddEvent(id: 1, + savedSnippet: eg.savedSnippet( + id: 102, + title: 'foo title', + content: 'foo content', + ))); + check(store).savedSnippets.values.deepEquals(>[ + (it) => it.isA().id.equals(101), + (it) => it.isA()..id.equals(102) + ..title.equals('foo title') + ..content.equals('foo content') + ]); + + await store.handleEvent(SavedSnippetsRemoveEvent(id: 1, savedSnippetId: 101)); + check(store).savedSnippets.values.single.id.equals(102); + + await store.handleEvent(SavedSnippetsUpdateEvent(id: 1, + savedSnippet: eg.savedSnippet( + id: 102, + title: 'bar title', + content: 'bar content', + dateCreated: store.savedSnippets.values.single.dateCreated, + ))); + check(store).savedSnippets.values.single + ..id.equals(102) + ..title.equals('bar title') + ..content.equals('bar content'); + }); +} diff --git a/test/model/schemas/drift_schema_v10.json b/test/model/schemas/drift_schema_v10.json new file mode 100644 index 0000000000..9a9a884263 --- /dev/null +++ b/test/model/schemas/drift_schema_v10.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}},{"name":"visit_first_unread","getter_name":"visitFirstUnread","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(VisitFirstUnreadSetting.values)","dart_type_name":"VisitFirstUnreadSetting"}},{"name":"mark_read_on_scroll","getter_name":"markReadOnScroll","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(MarkReadOnScrollSetting.values)","dart_type_name":"MarkReadOnScrollSetting"}},{"name":"legacy_upgrade_state","getter_name":"legacyUpgradeState","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LegacyUpgradeState.values)","dart_type_name":"LegacyUpgradeState"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"int_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":3,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/drift_schema_v11.json b/test/model/schemas/drift_schema_v11.json new file mode 100644 index 0000000000..9a9a884263 --- /dev/null +++ b/test/model/schemas/drift_schema_v11.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}},{"name":"visit_first_unread","getter_name":"visitFirstUnread","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(VisitFirstUnreadSetting.values)","dart_type_name":"VisitFirstUnreadSetting"}},{"name":"mark_read_on_scroll","getter_name":"markReadOnScroll","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(MarkReadOnScrollSetting.values)","dart_type_name":"MarkReadOnScrollSetting"}},{"name":"legacy_upgrade_state","getter_name":"legacyUpgradeState","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LegacyUpgradeState.values)","dart_type_name":"LegacyUpgradeState"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"int_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":3,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/drift_schema_v3.json b/test/model/schemas/drift_schema_v3.json new file mode 100644 index 0000000000..1c04b495f3 --- /dev/null +++ b/test/model/schemas/drift_schema_v3.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/drift_schema_v4.json b/test/model/schemas/drift_schema_v4.json new file mode 100644 index 0000000000..718222de66 --- /dev/null +++ b/test/model/schemas/drift_schema_v4.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/drift_schema_v5.json b/test/model/schemas/drift_schema_v5.json new file mode 100644 index 0000000000..718222de66 --- /dev/null +++ b/test/model/schemas/drift_schema_v5.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/drift_schema_v6.json b/test/model/schemas/drift_schema_v6.json new file mode 100644 index 0000000000..807c36c934 --- /dev/null +++ b/test/model/schemas/drift_schema_v6.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/drift_schema_v7.json b/test/model/schemas/drift_schema_v7.json new file mode 100644 index 0000000000..28ceaac619 --- /dev/null +++ b/test/model/schemas/drift_schema_v7.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}},{"name":"visit_first_unread","getter_name":"visitFirstUnread","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(VisitFirstUnreadSetting.values)","dart_type_name":"VisitFirstUnreadSetting"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/drift_schema_v8.json b/test/model/schemas/drift_schema_v8.json new file mode 100644 index 0000000000..62f8ca43d0 --- /dev/null +++ b/test/model/schemas/drift_schema_v8.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}},{"name":"visit_first_unread","getter_name":"visitFirstUnread","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(VisitFirstUnreadSetting.values)","dart_type_name":"VisitFirstUnreadSetting"}},{"name":"mark_read_on_scroll","getter_name":"markReadOnScroll","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(MarkReadOnScrollSetting.values)","dart_type_name":"MarkReadOnScrollSetting"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/drift_schema_v9.json b/test/model/schemas/drift_schema_v9.json new file mode 100644 index 0000000000..e425bc89c8 --- /dev/null +++ b/test/model/schemas/drift_schema_v9.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}},{"name":"visit_first_unread","getter_name":"visitFirstUnread","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(VisitFirstUnreadSetting.values)","dart_type_name":"VisitFirstUnreadSetting"}},{"name":"mark_read_on_scroll","getter_name":"markReadOnScroll","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(MarkReadOnScrollSetting.values)","dart_type_name":"MarkReadOnScrollSetting"}},{"name":"legacy_upgrade_state","getter_name":"legacyUpgradeState","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LegacyUpgradeState.values)","dart_type_name":"LegacyUpgradeState"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/schema.dart b/test/model/schemas/schema.dart index b2b7404b5a..1d78a44317 100644 --- a/test/model/schemas/schema.dart +++ b/test/model/schemas/schema.dart @@ -5,6 +5,15 @@ import 'package:drift/drift.dart'; import 'package:drift/internal/migrations.dart'; import 'schema_v1.dart' as v1; import 'schema_v2.dart' as v2; +import 'schema_v3.dart' as v3; +import 'schema_v4.dart' as v4; +import 'schema_v5.dart' as v5; +import 'schema_v6.dart' as v6; +import 'schema_v7.dart' as v7; +import 'schema_v8.dart' as v8; +import 'schema_v9.dart' as v9; +import 'schema_v10.dart' as v10; +import 'schema_v11.dart' as v11; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -14,10 +23,28 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v1.DatabaseAtV1(db); case 2: return v2.DatabaseAtV2(db); + case 3: + return v3.DatabaseAtV3(db); + case 4: + return v4.DatabaseAtV4(db); + case 5: + return v5.DatabaseAtV5(db); + case 6: + return v6.DatabaseAtV6(db); + case 7: + return v7.DatabaseAtV7(db); + case 8: + return v8.DatabaseAtV8(db); + case 9: + return v9.DatabaseAtV9(db); + case 10: + return v10.DatabaseAtV10(db); + case 11: + return v11.DatabaseAtV11(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2]; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; } diff --git a/test/model/schemas/schema_v1.dart b/test/model/schemas/schema_v1.dart index 497a491bc6..9629b868f7 100644 --- a/test/model/schemas/schema_v1.dart +++ b/test/model/schemas/schema_v1.dart @@ -9,44 +9,76 @@ class Accounts extends Table with TableInfo { final String? _alias; Accounts(this.attachedDatabase, [this._alias]); late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); late final GeneratedColumn realmUrl = GeneratedColumn( - 'realm_url', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); late final GeneratedColumn userId = GeneratedColumn( - 'user_id', aliasedName, false, - type: DriftSqlType.int, requiredDuringInsert: true); + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); late final GeneratedColumn email = GeneratedColumn( - 'email', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); late final GeneratedColumn apiKey = GeneratedColumn( - 'api_key', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); late final GeneratedColumn zulipVersion = GeneratedColumn( - 'zulip_version', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); late final GeneratedColumn zulipMergeBase = GeneratedColumn( - 'zulip_merge_base', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( - 'zulip_feature_level', aliasedName, false, - type: DriftSqlType.int, requiredDuringInsert: true); + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); @override List get $columns => [ - id, - realmUrl, - userId, - email, - apiKey, - zulipVersion, - zulipMergeBase, - zulipFeatureLevel - ]; + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -56,29 +88,45 @@ class Accounts extends Table with TableInfo { Set get $primaryKey => {id}; @override List> get uniqueKeys => [ - {realmUrl, userId}, - {realmUrl, email}, - ]; + {realmUrl, userId}, + {realmUrl, email}, + ]; @override AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - realmUrl: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}realm_url'])!, - userId: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}user_id'])!, - email: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}email'])!, - apiKey: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}api_key'])!, - zulipVersion: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}zulip_version'])!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}zulip_merge_base']), + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), zulipFeatureLevel: attachedDatabase.typeMapping.read( - DriftSqlType.int, data['${effectivePrefix}zulip_feature_level'])!, + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ); } @@ -97,15 +145,16 @@ class AccountsData extends DataClass implements Insertable { final String zulipVersion; final String? zulipMergeBase; final int zulipFeatureLevel; - const AccountsData( - {required this.id, - required this.realmUrl, - required this.userId, - required this.email, - required this.apiKey, - required this.zulipVersion, - this.zulipMergeBase, - required this.zulipFeatureLevel}); + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -137,8 +186,10 @@ class AccountsData extends DataClass implements Insertable { ); } - factory AccountsData.fromJson(Map json, - {ValueSerializer? serializer}) { + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { serializer ??= driftRuntimeOptions.defaultSerializer; return AccountsData( id: serializer.fromJson(json['id']), @@ -166,26 +217,27 @@ class AccountsData extends DataClass implements Insertable { }; } - AccountsData copyWith( - {int? id, - String? realmUrl, - int? userId, - String? email, - String? apiKey, - String? zulipVersion, - Value zulipMergeBase = const Value.absent(), - int? zulipFeatureLevel}) => - AccountsData( - id: id ?? this.id, - realmUrl: realmUrl ?? this.realmUrl, - userId: userId ?? this.userId, - email: email ?? this.email, - apiKey: apiKey ?? this.apiKey, - zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, - zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ); + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ); AccountsData copyWithCompanion(AccountsCompanion data) { return AccountsData( id: data.id.present ? data.id.value : this.id, @@ -221,8 +273,16 @@ class AccountsData extends DataClass implements Insertable { } @override - int get hashCode => Object.hash(id, realmUrl, userId, email, apiKey, - zulipVersion, zulipMergeBase, zulipFeatureLevel); + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -265,12 +325,12 @@ class AccountsCompanion extends UpdateCompanion { required String zulipVersion, this.zulipMergeBase = const Value.absent(), required int zulipFeatureLevel, - }) : realmUrl = Value(realmUrl), - userId = Value(userId), - email = Value(email), - apiKey = Value(apiKey), - zulipVersion = Value(zulipVersion), - zulipFeatureLevel = Value(zulipFeatureLevel); + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); static Insertable custom({ Expression? id, Expression? realmUrl, @@ -293,15 +353,16 @@ class AccountsCompanion extends UpdateCompanion { }); } - AccountsCompanion copyWith( - {Value? id, - Value? realmUrl, - Value? userId, - Value? email, - Value? apiKey, - Value? zulipVersion, - Value? zulipMergeBase, - Value? zulipFeatureLevel}) { + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + }) { return AccountsCompanion( id: id ?? this.id, realmUrl: realmUrl ?? this.realmUrl, diff --git a/test/model/schemas/schema_v10.dart b/test/model/schemas/schema_v10.dart new file mode 100644 index 0000000000..4de2d2b356 --- /dev/null +++ b/test/model/schemas/schema_v10.dart @@ -0,0 +1,1199 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visitFirstUnread = GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn markReadOnScroll = GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn legacyUpgradeState = + GeneratedColumn( + 'legacy_upgrade_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + visitFirstUnread: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + markReadOnScroll: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mark_read_on_scroll'], + ), + legacyUpgradeState: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}legacy_upgrade_state'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + final String? visitFirstUnread; + final String? markReadOnScroll; + final String? legacyUpgradeState; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + this.markReadOnScroll, + this.legacyUpgradeState, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable(visitFirstUnread); + } + if (!nullToAbsent || markReadOnScroll != null) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll); + } + if (!nullToAbsent || legacyUpgradeState != null) { + map['legacy_upgrade_state'] = Variable(legacyUpgradeState); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + markReadOnScroll: markReadOnScroll == null && nullToAbsent + ? const Value.absent() + : Value(markReadOnScroll), + legacyUpgradeState: legacyUpgradeState == null && nullToAbsent + ? const Value.absent() + : Value(legacyUpgradeState), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + visitFirstUnread: serializer.fromJson(json['visitFirstUnread']), + markReadOnScroll: serializer.fromJson(json['markReadOnScroll']), + legacyUpgradeState: serializer.fromJson( + json['legacyUpgradeState'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + 'visitFirstUnread': serializer.toJson(visitFirstUnread), + 'markReadOnScroll': serializer.toJson(markReadOnScroll), + 'legacyUpgradeState': serializer.toJson(legacyUpgradeState), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = const Value.absent(), + Value legacyUpgradeState = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: markReadOnScroll.present + ? markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState.present + ? legacyUpgradeState.value + : this.legacyUpgradeState, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: data.markReadOnScroll.present + ? data.markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: data.legacyUpgradeState.present + ? data.legacyUpgradeState.value + : this.legacyUpgradeState, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread && + other.markReadOnScroll == this.markReadOnScroll && + other.legacyUpgradeState == this.legacyUpgradeState); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value visitFirstUnread; + final Value markReadOnScroll; + final Value legacyUpgradeState; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? visitFirstUnread, + Expression? markReadOnScroll, + Expression? legacyUpgradeState, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, + if (legacyUpgradeState != null) + 'legacy_upgrade_state': legacyUpgradeState, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? visitFirstUnread, + Value? markReadOnScroll, + Value? legacyUpgradeState, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState ?? this.legacyUpgradeState, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable(visitFirstUnread.value); + } + if (markReadOnScroll.present) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll.value); + } + if (legacyUpgradeState.present) { + map['legacy_upgrade_state'] = Variable(legacyUpgradeState.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BoolGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BoolGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + BoolGlobalSettings createAlias(String alias) { + return BoolGlobalSettings(attachedDatabase, alias); + } +} + +class BoolGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final bool value; + const BoolGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingsData copyWith({String? name, bool? value}) => + BoolGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class IntGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + IntGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'int_global_settings'; + @override + Set get $primaryKey => {name}; + @override + IntGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return IntGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + IntGlobalSettings createAlias(String alias) { + return IntGlobalSettings(attachedDatabase, alias); + } +} + +class IntGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final int value; + const IntGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + IntGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return IntGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory IntGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return IntGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + IntGlobalSettingsData copyWith({String? name, int? value}) => + IntGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + IntGlobalSettingsData copyWithCompanion(IntGlobalSettingsCompanion data) { + return IntGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('IntGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is IntGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class IntGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const IntGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + IntGlobalSettingsCompanion.insert({ + required String name, + required int value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + IntGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return IntGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('IntGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV10 extends GeneratedDatabase { + DatabaseAtV10(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this); + late final IntGlobalSettings intGlobalSettings = IntGlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + intGlobalSettings, + accounts, + ]; + @override + int get schemaVersion => 10; +} diff --git a/test/model/schemas/schema_v11.dart b/test/model/schemas/schema_v11.dart new file mode 100644 index 0000000000..09a1f10fb7 --- /dev/null +++ b/test/model/schemas/schema_v11.dart @@ -0,0 +1,1199 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visitFirstUnread = GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn markReadOnScroll = GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn legacyUpgradeState = + GeneratedColumn( + 'legacy_upgrade_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + visitFirstUnread: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + markReadOnScroll: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mark_read_on_scroll'], + ), + legacyUpgradeState: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}legacy_upgrade_state'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + final String? visitFirstUnread; + final String? markReadOnScroll; + final String? legacyUpgradeState; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + this.markReadOnScroll, + this.legacyUpgradeState, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable(visitFirstUnread); + } + if (!nullToAbsent || markReadOnScroll != null) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll); + } + if (!nullToAbsent || legacyUpgradeState != null) { + map['legacy_upgrade_state'] = Variable(legacyUpgradeState); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + markReadOnScroll: markReadOnScroll == null && nullToAbsent + ? const Value.absent() + : Value(markReadOnScroll), + legacyUpgradeState: legacyUpgradeState == null && nullToAbsent + ? const Value.absent() + : Value(legacyUpgradeState), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + visitFirstUnread: serializer.fromJson(json['visitFirstUnread']), + markReadOnScroll: serializer.fromJson(json['markReadOnScroll']), + legacyUpgradeState: serializer.fromJson( + json['legacyUpgradeState'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + 'visitFirstUnread': serializer.toJson(visitFirstUnread), + 'markReadOnScroll': serializer.toJson(markReadOnScroll), + 'legacyUpgradeState': serializer.toJson(legacyUpgradeState), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = const Value.absent(), + Value legacyUpgradeState = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: markReadOnScroll.present + ? markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState.present + ? legacyUpgradeState.value + : this.legacyUpgradeState, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: data.markReadOnScroll.present + ? data.markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: data.legacyUpgradeState.present + ? data.legacyUpgradeState.value + : this.legacyUpgradeState, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread && + other.markReadOnScroll == this.markReadOnScroll && + other.legacyUpgradeState == this.legacyUpgradeState); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value visitFirstUnread; + final Value markReadOnScroll; + final Value legacyUpgradeState; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? visitFirstUnread, + Expression? markReadOnScroll, + Expression? legacyUpgradeState, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, + if (legacyUpgradeState != null) + 'legacy_upgrade_state': legacyUpgradeState, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? visitFirstUnread, + Value? markReadOnScroll, + Value? legacyUpgradeState, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState ?? this.legacyUpgradeState, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable(visitFirstUnread.value); + } + if (markReadOnScroll.present) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll.value); + } + if (legacyUpgradeState.present) { + map['legacy_upgrade_state'] = Variable(legacyUpgradeState.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BoolGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BoolGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + BoolGlobalSettings createAlias(String alias) { + return BoolGlobalSettings(attachedDatabase, alias); + } +} + +class BoolGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final bool value; + const BoolGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingsData copyWith({String? name, bool? value}) => + BoolGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class IntGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + IntGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'int_global_settings'; + @override + Set get $primaryKey => {name}; + @override + IntGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return IntGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + IntGlobalSettings createAlias(String alias) { + return IntGlobalSettings(attachedDatabase, alias); + } +} + +class IntGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final int value; + const IntGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + IntGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return IntGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory IntGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return IntGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + IntGlobalSettingsData copyWith({String? name, int? value}) => + IntGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + IntGlobalSettingsData copyWithCompanion(IntGlobalSettingsCompanion data) { + return IntGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('IntGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is IntGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class IntGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const IntGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + IntGlobalSettingsCompanion.insert({ + required String name, + required int value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + IntGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return IntGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('IntGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV11 extends GeneratedDatabase { + DatabaseAtV11(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this); + late final IntGlobalSettings intGlobalSettings = IntGlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + intGlobalSettings, + accounts, + ]; + @override + int get schemaVersion => 11; +} diff --git a/test/model/schemas/schema_v2.dart b/test/model/schemas/schema_v2.dart index 863ee8d36d..61c69dd90c 100644 --- a/test/model/schemas/schema_v2.dart +++ b/test/model/schemas/schema_v2.dart @@ -9,48 +9,84 @@ class Accounts extends Table with TableInfo { final String? _alias; Accounts(this.attachedDatabase, [this._alias]); late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); late final GeneratedColumn realmUrl = GeneratedColumn( - 'realm_url', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); late final GeneratedColumn userId = GeneratedColumn( - 'user_id', aliasedName, false, - type: DriftSqlType.int, requiredDuringInsert: true); + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); late final GeneratedColumn email = GeneratedColumn( - 'email', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); late final GeneratedColumn apiKey = GeneratedColumn( - 'api_key', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); late final GeneratedColumn zulipVersion = GeneratedColumn( - 'zulip_version', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); late final GeneratedColumn zulipMergeBase = GeneratedColumn( - 'zulip_merge_base', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( - 'zulip_feature_level', aliasedName, false, - type: DriftSqlType.int, requiredDuringInsert: true); + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); late final GeneratedColumn ackedPushToken = GeneratedColumn( - 'acked_push_token', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ - id, - realmUrl, - userId, - email, - apiKey, - zulipVersion, - zulipMergeBase, - zulipFeatureLevel, - ackedPushToken - ]; + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -60,31 +96,49 @@ class Accounts extends Table with TableInfo { Set get $primaryKey => {id}; @override List> get uniqueKeys => [ - {realmUrl, userId}, - {realmUrl, email}, - ]; + {realmUrl, userId}, + {realmUrl, email}, + ]; @override AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - realmUrl: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}realm_url'])!, - userId: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}user_id'])!, - email: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}email'])!, - apiKey: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}api_key'])!, - zulipVersion: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}zulip_version'])!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}zulip_merge_base']), + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), zulipFeatureLevel: attachedDatabase.typeMapping.read( - DriftSqlType.int, data['${effectivePrefix}zulip_feature_level'])!, + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}acked_push_token']), + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), ); } @@ -104,16 +158,17 @@ class AccountsData extends DataClass implements Insertable { final String? zulipMergeBase; final int zulipFeatureLevel; final String? ackedPushToken; - const AccountsData( - {required this.id, - required this.realmUrl, - required this.userId, - required this.email, - required this.apiKey, - required this.zulipVersion, - this.zulipMergeBase, - required this.zulipFeatureLevel, - this.ackedPushToken}); + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -151,8 +206,10 @@ class AccountsData extends DataClass implements Insertable { ); } - factory AccountsData.fromJson(Map json, - {ValueSerializer? serializer}) { + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { serializer ??= driftRuntimeOptions.defaultSerializer; return AccountsData( id: serializer.fromJson(json['id']), @@ -182,29 +239,31 @@ class AccountsData extends DataClass implements Insertable { }; } - AccountsData copyWith( - {int? id, - String? realmUrl, - int? userId, - String? email, - String? apiKey, - String? zulipVersion, - Value zulipMergeBase = const Value.absent(), - int? zulipFeatureLevel, - Value ackedPushToken = const Value.absent()}) => - AccountsData( - id: id ?? this.id, - realmUrl: realmUrl ?? this.realmUrl, - userId: userId ?? this.userId, - email: email ?? this.email, - apiKey: apiKey ?? this.apiKey, - zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, - zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, - ); + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); AccountsData copyWithCompanion(AccountsCompanion data) { return AccountsData( id: data.id.present ? data.id.value : this.id, @@ -244,8 +303,17 @@ class AccountsData extends DataClass implements Insertable { } @override - int get hashCode => Object.hash(id, realmUrl, userId, email, apiKey, - zulipVersion, zulipMergeBase, zulipFeatureLevel, ackedPushToken); + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -292,12 +360,12 @@ class AccountsCompanion extends UpdateCompanion { this.zulipMergeBase = const Value.absent(), required int zulipFeatureLevel, this.ackedPushToken = const Value.absent(), - }) : realmUrl = Value(realmUrl), - userId = Value(userId), - email = Value(email), - apiKey = Value(apiKey), - zulipVersion = Value(zulipVersion), - zulipFeatureLevel = Value(zulipFeatureLevel); + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); static Insertable custom({ Expression? id, Expression? realmUrl, @@ -322,16 +390,17 @@ class AccountsCompanion extends UpdateCompanion { }); } - AccountsCompanion copyWith( - {Value? id, - Value? realmUrl, - Value? userId, - Value? email, - Value? apiKey, - Value? zulipVersion, - Value? zulipMergeBase, - Value? zulipFeatureLevel, - Value? ackedPushToken}) { + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { return AccountsCompanion( id: id ?? this.id, realmUrl: realmUrl ?? this.realmUrl, diff --git a/test/model/schemas/schema_v3.dart b/test/model/schemas/schema_v3.dart new file mode 100644 index 0000000000..862ea42c18 --- /dev/null +++ b/test/model/schemas/schema_v3.dart @@ -0,0 +1,640 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [themeSetting]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + const GlobalSettingsData({this.themeSetting}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting') + ..write(')')) + .toString(); + } + + @override + int get hashCode => themeSetting.hashCode; + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && other.themeSetting == this.themeSetting); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV3 extends GeneratedDatabase { + DatabaseAtV3(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + accounts, + ]; + @override + int get schemaVersion => 3; +} diff --git a/test/model/schemas/schema_v4.dart b/test/model/schemas/schema_v4.dart new file mode 100644 index 0000000000..631d37ab82 --- /dev/null +++ b/test/model/schemas/schema_v4.dart @@ -0,0 +1,684 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [themeSetting, browserPreference]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + const GlobalSettingsData({this.themeSetting, this.browserPreference}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(themeSetting, browserPreference); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV4 extends GeneratedDatabase { + DatabaseAtV4(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + accounts, + ]; + @override + int get schemaVersion => 4; +} diff --git a/test/model/schemas/schema_v5.dart b/test/model/schemas/schema_v5.dart new file mode 100644 index 0000000000..1d3bc4d895 --- /dev/null +++ b/test/model/schemas/schema_v5.dart @@ -0,0 +1,684 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [themeSetting, browserPreference]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + const GlobalSettingsData({this.themeSetting, this.browserPreference}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(themeSetting, browserPreference); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV5 extends GeneratedDatabase { + DatabaseAtV5(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + accounts, + ]; + @override + int get schemaVersion => 5; +} diff --git a/test/model/schemas/schema_v6.dart b/test/model/schemas/schema_v6.dart new file mode 100644 index 0000000000..aac90f3ae3 --- /dev/null +++ b/test/model/schemas/schema_v6.dart @@ -0,0 +1,872 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [themeSetting, browserPreference]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + const GlobalSettingsData({this.themeSetting, this.browserPreference}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(themeSetting, browserPreference); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BoolGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BoolGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + BoolGlobalSettings createAlias(String alias) { + return BoolGlobalSettings(attachedDatabase, alias); + } +} + +class BoolGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final bool value; + const BoolGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingsData copyWith({String? name, bool? value}) => + BoolGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV6 extends GeneratedDatabase { + DatabaseAtV6(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + @override + int get schemaVersion => 6; +} diff --git a/test/model/schemas/schema_v7.dart b/test/model/schemas/schema_v7.dart new file mode 100644 index 0000000000..b74f391386 --- /dev/null +++ b/test/model/schemas/schema_v7.dart @@ -0,0 +1,921 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visitFirstUnread = GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + visitFirstUnread: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + final String? visitFirstUnread; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable(visitFirstUnread); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + visitFirstUnread: serializer.fromJson(json['visitFirstUnread']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + 'visitFirstUnread': serializer.toJson(visitFirstUnread), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(themeSetting, browserPreference, visitFirstUnread); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value visitFirstUnread; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? visitFirstUnread, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? visitFirstUnread, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable(visitFirstUnread.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BoolGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BoolGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + BoolGlobalSettings createAlias(String alias) { + return BoolGlobalSettings(attachedDatabase, alias); + } +} + +class BoolGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final bool value; + const BoolGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingsData copyWith({String? name, bool? value}) => + BoolGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV7 extends GeneratedDatabase { + DatabaseAtV7(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + @override + int get schemaVersion => 7; +} diff --git a/test/model/schemas/schema_v8.dart b/test/model/schemas/schema_v8.dart new file mode 100644 index 0000000000..fb17863b15 --- /dev/null +++ b/test/model/schemas/schema_v8.dart @@ -0,0 +1,967 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visitFirstUnread = GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn markReadOnScroll = GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + visitFirstUnread: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + markReadOnScroll: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mark_read_on_scroll'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + final String? visitFirstUnread; + final String? markReadOnScroll; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + this.markReadOnScroll, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable(visitFirstUnread); + } + if (!nullToAbsent || markReadOnScroll != null) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + markReadOnScroll: markReadOnScroll == null && nullToAbsent + ? const Value.absent() + : Value(markReadOnScroll), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + visitFirstUnread: serializer.fromJson(json['visitFirstUnread']), + markReadOnScroll: serializer.fromJson(json['markReadOnScroll']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + 'visitFirstUnread': serializer.toJson(visitFirstUnread), + 'markReadOnScroll': serializer.toJson(markReadOnScroll), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: markReadOnScroll.present + ? markReadOnScroll.value + : this.markReadOnScroll, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: data.markReadOnScroll.present + ? data.markReadOnScroll.value + : this.markReadOnScroll, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread && + other.markReadOnScroll == this.markReadOnScroll); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value visitFirstUnread; + final Value markReadOnScroll; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? visitFirstUnread, + Expression? markReadOnScroll, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? visitFirstUnread, + Value? markReadOnScroll, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable(visitFirstUnread.value); + } + if (markReadOnScroll.present) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BoolGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BoolGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + BoolGlobalSettings createAlias(String alias) { + return BoolGlobalSettings(attachedDatabase, alias); + } +} + +class BoolGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final bool value; + const BoolGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingsData copyWith({String? name, bool? value}) => + BoolGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV8 extends GeneratedDatabase { + DatabaseAtV8(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + @override + int get schemaVersion => 8; +} diff --git a/test/model/schemas/schema_v9.dart b/test/model/schemas/schema_v9.dart new file mode 100644 index 0000000000..d036e3a26f --- /dev/null +++ b/test/model/schemas/schema_v9.dart @@ -0,0 +1,1014 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visitFirstUnread = GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn markReadOnScroll = GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn legacyUpgradeState = + GeneratedColumn( + 'legacy_upgrade_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + visitFirstUnread: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + markReadOnScroll: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mark_read_on_scroll'], + ), + legacyUpgradeState: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}legacy_upgrade_state'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + final String? visitFirstUnread; + final String? markReadOnScroll; + final String? legacyUpgradeState; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + this.markReadOnScroll, + this.legacyUpgradeState, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable(visitFirstUnread); + } + if (!nullToAbsent || markReadOnScroll != null) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll); + } + if (!nullToAbsent || legacyUpgradeState != null) { + map['legacy_upgrade_state'] = Variable(legacyUpgradeState); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + markReadOnScroll: markReadOnScroll == null && nullToAbsent + ? const Value.absent() + : Value(markReadOnScroll), + legacyUpgradeState: legacyUpgradeState == null && nullToAbsent + ? const Value.absent() + : Value(legacyUpgradeState), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + visitFirstUnread: serializer.fromJson(json['visitFirstUnread']), + markReadOnScroll: serializer.fromJson(json['markReadOnScroll']), + legacyUpgradeState: serializer.fromJson( + json['legacyUpgradeState'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + 'visitFirstUnread': serializer.toJson(visitFirstUnread), + 'markReadOnScroll': serializer.toJson(markReadOnScroll), + 'legacyUpgradeState': serializer.toJson(legacyUpgradeState), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = const Value.absent(), + Value legacyUpgradeState = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: markReadOnScroll.present + ? markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState.present + ? legacyUpgradeState.value + : this.legacyUpgradeState, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: data.markReadOnScroll.present + ? data.markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: data.legacyUpgradeState.present + ? data.legacyUpgradeState.value + : this.legacyUpgradeState, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread && + other.markReadOnScroll == this.markReadOnScroll && + other.legacyUpgradeState == this.legacyUpgradeState); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value visitFirstUnread; + final Value markReadOnScroll; + final Value legacyUpgradeState; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? visitFirstUnread, + Expression? markReadOnScroll, + Expression? legacyUpgradeState, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, + if (legacyUpgradeState != null) + 'legacy_upgrade_state': legacyUpgradeState, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? visitFirstUnread, + Value? markReadOnScroll, + Value? legacyUpgradeState, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState ?? this.legacyUpgradeState, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable(visitFirstUnread.value); + } + if (markReadOnScroll.present) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll.value); + } + if (legacyUpgradeState.present) { + map['legacy_upgrade_state'] = Variable(legacyUpgradeState.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BoolGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BoolGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + BoolGlobalSettings createAlias(String alias) { + return BoolGlobalSettings(attachedDatabase, alias); + } +} + +class BoolGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final bool value; + const BoolGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingsData copyWith({String? name, bool? value}) => + BoolGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV9 extends GeneratedDatabase { + DatabaseAtV9(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + @override + int get schemaVersion => 9; +} diff --git a/test/model/settings_test.dart b/test/model/settings_test.dart new file mode 100644 index 0000000000..3e5142914b --- /dev/null +++ b/test/model/settings_test.dart @@ -0,0 +1,237 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/binding.dart'; +import 'package:zulip/model/database.dart'; +import 'package:zulip/model/settings.dart'; + +import '../example_data.dart' as eg; +import 'store_checks.dart'; +import 'store_test.dart'; + +void main() { + final httpLink = Uri.parse('http://chat.zulip.org'); + final nonHttpLink = Uri.parse('mailto:chat@zulip.org'); + + group('getUrlLaunchMode', () { + // See also test/widgets/actions_test.dart, where we test that the setting + // is actually used when we open links, with PlatformActions.launchUrl. + + testAndroidIos('globalSettings.browserPreference is null; use our per-platform defaults for HTTP links', () { + final globalSettings = eg.globalStore(globalSettings: GlobalSettingsData( + browserPreference: null)).settings; + check(globalSettings).getUrlLaunchMode(httpLink).equals( + defaultTargetPlatform == TargetPlatform.android + ? UrlLaunchMode.inAppBrowserView : UrlLaunchMode.externalApplication); + }); + + testAndroidIos('globalSettings.browserPreference is null; use our per-platform defaults for non-HTTP links', () { + final globalSettings = eg.globalStore(globalSettings: GlobalSettingsData( + browserPreference: null)).settings; + check(globalSettings).getUrlLaunchMode(nonHttpLink).equals( + defaultTargetPlatform == TargetPlatform.android + ? UrlLaunchMode.platformDefault : UrlLaunchMode.externalApplication); + }); + + testAndroidIos('globalSettings.browserPreference is inApp; follow the user preference for http links', () { + final globalSettings = eg.globalStore(globalSettings: GlobalSettingsData( + browserPreference: BrowserPreference.inApp)).settings; + check(globalSettings).getUrlLaunchMode(httpLink).equals( + UrlLaunchMode.inAppBrowserView); + }); + + testAndroidIos('globalSettings.browserPreference is inApp; use platform default for non-http links', () { + final globalSettings = eg.globalStore(globalSettings: GlobalSettingsData( + browserPreference: BrowserPreference.inApp)).settings; + check(globalSettings).getUrlLaunchMode(nonHttpLink).equals( + UrlLaunchMode.platformDefault); + }); + + testAndroidIos('globalSettings.browserPreference is external; follow the user preference', () { + final globalSettings = eg.globalStore(globalSettings: GlobalSettingsData( + browserPreference: BrowserPreference.external)).settings; + check(globalSettings).getUrlLaunchMode(httpLink).equals( + UrlLaunchMode.externalApplication); + }); + }); + + group('setThemeSetting', () { + test('smoke', () async { + final globalSettings = eg.globalStore().settings; + check(globalSettings).themeSetting.equals(null); + + await globalSettings.setThemeSetting(ThemeSetting.dark); + check(globalSettings).themeSetting.equals(ThemeSetting.dark); + }); + + test('should notify listeners', () async { + int notifyCount = 0; + final globalSettings = eg.globalStore().settings; + globalSettings.addListener(() => notifyCount++); + check(notifyCount).equals(0); + + await globalSettings.setThemeSetting(ThemeSetting.light); + check(notifyCount).equals(1); + }); + + // TODO integration tests with sqlite + }); + + // TODO(#1571) test visitFirstUnread applies default + // TODO(#1571) test shouldVisitFirstUnread + + // TODO(#1583) test markReadOnScroll applies default + // TODO(#1583) test markReadOnScrollForNarrow + + group('getBool/setBool', () { + test('get from default', () { + final globalSettings = eg.globalStore(boolGlobalSettings: {}).settings; + check(globalSettings).getBool(BoolGlobalSetting.placeholderIgnore) + .isFalse(); + assert(!BoolGlobalSetting.placeholderIgnore.default_); + }); + + test('get from initial load', () { + final globalSettings = eg.globalStore(boolGlobalSettings: { + BoolGlobalSetting.placeholderIgnore: true, + }).settings; + check(globalSettings).getBool(BoolGlobalSetting.placeholderIgnore) + .isTrue(); + }); + + test('set, get', () async { + final globalSettings = eg.globalStore(boolGlobalSettings: {}).settings; + check(globalSettings).getBool(BoolGlobalSetting.placeholderIgnore) + .isFalse(); + + await globalSettings.setBool(BoolGlobalSetting.placeholderIgnore, true); + check(globalSettings).getBool(BoolGlobalSetting.placeholderIgnore) + .isTrue(); + + await globalSettings.setBool(BoolGlobalSetting.placeholderIgnore, false); + check(globalSettings).getBool(BoolGlobalSetting.placeholderIgnore) + .isFalse(); + }); + + test('set to null -> revert to default', () async { + final globalSettings = eg.globalStore(boolGlobalSettings: { + BoolGlobalSetting.placeholderIgnore: true, + }).settings; + check(globalSettings).getBool(BoolGlobalSetting.placeholderIgnore) + .isTrue(); + + await globalSettings.setBool(BoolGlobalSetting.placeholderIgnore, null); + check(globalSettings).getBool(BoolGlobalSetting.placeholderIgnore) + .isFalse(); + assert(!BoolGlobalSetting.placeholderIgnore.default_); + }); + + group('set avoids redundant updates', () { + void checkUpdated(bool? old, bool? new_, {required bool expected}) async { + final globalSettings = eg.globalStore(boolGlobalSettings: { + if (old != null) BoolGlobalSetting.placeholderIgnore: old, + }).settings; + + bool updated = false; + globalSettings.addListener(() => updated = true); + + await globalSettings.setBool(BoolGlobalSetting.placeholderIgnore, new_); + check(updated).equals(expected); + } + + test('null to null -> no update', () async { + checkUpdated(null, null, expected: false); + }); + + test('true to true -> no update', () async { + checkUpdated(true, true, expected: false); + }); + + test('false to false -> no update', () async { + checkUpdated(false, false, expected: false); + }); + + test('null to false -> does the update', () async { + checkUpdated(null, false, expected: true); + }); + + test('true to null -> does the update', () async { + checkUpdated(true, null, expected: true); + }); + + test('false to true -> does the update', () async { + checkUpdated(false, true, expected: true); + }); + }); + }); + + group('getInt/setInt', () { + test('get from initial load', () { + final globalSettings = eg.globalStore(intGlobalSettings: { + IntGlobalSetting.placeholderIgnore: 1, + }).settings; + check(globalSettings).getInt(IntGlobalSetting.placeholderIgnore) + .equals(1); + }); + + test('set, get', () async { + final globalSettings = eg.globalStore(intGlobalSettings: {}).settings; + check(globalSettings).getInt(IntGlobalSetting.placeholderIgnore) + .isNull(); + + await globalSettings.setInt(IntGlobalSetting.placeholderIgnore, 1); + check(globalSettings).getInt(IntGlobalSetting.placeholderIgnore) + .equals(1); + + await globalSettings.setInt(IntGlobalSetting.placeholderIgnore, 100); + check(globalSettings).getInt(IntGlobalSetting.placeholderIgnore) + .equals(100); + }); + + test('set to null -> get returns null', () async { + final globalSettings = eg.globalStore(intGlobalSettings: { + IntGlobalSetting.placeholderIgnore: 1, + }).settings; + check(globalSettings).getInt(IntGlobalSetting.placeholderIgnore) + .equals(1); + + await globalSettings.setInt(IntGlobalSetting.placeholderIgnore, null); + check(globalSettings).getInt(IntGlobalSetting.placeholderIgnore) + .isNull(); + }); + + group('set avoids redundant updates', () { + void checkUpdated(int? old, int? new_, {required bool expected}) async { + final globalSettings = eg.globalStore(intGlobalSettings: { + if (old != null) IntGlobalSetting.placeholderIgnore: old, + }).settings; + + bool updated = false; + globalSettings.addListener(() => updated = true); + + await globalSettings.setInt(IntGlobalSetting.placeholderIgnore, new_); + check(updated).equals(expected); + } + + test('null to null -> no update', () async { + checkUpdated(null, null, expected: false); + }); + + test('10 to 10 -> no update', () async { + checkUpdated(10, 10, expected: false); + }); + + test('null to 10 -> does the update', () async { + checkUpdated(null, 10, expected: true); + }); + + test('10 to null -> does the update', () async { + checkUpdated(10, null, expected: true); + }); + + test('10 to 100 -> does the update', () async { + checkUpdated(10, 100, expected: true); + }); + }); + }); +} diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index f2ef63cd4b..36a59c2e16 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -3,10 +3,19 @@ import 'package:zulip/api/core.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/autocomplete.dart'; +import 'package:zulip/model/binding.dart'; +import 'package:zulip/model/database.dart'; import 'package:zulip/model/recent_dm_conversations.dart'; +import 'package:zulip/model/server_support.dart'; +import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/unreads.dart'; +extension GlobalSettingsDataChecks on Subject { + Subject get themeSetting => has((x) => x.themeSetting, 'themeSetting'); + Subject get browserPreference => has((x) => x.browserPreference, 'browserPreference'); +} + extension AccountChecks on Subject { Subject get id => has((x) => x.id, 'id'); Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); @@ -19,16 +28,29 @@ extension AccountChecks on Subject { Subject get ackedPushToken => has((x) => x.ackedPushToken, 'ackedPushToken'); } +extension GlobalSettingsStoreChecks on Subject { + Subject get themeSetting => has((x) => x.themeSetting, 'themeSetting'); + Subject get browserPreference => has((x) => x.browserPreference, 'browserPreference'); + Subject get effectiveBrowserPreference => has((x) => x.effectiveBrowserPreference, 'effectiveBrowserPreference'); + Subject getUrlLaunchMode(Uri url) => has((x) => x.getUrlLaunchMode(url), 'getUrlLaunchMode'); + Subject get visitFirstUnread => has((x) => x.visitFirstUnread, 'visitFirstUnread'); + Subject get markReadOnScroll => has((x) => x.markReadOnScroll, 'markReadOnScroll'); + Subject getBool(BoolGlobalSetting setting) => has((x) => x.getBool(setting), 'getBool(${setting.name}'); + Subject getInt(IntGlobalSetting setting) => has((x) => x.getInt(setting), 'getInt(${setting.name}'); +} + extension GlobalStoreChecks on Subject { + Subject get settings => has((x) => x.settings, 'settings'); Subject> get accounts => has((x) => x.accounts, 'accounts'); Subject> get accountIds => has((x) => x.accountIds, 'accountIds'); Subject> get accountEntries => has((x) => x.accountEntries, 'accountEntries'); Subject getAccount(int id) => has((x) => x.getAccount(id), 'getAccount($id)'); + Subject get lastVisitedAccount => has((x) => x.lastVisitedAccount, 'lastVisitedAccount'); } extension PerAccountStoreChecks on Subject { Subject get connection => has((x) => x.connection, 'connection'); - Subject get isLoading => has((x) => x.isLoading, 'isLoading'); + Subject get isRecoveringEventStream => has((x) => x.isRecoveringEventStream, 'isRecoveringEventStream'); Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); Subject get zulipVersion => has((x) => x.zulipVersion, 'zulipVersion'); Subject get realmMandatoryTopics => has((x) => x.realmMandatoryTopics, 'realmMandatoryTopics'); @@ -38,8 +60,8 @@ extension PerAccountStoreChecks on Subject { Subject get accountId => has((x) => x.accountId, 'accountId'); Subject get account => has((x) => x.account, 'account'); Subject get selfUserId => has((x) => x.selfUserId, 'selfUserId'); - Subject get userSettings => has((x) => x.userSettings, 'userSettings'); - Subject> get users => has((x) => x.users, 'users'); + Subject get userSettings => has((x) => x.userSettings, 'userSettings'); + Subject> get savedSnippets => has((x) => x.savedSnippets, 'savedSnippets'); Subject> get streams => has((x) => x.streams, 'streams'); Subject> get streamsByName => has((x) => x.streamsByName, 'streamsByName'); Subject> get subscriptions => has((x) => x.subscriptions, 'subscriptions'); @@ -48,3 +70,9 @@ extension PerAccountStoreChecks on Subject { Subject get recentDmConversationsView => has((x) => x.recentDmConversationsView, 'recentDmConversationsView'); Subject get autocompleteViewManager => has((x) => x.autocompleteViewManager, 'autocompleteViewManager'); } + +extension ZulipVersionDataChecks on Subject { + Subject get zulipVersion => has((x) => x.zulipVersion, 'zulipVersion'); + Subject get zulipMergeBase => has((x) => x.zulipMergeBase, 'zulipMergeBase'); + Subject get zulipFeatureLevel => has((x) => x.zulipFeatureLevel, 'zulipFeatureLevel'); +} diff --git a/test/model/store_test.dart b/test/model/store_test.dart index c8c6b4c266..d3c3e9c4e4 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -6,15 +6,18 @@ import 'package:fake_async/fake_async.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; +import 'package:zulip/api/backoff.dart'; import 'package:zulip/api/core.dart'; +import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/events.dart'; -import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/realm.dart'; -import 'package:zulip/model/message_list.dart'; -import 'package:zulip/model/narrow.dart'; import 'package:zulip/log.dart'; +import 'package:zulip/model/actions.dart'; +import 'package:zulip/model/presence.dart'; +import 'package:zulip/model/server_support.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/receive.dart'; @@ -29,6 +32,7 @@ import 'test_store.dart'; void main() { TestZulipBinding.ensureInitialized(); + Presence.debugEnable = false; final account1 = eg.selfAccount.copyWith(id: 1); final account2 = eg.otherAccount.copyWith(id: 2); @@ -43,7 +47,7 @@ void main() { final store1 = PerAccountStore.fromInitialSnapshot( globalStore: globalStore, accountId: 1, - initialSnapshot: eg.initialSnapshot(), + initialSnapshot: eg.initialSnapshot(realmUsers: [eg.selfUser]), ); completers(1).single.complete(store1); check(await future1).identicalTo(store1); @@ -54,7 +58,7 @@ void main() { final store2 = PerAccountStore.fromInitialSnapshot( globalStore: globalStore, accountId: 2, - initialSnapshot: eg.initialSnapshot(), + initialSnapshot: eg.initialSnapshot(realmUsers: [eg.otherUser]), ); completers(2).single.complete(store2); check(await future2).identicalTo(store2); @@ -81,12 +85,12 @@ void main() { final store1 = PerAccountStore.fromInitialSnapshot( globalStore: globalStore, accountId: 1, - initialSnapshot: eg.initialSnapshot(), + initialSnapshot: eg.initialSnapshot(realmUsers: [eg.selfUser]), ); final store2 = PerAccountStore.fromInitialSnapshot( globalStore: globalStore, accountId: 2, - initialSnapshot: eg.initialSnapshot(), + initialSnapshot: eg.initialSnapshot(realmUsers: [eg.otherUser]), ); completers(1).single.complete(store1); completers(2).single.complete(store2); @@ -122,6 +126,188 @@ void main() { check(completers(1)).length.equals(1); }); + test('GlobalStore.perAccount loading fails with HTTP status code 401', () => awaitFakeAsync((async) async { + final globalStore = LoadingTestGlobalStore(accounts: [eg.selfAccount]); + final future = globalStore.perAccount(eg.selfAccount.id); + + globalStore.completers[eg.selfAccount.id]! + .single.completeError(eg.apiExceptionUnauthorized()); + await check(future).throws(); + })); + + test('GlobalStore.perAccount loading succeeds', () => awaitFakeAsync((async) async { + NotificationService.instance.token = ValueNotifier('asdf'); + addTearDown(NotificationService.debugReset); + + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request + + await future; + // poll, server-emoji-data, register-token requests + check(connection.takeRequests()).length.equals(3); + check(connection).isOpen.isTrue(); + })); + + test('GlobalStore.perAccount loading succeeds; InitialSnapshot has ancient server version', () => awaitFakeAsync((async) async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + final json = eg.initialSnapshot(zulipFeatureLevel: eg.ancientZulipFeatureLevel).toJson(); + globalStore.prepareRegisterQueueResponse = (connection) { + connection.prepare(json: json); + }; + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request + + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + // no poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); + })); + + test('GlobalStore.perAccount loading fails; malformed response with ancient server version', () => awaitFakeAsync((async) async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + final json = eg.initialSnapshot(zulipFeatureLevel: eg.ancientZulipFeatureLevel).toJson(); + json['realm_emoji'] = 123; + check(() => InitialSnapshot.fromJson(json)).throws(); + globalStore.prepareRegisterQueueResponse = (connection) { + connection.prepare(json: json); + }; + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request + + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + // no poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); + })); + + test('GlobalStore.perAccount account is logged out while loading; then succeeds', () => awaitFakeAsync((async) async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + globalStore.prepareRegisterQueueResponse = (connection) => + connection.prepare( + delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1), + json: eg.initialSnapshot().toJson()); + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request + + await logOutAccount(globalStore, eg.selfAccount.id); + check(globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).isEmpty(); + // no poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); + })); + + test('GlobalStore.perAccount account is logged out while loading; then fails with HTTP status code 401', () => awaitFakeAsync((async) async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + globalStore.prepareRegisterQueueResponse = (connection) => + connection.prepare( + delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1), + apiException: eg.apiExceptionUnauthorized()); + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request + + await logOutAccount(globalStore, eg.selfAccount.id); + check(globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).isEmpty(); + // no poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); + })); + + test('GlobalStore.perAccount account is logged out while loading; then succeeds; InitialSnapshot has ancient server version', () => awaitFakeAsync((async) async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + final json = eg.initialSnapshot(zulipFeatureLevel: eg.ancientZulipFeatureLevel).toJson(); + globalStore.prepareRegisterQueueResponse = (connection) { + connection.prepare( + delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1), + json: json); + }; + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request + + await logOutAccount(globalStore, eg.selfAccount.id); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).isEmpty(); + // no poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); + })); + + test('GlobalStore.perAccount account is logged out while loading; then fails; malformed response with ancient server version', () => awaitFakeAsync((async) async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + final json = eg.initialSnapshot(zulipFeatureLevel: eg.ancientZulipFeatureLevel).toJson(); + json['realm_emoji'] = 123; + check(() => InitialSnapshot.fromJson(json)).throws(); + globalStore.prepareRegisterQueueResponse = (connection) { + connection.prepare( + delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1), + json: json); + }; + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request + + await logOutAccount(globalStore, eg.selfAccount.id); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).isEmpty(); + // no poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); + })); + + test('GlobalStore.perAccount account is logged out during transient-error backoff', () => awaitFakeAsync((async) async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + globalStore.prepareRegisterQueueResponse = (connection) => + connection.prepare( + delay: Duration(seconds: 1), + httpException: http.ClientException('Oops')); + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + BackoffMachine.debugDuration = Duration(seconds: 1); + async.elapse(Duration(milliseconds: 1500)); + check(connection.takeRequests()).length.equals(1); // register request + + assert(TestGlobalStore.removeAccountDuration < Duration(milliseconds: 500)); + await logOutAccount(globalStore, eg.selfAccount.id); + check(globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).isEmpty(); + // no retry-register, poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); + })); + + test('GlobalStore.perAccount throws if missing queueId', () async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + globalStore.prepareRegisterQueueResponse = (connection) { + connection.prepare(json: + deepToJson(eg.initialSnapshot()) as Map + ..['queue_id'] = null); + }; + await check(globalStore.perAccount(eg.selfAccount.id)).throws(); + }); + // TODO test insertAccount group('GlobalStore.updateAccount', () { @@ -168,6 +354,31 @@ void main() { // TODO test database gets updated correctly (an integration test with sqlite?) }); + + test('GlobalStore.updateZulipVersionData', () async { + final [currentZulipVersion, newZulipVersion ] + = ['10.0-beta2-302-gf5b08b11f4', '10.0-beta2-351-g75ac8fe961']; + final [currentZulipMergeBase, newZulipMergeBase ] + = ['10.0-beta2-291-g33ffd8c040', '10.0-beta2-349-g463dc632b3']; + final [currentZulipFeatureLevel, newZulipFeatureLevel ] + = [368, 370 ]; + + final selfAccount = eg.selfAccount.copyWith( + zulipVersion: currentZulipVersion, + zulipMergeBase: Value(currentZulipMergeBase), + zulipFeatureLevel: currentZulipFeatureLevel); + final globalStore = eg.globalStore(accounts: [selfAccount]); + final updated = await globalStore.updateZulipVersionData(selfAccount.id, + ZulipVersionData( + zulipVersion: newZulipVersion, + zulipMergeBase: newZulipMergeBase, + zulipFeatureLevel: newZulipFeatureLevel)); + check(globalStore.getAccount(selfAccount.id)).identicalTo(updated); + check(updated).equals(selfAccount.copyWith( + zulipVersion: newZulipVersion, + zulipMergeBase: Value(newZulipMergeBase), + zulipFeatureLevel: newZulipFeatureLevel)); + }); group('GlobalStore.removeAccount', () { void checkGlobalStore(GlobalStore store, int accountId, { @@ -217,11 +428,20 @@ void main() { }); test('when store loading', () async { - final globalStore = LoadingTestGlobalStore(accounts: [eg.selfAccount]); + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); checkGlobalStore(globalStore, eg.selfAccount.id, expectAccount: true, expectStore: false); - // don't await; we'll complete/await it manually after removeAccount + assert(globalStore.useCachedApiConnections); + // Cache a connection and get this reference to it, + // so we can check later that it gets closed. + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + + globalStore.prepareRegisterQueueResponse = (connection) { + connection.prepare( + delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1), + json: eg.initialSnapshot().toJson()); + }; final loadingFuture = globalStore.perAccount(eg.selfAccount.id); checkGlobalStore(globalStore, eg.selfAccount.id, @@ -235,170 +455,20 @@ void main() { expectAccount: false, expectStore: false); check(notifyCount).equals(1); - globalStore.completers[eg.selfAccount.id]!.single - .complete(eg.store(account: eg.selfAccount, initialSnapshot: eg.initialSnapshot())); - // TODO test that the never-used store got disposed and its connection closed await check(loadingFuture).throws(); checkGlobalStore(globalStore, eg.selfAccount.id, expectAccount: false, expectStore: false); check(notifyCount).equals(1); // no extra notify + check(connection).isOpen.isFalse(); check(globalStore.debugNumPerAccountStoresLoading).equals(0); }); }); - group('PerAccountStore.hasPassedWaitingPeriod', () { - final store = eg.store(initialSnapshot: - eg.initialSnapshot(realmWaitingPeriodThreshold: 2)); - - final testCases = [ - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 0, 10, 00), false), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1, 10, 00), false), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 09, 59), false), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 10, 00), true), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1000, 07, 00), true), - ]; - - for (final (String dateJoined, DateTime currentDate, bool hasPassedWaitingPeriod) in testCases) { - test('user joined at $dateJoined ${hasPassedWaitingPeriod ? 'has' : "hasn't"} ' - 'passed waiting period by $currentDate', () { - final user = eg.user(dateJoined: dateJoined); - check(store.hasPassedWaitingPeriod(user, byDate: currentDate)) - .equals(hasPassedWaitingPeriod); - }); - } - }); - - group('PerAccountStore.hasPostingPermission', () { - final testCases = [ - (ChannelPostPolicy.unknown, UserRole.unknown, true), - (ChannelPostPolicy.unknown, UserRole.guest, true), - (ChannelPostPolicy.unknown, UserRole.member, true), - (ChannelPostPolicy.unknown, UserRole.moderator, true), - (ChannelPostPolicy.unknown, UserRole.administrator, true), - (ChannelPostPolicy.unknown, UserRole.owner, true), - (ChannelPostPolicy.any, UserRole.unknown, true), - (ChannelPostPolicy.any, UserRole.guest, true), - (ChannelPostPolicy.any, UserRole.member, true), - (ChannelPostPolicy.any, UserRole.moderator, true), - (ChannelPostPolicy.any, UserRole.administrator, true), - (ChannelPostPolicy.any, UserRole.owner, true), - (ChannelPostPolicy.fullMembers, UserRole.unknown, true), - (ChannelPostPolicy.fullMembers, UserRole.guest, false), - // The fullMembers/member case gets its own tests further below. - // (ChannelPostPolicy.fullMembers, UserRole.member, /* complicated */), - (ChannelPostPolicy.fullMembers, UserRole.moderator, true), - (ChannelPostPolicy.fullMembers, UserRole.administrator, true), - (ChannelPostPolicy.fullMembers, UserRole.owner, true), - (ChannelPostPolicy.moderators, UserRole.unknown, true), - (ChannelPostPolicy.moderators, UserRole.guest, false), - (ChannelPostPolicy.moderators, UserRole.member, false), - (ChannelPostPolicy.moderators, UserRole.moderator, true), - (ChannelPostPolicy.moderators, UserRole.administrator, true), - (ChannelPostPolicy.moderators, UserRole.owner, true), - (ChannelPostPolicy.administrators, UserRole.unknown, true), - (ChannelPostPolicy.administrators, UserRole.guest, false), - (ChannelPostPolicy.administrators, UserRole.member, false), - (ChannelPostPolicy.administrators, UserRole.moderator, false), - (ChannelPostPolicy.administrators, UserRole.administrator, true), - (ChannelPostPolicy.administrators, UserRole.owner, true), - ]; - - for (final (ChannelPostPolicy policy, UserRole role, bool canPost) in testCases) { - test('"${role.name}" user ${canPost ? 'can' : "can't"} post in channel ' - 'with "${policy.name}" policy', () { - final store = eg.store(); - final actual = store.hasPostingPermission( - inChannel: eg.stream(channelPostPolicy: policy), user: eg.user(role: role), - // [byDate] is not actually relevant for these test cases; for the - // ones which it is, they're practiced below. - byDate: DateTime.now()); - check(actual).equals(canPost); - }); - } - - group('"member" user posting in a channel with "fullMembers" policy', () { - PerAccountStore localStore({required int realmWaitingPeriodThreshold}) => - eg.store(initialSnapshot: eg.initialSnapshot( - realmWaitingPeriodThreshold: realmWaitingPeriodThreshold)); - - User memberUser({required String dateJoined}) => eg.user( - role: UserRole.member, dateJoined: dateJoined); - - test('a "full" member -> can post in the channel', () { - final store = localStore(realmWaitingPeriodThreshold: 3); - final hasPermission = store.hasPostingPermission( - inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), - user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), - byDate: DateTime.utc(2024, 11, 28, 10, 00)); - check(hasPermission).isTrue(); - }); - - test('not a "full" member -> cannot post in the channel', () { - final store = localStore(realmWaitingPeriodThreshold: 3); - final actual = store.hasPostingPermission( - inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), - user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), - byDate: DateTime.utc(2024, 11, 28, 09, 59)); - check(actual).isFalse(); - }); - }); - }); - group('PerAccountStore.handleEvent', () { // Mostly this method just dispatches to ChannelStore and MessageStore etc., - // and so most of the tests live in the test files for those + // and so its tests generally live in the test files for those // (but they call the handleEvent method because it's the entry point). - - group('RealmUserUpdateEvent', () { - // TODO write more tests for handling RealmUserUpdateEvent - - test('deliveryEmail', () { - final user = eg.user(deliveryEmail: 'a@mail.example'); - final store = eg.store(initialSnapshot: eg.initialSnapshot( - realmUsers: [eg.selfUser, user])); - - User getUser() => store.users[user.userId]!; - - store.handleEvent(RealmUserUpdateEvent(id: 1, userId: user.userId, - deliveryEmail: null)); - check(getUser()).deliveryEmail.equals('a@mail.example'); - - store.handleEvent(RealmUserUpdateEvent(id: 1, userId: user.userId, - deliveryEmail: const JsonNullable(null))); - check(getUser()).deliveryEmail.isNull(); - - store.handleEvent(RealmUserUpdateEvent(id: 1, userId: user.userId, - deliveryEmail: const JsonNullable('b@mail.example'))); - check(getUser()).deliveryEmail.equals('b@mail.example'); - - store.handleEvent(RealmUserUpdateEvent(id: 1, userId: user.userId, - deliveryEmail: const JsonNullable('c@mail.example'))); - check(getUser()).deliveryEmail.equals('c@mail.example'); - }); - }); - }); - - group('PerAccountStore.sendMessage', () { - test('smoke', () async { - final store = eg.store(); - final connection = store.connection as FakeApiConnection; - final stream = eg.stream(); - connection.prepare(json: SendMessageResult(id: 12345).toJson()); - await store.sendMessage( - destination: StreamDestination(stream.streamId, eg.t('world')), - content: 'hello'); - check(connection.takeRequests()).single.isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages') - ..bodyFields.deepEquals({ - 'type': 'stream', - 'to': stream.streamId.toString(), - 'topic': 'world', - 'content': 'hello', - 'read_by_sender': 'true', - }); - }); }); group('UpdateMachine.load', () { @@ -406,7 +476,7 @@ void main() { late FakeApiConnection connection; Future prepareStore({Account? account}) async { - globalStore = TestGlobalStore(accounts: []); + globalStore = eg.globalStore(); account ??= eg.selfAccount; await globalStore.insertAccount(account.toCompanion(false)); connection = (globalStore.apiConnectionFromAccount(account) @@ -439,7 +509,7 @@ void main() { // clobber the recorded registerQueue request so we can't check it. // checkLastRequest(); - check(updateMachine.store.users.values).unorderedMatches( + check(updateMachine.store.allUsers).unorderedMatches( users.map((expected) => (it) => it.fullName.equals(expected.fullName))); })); @@ -475,7 +545,7 @@ void main() { // Try to load, inducing an error in the request. globalStore.useCachedApiConnections = true; - connection.prepare(exception: Exception('failed')); + connection.prepare(httpException: Exception('failed')); final future = UpdateMachine.load(globalStore, eg.selfAccount.id); bool complete = false; unawaited(future.whenComplete(() => complete = true)); @@ -496,7 +566,7 @@ void main() { updateMachine.debugPauseLoop(); check(complete).isTrue(); // checkLastRequest(); TODO UpdateMachine.debugPauseLoop was too late; see comment above - check(updateMachine.store.users.values).unorderedMatches( + check(updateMachine.store.allUsers).unorderedMatches( users.map((expected) => (it) => it.fullName.equals(expected.fullName))); })); @@ -517,7 +587,7 @@ void main() { final emojiDataUrl = Uri.parse('https://cdn.example/emoji.json'); final data = { - '1f642': ['smile'], + '1f642': ['slight_smile'], '1f34a': ['orange', 'tangerine', 'mandarin'], }; @@ -543,7 +613,7 @@ void main() { check(store.debugServerEmojiData).isNull(); // Try to fetch, inducing an error in the request. - connection.prepare(exception: Exception('failed')); + connection.prepare(httpException: Exception('failed')); final future = updateMachine.fetchEmojiData(emojiDataUrl); bool complete = false; unawaited(future.whenComplete(() => complete = true)); @@ -569,19 +639,18 @@ void main() { group('UpdateMachine.poll', () { late TestGlobalStore globalStore; - late UpdateMachine updateMachine; late PerAccountStore store; + late UpdateMachine updateMachine; late FakeApiConnection connection; void updateFromGlobalStore() { - updateMachine = globalStore.updateMachines[eg.selfAccount.id]!; - store = updateMachine.store; - assert(identical(store, globalStore.perAccountSync(eg.selfAccount.id))); + store = globalStore.perAccountSync(eg.selfAccount.id)!; + updateMachine = store.updateMachine!; connection = store.connection as FakeApiConnection; } Future preparePoll({int? lastEventId}) async { - globalStore = TestGlobalStore(accounts: []); + globalStore = eg.globalStore(); await globalStore.add(eg.selfAccount, eg.initialSnapshot( lastEventId: lastEventId)); await globalStore.perAccount(eg.selfAccount.id); @@ -590,13 +659,14 @@ void main() { updateMachine.poll(); } - void checkLastRequest({required int lastEventId}) { + void checkLastRequest({required int lastEventId, bool expectDontBlock = false}) { check(connection.takeRequests()).single.isA() ..method.equals('GET') ..url.path.equals('/api/v1/events') ..url.queryParameters.deepEquals({ - 'queue_id': updateMachine.queueId, + 'queue_id': store.queueId, 'last_event_id': lastEventId.toString(), + if (expectDontBlock) 'dont_block': 'true', }); } @@ -629,14 +699,16 @@ void main() { await preparePoll(); // Pick some arbitrary event and check it gets processed on the store. - check(store.userSettings!.twentyFourHourTime).isFalse(); + check(store.userSettings.twentyFourHourTime) + .equals(TwentyFourHourTimeMode.twelveHour); connection.prepare(json: GetEventsResult(events: [ UserSettingsUpdateEvent(id: 2, property: UserSettingName.twentyFourHourTime, value: true), ], queueId: null).toJson()); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - check(store.userSettings!.twentyFourHourTime).isTrue(); + check(store.userSettings.twentyFourHourTime) + .equals(TwentyFourHourTimeMode.twentyFourHour); })); void checkReload(FutureOr Function() prepareError, { @@ -649,7 +721,7 @@ void main() { await prepareError(); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - check(store).isLoading.isTrue(); + check(store).isRecoveringEventStream.isTrue(); if (expectBackoff) { // The reload doesn't happen immediately; there's a timer. @@ -661,19 +733,21 @@ void main() { // The global store has a new store. check(globalStore.perAccountSync(store.accountId)).not((it) => it.identicalTo(store)); updateFromGlobalStore(); - check(store).isLoading.isFalse(); + check(store).isRecoveringEventStream.isFalse(); // The new UpdateMachine updates the new store. updateMachine.debugPauseLoop(); updateMachine.poll(); - check(store.userSettings!.twentyFourHourTime).isFalse(); + check(store.userSettings.twentyFourHourTime) + .equals(TwentyFourHourTimeMode.twelveHour); connection.prepare(json: GetEventsResult(events: [ UserSettingsUpdateEvent(id: 2, property: UserSettingName.twentyFourHourTime, value: true), ], queueId: null).toJson()); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - check(store.userSettings!.twentyFourHourTime).isTrue(); + check(store.userSettings.twentyFourHourTime) + .equals(TwentyFourHourTimeMode.twentyFourHour); }); } @@ -686,8 +760,8 @@ void main() { prepareError(); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - checkLastRequest(lastEventId: 1); - check(store).isLoading.isTrue(); + checkLastRequest(lastEventId: 1, expectDontBlock: false); + check(store).isRecoveringEventStream.isTrue(); // Polling doesn't resume immediately; there's a timer. check(async.pendingTimers).length.equals(1); @@ -701,9 +775,9 @@ void main() { HeartbeatEvent(id: 2), ], queueId: null).toJson()); async.flushTimers(); - checkLastRequest(lastEventId: 1); + checkLastRequest(lastEventId: 1, expectDontBlock: true); check(updateMachine.lastEventId).equals(2); - check(store).isLoading.isFalse(); + check(store).isRecoveringEventStream.isFalse(); }); } @@ -714,11 +788,11 @@ void main() { } void prepareNetworkExceptionSocketException() { - connection.prepare(exception: const SocketException('failed')); + connection.prepare(httpException: const SocketException('failed')); } void prepareNetworkException() { - connection.prepare(exception: Exception("failed")); + connection.prepare(httpException: Exception("failed")); } void prepareServer5xxException() { @@ -759,11 +833,8 @@ void main() { } void prepareExpiredEventQueue() { - connection.prepare(httpStatus: 400, json: { - 'result': 'error', 'code': 'BAD_EVENT_QUEUE_ID', - 'queue_id': updateMachine.queueId, - 'msg': 'Bad event queue ID: ${updateMachine.queueId}', - }); + connection.prepare(apiException: eg.apiExceptionBadEventQueueId( + queueId: store.queueId)); } Future prepareHandleEventError() async { @@ -824,25 +895,6 @@ void main() { checkReload(prepareHandleEventError); }); - test('expired queue disposes registered MessageListView instances', () => awaitFakeAsync((async) async { - // Regression test for: https://github.com/zulip/zulip-flutter/issues/810 - await preparePoll(); - - // Make sure there are [MessageListView]s in the message store. - MessageListView.init(store: store, narrow: const MentionsNarrow()); - MessageListView.init(store: store, narrow: const StarredMessagesNarrow()); - check(store.debugMessageListViews).length.equals(2); - - // Let the server expire the event queue. - prepareExpiredEventQueue(); - updateMachine.debugAdvanceLoop(); - async.elapse(Duration.zero); - - // The old store's [MessageListView]s have been disposed. - // (And no exception was thrown; that was #810.) - check(store.debugMessageListViews).isEmpty(); - })); - group('report error', () { String? lastReportedError; String? takeLastReportedError() { @@ -862,11 +914,13 @@ void main() { await preparePoll(lastEventId: 1); } - void pollAndFail(FakeAsync async, {bool shouldCheckRequest = true}) { + void pollAndFail(FakeAsync async, {bool shouldCheckRequest = true, bool expectDontBlock = false}) { updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - if (shouldCheckRequest) checkLastRequest(lastEventId: 1); - check(store).isLoading.isTrue(); + if (shouldCheckRequest) { + checkLastRequest(lastEventId: 1, expectDontBlock: expectDontBlock); + } + check(store).isRecoveringEventStream.isTrue(); } Subject checkReported(void Function() prepareError) { @@ -884,9 +938,11 @@ void main() { return awaitFakeAsync((async) async { await prepare(); + bool expectDontBlock = false; for (int i = 0; i < UpdateMachine.transientFailureCountNotifyThreshold; i++) { prepareError(); - pollAndFail(async); + pollAndFail(async, expectDontBlock: expectDontBlock); + expectDontBlock = true; check(takeLastReportedError()).isNull(); async.flushTimers(); if (!identical(store, globalStore.perAccountSync(store.accountId))) { @@ -894,11 +950,14 @@ void main() { updateFromGlobalStore(); updateMachine.debugPauseLoop(); updateMachine.poll(); + // Loading indicator is cleared on successful /register; + // we don't need dont_block for the new queue's first poll. + expectDontBlock = false; } } prepareError(); - pollAndFail(async); + pollAndFail(async, expectDontBlock: expectDontBlock); return check(takeLastReportedError()).isNotNull(); }); } @@ -907,9 +966,11 @@ void main() { return awaitFakeAsync((async) async { await prepare(); + bool expectDontBlock = false; for (int i = 0; i < UpdateMachine.transientFailureCountNotifyThreshold; i++) { prepareError(); - pollAndFail(async); + pollAndFail(async, expectDontBlock: expectDontBlock); + expectDontBlock = true; check(takeLastReportedError()).isNull(); async.flushTimers(); if (!identical(store, globalStore.perAccountSync(store.accountId))) { @@ -917,11 +978,14 @@ void main() { updateFromGlobalStore(); updateMachine.debugPauseLoop(); updateMachine.poll(); + // Loading indicator is cleared on successful /register; + // we don't need dont_block for the new queue's first poll. + expectDontBlock = false; } } prepareError(); - pollAndFail(async); + pollAndFail(async, expectDontBlock: expectDontBlock); // Still no error reported, even after the same number of iterations // where other errors get reported (as [checkLateReported] checks). check(takeLastReportedError()).isNull(); @@ -993,6 +1057,109 @@ void main() { }); }); + group('UpdateMachine.poll reload failure', () { + late UpdateMachineTestGlobalStore globalStore; + + Future prepareReload(FakeAsync async, { + required void Function(FakeApiConnection) prepareRegisterQueueResponse, + }) async { + globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + + final store = await globalStore.perAccount(eg.selfAccount.id); + final updateMachine = store.updateMachine!; + + final connection = store.connection as FakeApiConnection; + connection.prepare( + apiException: eg.apiExceptionBadEventQueueId()); + globalStore.prepareRegisterQueueResponse = prepareRegisterQueueResponse; + // When we reload, we should get a new connection, + // just like when the app runs live. This is more realistic, + // and we don't want a glitch where we try to double-close a connection + // just because of the test infrastructure. (One of the tests + // logs out the account, and the connection shouldn't be used after that.) + globalStore.clearCachedApiConnections(); + updateMachine.debugAdvanceLoop(); + async.elapse(Duration.zero); // the bad-event-queue error arrives + check(store).isRecoveringEventStream.isTrue(); + } + + test('user logged out before new store is loaded', () => awaitFakeAsync((async) async { + await prepareReload(async, prepareRegisterQueueResponse: (connection) { + connection.prepare( + delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1), + json: eg.initialSnapshot().toJson()); + }); + + await logOutAccount(globalStore, eg.selfAccount.id); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + + async.elapse(TestGlobalStore.removeAccountDuration); + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + + async.flushTimers(); + // Reload never succeeds and there are no unhandled errors. + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + })); + + test('new store is not loaded, gets HTTP 401 error instead', () => awaitFakeAsync((async) async { + await prepareReload(async, prepareRegisterQueueResponse: (connection) { + connection.prepare( + delay: Duration(seconds: 1), + apiException: eg.apiExceptionUnauthorized()); + }); + + async.elapse(const Duration(seconds: 1)); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + + async.elapse(TestGlobalStore.removeAccountDuration); + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + + async.flushTimers(); + // Reload never succeeds and there are no unhandled errors. + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + })); + + test('new store is not loaded, gets InitialSnapshot with ancient server version', () => awaitFakeAsync((async) async { + final json = eg.initialSnapshot(zulipFeatureLevel: eg.ancientZulipFeatureLevel).toJson(); + await prepareReload(async, prepareRegisterQueueResponse: (connection) { + connection.prepare( + delay: Duration(seconds: 1), + json: json); + }); + + async.elapse(const Duration(seconds: 1)); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + + async.elapse(TestGlobalStore.removeAccountDuration); + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + + async.flushTimers(); + // Reload never succeeds and there are no unhandled errors. + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + })); + + test('new store is not loaded, gets malformed response with ancient server version', () => awaitFakeAsync((async) async { + final json = eg.initialSnapshot(zulipFeatureLevel: eg.ancientZulipFeatureLevel).toJson(); + json['realm_emoji'] = 123; + check(() => InitialSnapshot.fromJson(json)).throws(); + await prepareReload(async, prepareRegisterQueueResponse: (connection) { + connection.prepare( + delay: Duration(seconds: 1), + json: json); + }); + + async.elapse(const Duration(seconds: 1)); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + + async.elapse(TestGlobalStore.removeAccountDuration); + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + + async.flushTimers(); + // Reload never succeeds and there are no unhandled errors. + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + })); + }); + group('UpdateMachine.registerNotificationToken', () { late UpdateMachine updateMachine; late FakeApiConnection connection; @@ -1022,6 +1189,7 @@ void main() { // (This is probably the common case.) addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; + testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter'); addTearDown(NotificationService.debugReset); await NotificationService.instance.start(); @@ -1049,6 +1217,7 @@ void main() { // request for the token is still pending. addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; + testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter'); addTearDown(NotificationService.debugReset); final startFuture = NotificationService.instance.start(); @@ -1068,6 +1237,7 @@ void main() { // When the token later appears, send it. connection.prepare(json: {}); await startFuture; + async.flushMicrotasks(); if (defaultTargetPlatform == TargetPlatform.android) { checkLastRequestFcm(token: '012abc'); } else { @@ -1082,6 +1252,51 @@ void main() { checkLastRequestFcm(token: '456def'); } })); + + test('on iOS, use provided app ID from packageInfo', () => awaitFakeAsync((async) async { + final origTargetPlatform = debugDefaultTargetPlatformOverride; + addTearDown(() => debugDefaultTargetPlatformOverride = origTargetPlatform); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(testBinding.reset); + testBinding.firebaseMessagingInitialToken = '012abc'; + testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.example.test'); + addTearDown(NotificationService.debugReset); + await NotificationService.instance.start(); + + prepareStore(); + connection.prepare(json: {}); + await updateMachine.registerNotificationToken(); + checkLastRequestApns(token: '012abc', appid: 'com.example.test'); + })); + }); + + group('ZulipVersionData', () { + group('fromMalformedServerResponseException', () { + test('replace missing feature level with 0', () async { + final connection = testBinding.globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + + final json = eg.initialSnapshot().toJson() + ..['zulip_version'] = '2.0.0' + ..remove('zulip_feature_level') // malformed in current schema + ..remove('zulip_merge_base'); + + Object? error; + connection.prepare(json: json); + try { + await registerQueue(connection); + } catch (e) { + error = e; + } + + check(error).isNotNull().isA(); + final zulipVersionData = ZulipVersionData.fromMalformedServerResponseException( + error as MalformedServerResponseException); + check(zulipVersionData).isNotNull() + ..zulipVersion.equals('2.0.0') + ..zulipMergeBase.isNull() + ..zulipFeatureLevel.equals(0); + }); + }); }); } diff --git a/test/model/test_store.dart b/test/model/test_store.dart index b6887cbed7..e706f3139f 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -1,29 +1,18 @@ import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/events.dart'; +import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/model/database.dart'; +import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; +import 'package:zulip/notifications/receive.dart'; import 'package:zulip/widgets/store.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; -/// A [GlobalStore] containing data provided by callers, -/// and that causes no database queries or network requests. -/// -/// Tests can provide data to the store by calling [add]. -/// -/// The per-account stores will use [FakeApiConnection]. -/// -/// Unlike with [LiveGlobalStore] and the associated [UpdateMachine.load], -/// there is no automatic event-polling loop or other automated requests. -/// For each account loaded, there is a corresponding [UpdateMachine] -/// in [updateMachines], which tests can use for invoking that logic -/// explicitly when desired. -/// -/// See also [TestZulipBinding.globalStore], which provides one of these. -class TestGlobalStore extends GlobalStore { - TestGlobalStore({required super.accounts}); - +mixin _ApiConnectionsMixin on GlobalStore { final Map< ({Uri realmUrl, int? zulipFeatureLevel, String? email, String? apiKey}), FakeApiConnection @@ -72,27 +61,26 @@ class TestGlobalStore extends GlobalStore { realmUrl: realmUrl, zulipFeatureLevel: zulipFeatureLevel, email: email, apiKey: apiKey)); } +} - /// A corresponding [UpdateMachine] for each loaded account. - final Map updateMachines = {}; +class _TestGlobalStoreBackend implements GlobalStoreBackend { + @override + Future doUpdateGlobalSettings(GlobalSettingsCompanion data) async { + // Nothing to do. + } - final Map _initialSnapshots = {}; + @override + Future doSetBoolGlobalSetting(BoolGlobalSetting setting, bool? value) async { + // Nothing to do. + } - /// Add an account and corresponding server data to the test data. - /// - /// The given account will be added to the store. - /// The given initial snapshot will be used to initialize a corresponding - /// [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; + @override + Future doSetIntGlobalSetting(IntGlobalSetting setting, int? value) async { + // Nothing to do. } +} +mixin _DatabaseMixin on GlobalStore { int _nextAccountId = 1; @override @@ -127,9 +115,6 @@ class TestGlobalStore extends GlobalStore { // Nothing to do. } - static const Duration removeAccountDuration = Duration(milliseconds: 1); - Duration? loadPerAccountDuration; - /// Consume the log of calls made to [doRemoveAccount]. List takeDoRemoveAccountCalls() { final result = _doRemoveAccountCalls; @@ -141,27 +126,156 @@ class TestGlobalStore extends GlobalStore { @override Future doRemoveAccount(int accountId) async { (_doRemoveAccountCalls ??= []).add(accountId); - await Future.delayed(removeAccountDuration); + await Future.delayed(TestGlobalStore.removeAccountDuration); // Nothing else to do. } +} + +/// A [GlobalStore] containing data provided by callers, +/// and that causes no database queries or network requests. +/// +/// Tests can provide data to the store by calling [add]. +/// +/// The per-account stores will use [FakeApiConnection]. +/// +/// Unlike with [LiveGlobalStore] and the associated [UpdateMachine.load], +/// there is no automatic event-polling loop or other automated requests. +/// Tests can use [PerAccountStore.updateMachine] in order to invoke that logic +/// explicitly when desired. +/// +/// See also: +/// * [TestZulipBinding.globalStore], which provides one of these. +/// * [UpdateMachineTestGlobalStore], which prepares per-account data +/// using [UpdateMachine.load] (like [LiveGlobalStore] does). +class TestGlobalStore extends GlobalStore with _ApiConnectionsMixin, _DatabaseMixin { + TestGlobalStore({ + GlobalSettingsData? globalSettings, + Map? boolGlobalSettings, + Map? intGlobalSettings, + required super.accounts, + }) : super(backend: _TestGlobalStoreBackend(), + globalSettings: globalSettings ?? GlobalSettingsData(), + boolGlobalSettings: boolGlobalSettings ?? {}, + intGlobalSettings: intGlobalSettings ?? {}, + ); + + final Map _initialSnapshots = {}; + + static const Duration removeAccountDuration = Duration(milliseconds: 1); + + /// Add an account and corresponding server data to the test data. + /// + /// The given account will be added to the store. + /// The given initial snapshot will be used to initialize a corresponding + /// [PerAccountStore] when [perAccount] is subsequently called for this + /// account, in particular when a [PerAccountStoreWidget] is mounted. + /// + /// By default, [setLastVisitedAccount] is called for the account. + /// Pass false for [markLastVisited] to skip that. + Future add( + Account account, + InitialSnapshot initialSnapshot, { + bool markLastVisited = true, + }) 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; + + if (markLastVisited) { + await setLastVisitedAccount(account.id); + } + } + + Duration? loadPerAccountDuration; + Object? loadPerAccountException; @override Future doLoadPerAccount(int accountId) async { if (loadPerAccountDuration != null) { await Future.delayed(loadPerAccountDuration!); } + if (loadPerAccountException != null) { + throw loadPerAccountException!; + } final initialSnapshot = _initialSnapshots[accountId]!; final store = PerAccountStore.fromInitialSnapshot( globalStore: this, accountId: accountId, initialSnapshot: initialSnapshot, ); - updateMachines[accountId] = UpdateMachine.fromInitialSnapshot( + UpdateMachine.fromInitialSnapshot( store: store, initialSnapshot: initialSnapshot); return Future.value(store); } } +/// A [GlobalStore] that causes no database queries, +/// and loads per-account data from API responses prepared by callers. +/// +/// The per-account stores will use [FakeApiConnection]. +/// +/// Like [LiveGlobalStore] and unlike [TestGlobalStore], +/// account data is loaded via [UpdateMachine.load]. +/// Callers can set [prepareRegisterQueueResponse] +/// to prepare a register-queue payload or an exception. +/// The implementation pauses the event-polling loop +/// to avoid being a nuisance and does a boring +/// [FakeApiConnection.prepare] for the register-token request. +/// +/// See also: +/// * [TestGlobalStore], which prepares per-account data +/// without using [UpdateMachine.load]. +class UpdateMachineTestGlobalStore extends GlobalStore with _ApiConnectionsMixin, _DatabaseMixin { + UpdateMachineTestGlobalStore({ + GlobalSettingsData? globalSettings, + Map? boolGlobalSettings, + Map? intGlobalSettings, + required super.accounts, + }) : super(backend: _TestGlobalStoreBackend(), + globalSettings: globalSettings ?? GlobalSettingsData(), + boolGlobalSettings: boolGlobalSettings ?? {}, + intGlobalSettings: intGlobalSettings ?? {}, + ); + + // [doLoadPerAccount] depends on the cache to prepare the API responses. + // Calling [clearCachedApiConnections] is permitted, though. + @override bool get useCachedApiConnections => true; + @override set useCachedApiConnections(bool value) => + throw UnsupportedError( + 'Setting UpdateMachineTestGlobalStore.useCachedApiConnections ' + 'is not supported.'); + + void Function(FakeApiConnection)? prepareRegisterQueueResponse; + + void _prepareRegisterQueueSuccess(FakeApiConnection connection) { + connection.prepare(json: eg.initialSnapshot().toJson()); + } + + @override + Future doLoadPerAccount(int accountId) async { + final account = getAccount(accountId); + + // UpdateMachine.load should pick up the connection + // with the network-request responses that we've prepared. + assert(useCachedApiConnections); + + final connection = apiConnectionFromAccount(account!) as FakeApiConnection; + (prepareRegisterQueueResponse ?? _prepareRegisterQueueSuccess)(connection); + connection + ..prepare(json: GetEventsResult(events: [HeartbeatEvent(id: 2)], queueId: null).toJson()) + ..prepare(json: ServerEmojiData(codeToNames: {}).toJson()); + if (NotificationService.instance.token.value != null) { + connection.prepare(json: {}); // register-token + } + final updateMachine = await UpdateMachine.load(this, accountId); + updateMachine.debugPauseLoop(); + return updateMachine.store; + } +} + extension PerAccountStoreTestExtension on PerAccountStore { Future addUser(User user) async { await handleEvent(RealmUserAddEvent(id: 1, person: user)); @@ -173,6 +287,30 @@ extension PerAccountStoreTestExtension on PerAccountStore { } } + Future addUserGroup(UserGroup userGroup) async { + await handleEvent(UserGroupAddEvent(id: 1, group: userGroup)); + } + + Future addUserGroups(Iterable userGroups) async { + for (final userGroup in userGroups) { + await addUserGroup(userGroup); + } + } + + Future setMutedUsers(List userIds) async { + await handleEvent(eg.mutedUsersEvent(userIds)); + } + + Future changeUserStatus(int userId, UserStatusChange change) async { + await handleEvent(UserStatusEvent(id: 1, userId: userId, change: change)); + } + + Future changeUserStatuses(Map changes) async { + for (final MapEntry(key: userId, value: change) in changes.entries) { + await changeUserStatus(userId, change); + } + } + Future addStream(ZulipStream stream) async { await addStreams([stream]); } @@ -189,12 +327,20 @@ extension PerAccountStoreTestExtension on PerAccountStore { await handleEvent(SubscriptionAddEvent(id: 1, subscriptions: subscriptions)); } - Future addUserTopic(ZulipStream stream, String topic, UserTopicVisibilityPolicy visibilityPolicy) async { + Future removeSubscription(int channelId) async { + await removeSubscriptions([channelId]); + } + + Future removeSubscriptions(List channelIds) async { + await handleEvent(SubscriptionRemoveEvent(id: 1, streamIds: channelIds)); + } + + Future setUserTopic(ZulipStream stream, String topic, UserTopicVisibilityPolicy visibilityPolicy) async { await handleEvent(eg.userTopicEvent(stream.streamId, topic, visibilityPolicy)); } Future addMessage(Message message) async { - await handleEvent(MessageEvent(id: 1, message: message)); + await handleEvent(eg.messageEvent(message)); } Future addMessages(Iterable messages) async { diff --git a/test/model/typing_status_test.dart b/test/model/typing_status_test.dart index 4061301366..8d293940ad 100644 --- a/test/model/typing_status_test.dart +++ b/test/model/typing_status_test.dart @@ -77,9 +77,11 @@ void main() { int? selfUserId, Map> typistsByNarrow = const {}, }) { - model = TypingStatus( - selfUserId: selfUserId ?? eg.selfUser.userId, - typingStartedExpiryPeriod: const Duration(milliseconds: 15000)); + final store = eg.store( + account: eg.selfAccount.copyWith(id: selfUserId), + initialSnapshot: eg.initialSnapshot( + serverTypingStartedExpiryPeriodMilliseconds: 15000)); + model = store.typingStatus; check(model.debugActiveNarrows).isEmpty(); notifiedCount = 0; model.addListener(() => notifiedCount += 1); @@ -295,7 +297,7 @@ void main() { const waitTime = Duration(milliseconds: 100); // [waitTime] should not be long enough // to trigger a "typing stopped" notice. - assert(waitTime < model.typingStoppedWaitPeriod); + assert(waitTime < store.serverTypingStoppedWaitPeriod); async.elapse(waitTime); // t = 100ms: The idle timer is reset to typingStoppedWaitPeriod. @@ -304,7 +306,7 @@ void main() { check(connection.lastRequest).isNull(); check(async.pendingTimers).single; - async.elapse(model.typingStoppedWaitPeriod - const Duration(milliseconds: 1)); + async.elapse(store.serverTypingStoppedWaitPeriod - const Duration(milliseconds: 1)); // t = typingStoppedWaitPeriod + 99ms: // Since the timer was reset at t = 100ms, the "typing stopped" notice has // not been sent yet. @@ -324,12 +326,12 @@ void main() { const waitInterval = Duration(milliseconds: 2000); // [waitInterval] should not be long enough // to trigger a "typing stopped" notice. - assert(waitInterval < model.typingStoppedWaitPeriod); + assert(waitInterval < store.serverTypingStoppedWaitPeriod); // [waitInterval] should be short enough // that the loop below runs more than once. - assert(waitInterval < model.typingStartedWaitPeriod); + assert(waitInterval < store.serverTypingStartedWaitPeriod); - while (async.elapsed <= model.typingStartedWaitPeriod) { + while (async.elapsed <= store.serverTypingStartedWaitPeriod) { // t <= typingStartedWaitPeriod: "Typing started" notices are throttled. model.keystroke(narrow); check(connection.lastRequest).isNull(); @@ -352,7 +354,7 @@ void main() { await prepareStartTyping(async); connection.prepare(json: {}); - async.elapse(model.typingStoppedWaitPeriod); + async.elapse(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); check(async.pendingTimers).isEmpty(); })); @@ -404,7 +406,7 @@ void main() { const waitTime = Duration(milliseconds: 100); // [waitTime] should not be long enough // to trigger a "typing stopped" notice. - assert(waitTime < model.typingStoppedWaitPeriod); + assert(waitTime < store.serverTypingStoppedWaitPeriod); // t = 0ms: Start typing. The idle timer is set to typingStoppedWaitPeriod. connection.prepare(json: {}); @@ -427,7 +429,7 @@ void main() { async.elapse(Duration.zero); check(async.pendingTimers).single; - async.elapse(model.typingStoppedWaitPeriod - waitTime); + async.elapse(store.serverTypingStoppedWaitPeriod - waitTime); // t = typingStoppedPeriod: // Because the old timer has been canceled at t = 100ms, // no "typing stopped" notice has been sent yet. @@ -450,7 +452,7 @@ void main() { const waitInterval = Duration(milliseconds: 2000); // [waitInterval] should not be long enough // to trigger a "typing stopped" notice. - assert(waitInterval < model.typingStoppedWaitPeriod); + assert(waitInterval < store.serverTypingStoppedWaitPeriod); // t = 0ms: Start typing. The typing started time is set to 0ms. connection.prepare(json: {}); @@ -469,7 +471,7 @@ void main() { checkSetTypingStatusRequests(connection.takeRequests(), [(TypingOp.stop, topicNarrow), (TypingOp.start, dmNarrow)]); - while (async.elapsed <= model.typingStartedWaitPeriod) { + while (async.elapsed <= store.serverTypingStartedWaitPeriod) { // t <= typingStartedWaitPeriod: "still typing" requests are throttled. model.keystroke(dmNarrow); check(connection.lastRequest).isNull(); @@ -477,8 +479,8 @@ void main() { async.elapse(waitInterval); } - assert(async.elapsed > model.typingStartedWaitPeriod); - assert(async.elapsed <= model.typingStartedWaitPeriod + waitInterval); + assert(async.elapsed > store.serverTypingStartedWaitPeriod); + assert(async.elapsed <= store.serverTypingStartedWaitPeriod + waitInterval); // typingStartedWaitPeriod < t <= typingStartedWaitPeriod + waitInterval * 1: // The "still typing" requests are still throttled, because it hasn't // been a full typingStartedWaitPeriod since the last time we sent diff --git a/test/model/unreads_checks.dart b/test/model/unreads_checks.dart index ac4d64846a..b38dd4dd0a 100644 --- a/test/model/unreads_checks.dart +++ b/test/model/unreads_checks.dart @@ -5,6 +5,7 @@ import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/unreads.dart'; extension UnreadsChecks on Subject { + Subject> get locatorMap => has((u) => u.locatorMap, 'locatorMap'); Subject>>> get streams => has((u) => u.streams, 'streams'); Subject>> get dms => has((u) => u.dms, 'dms'); Subject> get mentions => has((u) => u.mentions, 'mentions'); diff --git a/test/model/unreads_test.dart b/test/model/unreads_test.dart index 40e074dcaa..4952354044 100644 --- a/test/model/unreads_test.dart +++ b/test/model/unreads_test.dart @@ -4,26 +4,44 @@ import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/algorithms.dart'; +import 'package:zulip/model/channel.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/unreads.dart'; import '../example_data.dart' as eg; +import '../stdlib_checks.dart'; import 'test_store.dart'; import 'unreads_checks.dart'; +void checkInvariants(Unreads model) { + for (final MapEntry(value: topics) in model.streams.entries) { + for (final MapEntry(value: messageIds) in topics.entries) { + check(isSortedWithoutDuplicates(messageIds)).isTrue(); + } + } + + for (final MapEntry(value: messageIds) in model.dms.entries) { + check(isSortedWithoutDuplicates(messageIds)).isTrue(); + } +} + void main() { // These variables are the common state operated on by each test. // Each test case calls [prepare] to initialize them. late Unreads model; - late PerAccountStore channelStore; // TODO reduce this to ChannelStore + late PerAccountStore store; late int notifiedCount; void checkNotified({required int count}) { check(notifiedCount).equals(count); notifiedCount = 0; } - void checkNotNotified() => checkNotified(count: 0); + void checkNotNotified() { + checkInvariants(model); + checkNotified(count: 0); + } void checkNotifiedOnce() => checkNotified(count: 1); /// Initialize [model] and the rest of the test state. @@ -37,19 +55,21 @@ void main() { oldUnreadsMissing: false, ), }) { - channelStore = eg.store(); + store = eg.store(initialSnapshot: eg.initialSnapshot(unreadMsgs: initial)); + checkInvariants(store.unreads); notifiedCount = 0; - model = Unreads(initial: initial, - selfUserId: eg.selfUser.userId, channelStore: channelStore) + model = store.unreads ..addListener(() { + checkInvariants(model); notifiedCount++; }); checkNotNotified(); } - void fillWithMessages(Iterable messages) { + void fillWithMessages(List messages) { + check(isSortedWithoutDuplicates(messages.map((m) => m.id).toList())).isTrue(); for (final message in messages) { - model.handleMessageEvent(MessageEvent(id: 0, message: message)); + model.handleMessageEvent(eg.messageEvent(message)); } notifiedCount = 0; } @@ -58,7 +78,8 @@ void main() { assert(Set.of(messages.map((m) => m.id)).length == messages.length, 'checkMatchesMessages: duplicate messages in test input'); - final Map>> expectedStreams = {}; + final Map expectedLocatorMap = {}; + final Map>> expectedStreams = {}; final Map> expectedDms = {}; final Set expectedMentions = {}; for (final message in messages) { @@ -67,10 +88,12 @@ void main() { } switch (message) { case StreamMessage(): - final perTopic = expectedStreams[message.streamId] ??= {}; + expectedLocatorMap[message.id] = TopicNarrow.ofMessage(message); + final perTopic = expectedStreams[message.streamId] ??= makeTopicKeyedMap(); final messageIds = perTopic[message.topic] ??= QueueList(); messageIds.add(message.id); case DmMessage(): + expectedLocatorMap[message.id] = DmNarrow.ofMessage(message, selfUserId: store.selfUserId); final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); final messageIds = expectedDms[narrow] ??= QueueList(); messageIds.add(message.id); @@ -92,6 +115,7 @@ void main() { } check(model) + ..locatorMap.deepEquals(expectedLocatorMap) ..streams.deepEquals(expectedStreams) ..dms.deepEquals(expectedDms) ..mentions.unorderedEquals(expectedMentions); @@ -118,16 +142,19 @@ void main() { eg.unreadChannelMsgs(streamId: stream1.streamId, topic: 'b', unreadMessageIds: [3, 4]), eg.unreadChannelMsgs(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [5, 6]), eg.unreadChannelMsgs(streamId: stream2.streamId, topic: 'c', unreadMessageIds: [7, 8]), + + // TODO(server-10) drop this (see implementation) + eg.unreadChannelMsgs(streamId: stream2.streamId, topic: 'C', unreadMessageIds: [9, 10]), ], dms: [ - UnreadDmSnapshot(otherUserId: 1, unreadMessageIds: [9, 10]), - UnreadDmSnapshot(otherUserId: 2, unreadMessageIds: [11, 12]), + UnreadDmSnapshot(otherUserId: 1, unreadMessageIds: [11, 12]), + UnreadDmSnapshot(otherUserId: 2, unreadMessageIds: [13, 14]), ], huddles: [ - UnreadHuddleSnapshot(userIdsString: '1,2,${eg.selfUser.userId}', unreadMessageIds: [13, 14]), - UnreadHuddleSnapshot(userIdsString: '2,3,${eg.selfUser.userId}', unreadMessageIds: [15, 16]), + UnreadHuddleSnapshot(userIdsString: '1,2,${eg.selfUser.userId}', unreadMessageIds: [15, 16]), + UnreadHuddleSnapshot(userIdsString: '2,3,${eg.selfUser.userId}', unreadMessageIds: [17, 18]), ], - mentions: [6, 12, 16], + mentions: [6, 14, 18], oldUnreadsMissing: false, )); checkMatchesMessages([ @@ -139,14 +166,16 @@ void main() { eg.streamMessage(id: 6, stream: stream2, topic: 'b', flags: [MessageFlag.mentioned]), eg.streamMessage(id: 7, stream: stream2, topic: 'c', flags: []), eg.streamMessage(id: 8, stream: stream2, topic: 'c', flags: []), - eg.dmMessage(id: 9, from: user1, to: [eg.selfUser], flags: []), - eg.dmMessage(id: 10, from: user1, to: [eg.selfUser], flags: []), - eg.dmMessage(id: 11, from: user2, to: [eg.selfUser], flags: []), - eg.dmMessage(id: 12, from: user2, to: [eg.selfUser], flags: [MessageFlag.mentioned]), - eg.dmMessage(id: 13, from: user1, to: [user2, eg.selfUser], flags: []), - eg.dmMessage(id: 14, from: user1, to: [user2, eg.selfUser], flags: []), - eg.dmMessage(id: 15, from: user2, to: [user3, eg.selfUser], flags: []), - eg.dmMessage(id: 16, from: user2, to: [user3, eg.selfUser], flags: [MessageFlag.wildcardMentioned]), + eg.streamMessage(id: 9, stream: stream2, topic: 'C', flags: []), + eg.streamMessage(id: 10, stream: stream2, topic: 'C', flags: []), + eg.dmMessage(id: 11, from: user1, to: [eg.selfUser], flags: []), + eg.dmMessage(id: 12, from: user1, to: [eg.selfUser], flags: []), + eg.dmMessage(id: 13, from: user2, to: [eg.selfUser], flags: []), + eg.dmMessage(id: 14, from: user2, to: [eg.selfUser], flags: [MessageFlag.mentioned]), + eg.dmMessage(id: 15, from: user1, to: [user2, eg.selfUser], flags: []), + eg.dmMessage(id: 16, from: user1, to: [user2, eg.selfUser], flags: []), + eg.dmMessage(id: 17, from: user2, to: [user3, eg.selfUser], flags: []), + eg.dmMessage(id: 18, from: user2, to: [user3, eg.selfUser], flags: [MessageFlag.wildcardMentioned]), ]); }); }); @@ -157,11 +186,11 @@ void main() { final stream2 = eg.stream(); final stream3 = eg.stream(); prepare(); - await channelStore.addStreams([stream1, stream2, stream3]); - await channelStore.addSubscription(eg.subscription(stream1)); - await channelStore.addSubscription(eg.subscription(stream2)); - await channelStore.addSubscription(eg.subscription(stream3, isMuted: true)); - await channelStore.addUserTopic(stream1, 'a', UserTopicVisibilityPolicy.muted); + await store.addStreams([stream1, stream2, stream3]); + await store.addSubscription(eg.subscription(stream1)); + await store.addSubscription(eg.subscription(stream2)); + await store.addSubscription(eg.subscription(stream3, isMuted: true)); + await store.setUserTopic(stream1, 'a', UserTopicVisibilityPolicy.muted); fillWithMessages([ eg.streamMessage(stream: stream1, topic: 'a', flags: []), eg.streamMessage(stream: stream1, topic: 'b', flags: []), @@ -177,22 +206,22 @@ void main() { test('countInChannel/Narrow', () async { final stream = eg.stream(); prepare(); - await channelStore.addStream(stream); - await channelStore.addSubscription(eg.subscription(stream)); - await channelStore.addUserTopic(stream, 'a', UserTopicVisibilityPolicy.unmuted); - await channelStore.addUserTopic(stream, 'c', UserTopicVisibilityPolicy.muted); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await store.setUserTopic(stream, 'a', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream, 'c', UserTopicVisibilityPolicy.muted); fillWithMessages([ eg.streamMessage(stream: stream, topic: 'a', flags: []), - eg.streamMessage(stream: stream, topic: 'a', flags: []), - eg.streamMessage(stream: stream, topic: 'b', flags: []), + eg.streamMessage(stream: stream, topic: 'A', flags: []), eg.streamMessage(stream: stream, topic: 'b', flags: []), eg.streamMessage(stream: stream, topic: 'b', flags: []), + eg.streamMessage(stream: stream, topic: 'B', flags: []), eg.streamMessage(stream: stream, topic: 'c', flags: []), ]); check(model.countInChannel (stream.streamId)).equals(5); check(model.countInChannelNarrow(stream.streamId)).equals(5); - await channelStore.handleEvent(SubscriptionUpdateEvent(id: 1, + await store.handleEvent(SubscriptionUpdateEvent(id: 1, streamId: stream.streamId, property: SubscriptionProperty.isMuted, value: true)); check(model.countInChannel (stream.streamId)).equals(2); @@ -202,9 +231,13 @@ void main() { test('countInTopicNarrow', () { final stream = eg.stream(); prepare(); - fillWithMessages(List.generate(7, (i) => eg.streamMessage( - stream: stream, topic: 'a', flags: []))); - check(model.countInTopicNarrow(stream.streamId, eg.t('a'))).equals(7); + final messages = [ + ...List.generate(7, (i) => eg.streamMessage(stream: stream, topic: 'a', flags: [])), + ...List.generate(2, (i) => eg.streamMessage(stream: stream, topic: 'A', flags: [])), + ]; + fillWithMessages(messages); + check(model.countInTopicNarrow(stream.streamId, eg.t('a'))).equals(9); + check(model.countInTopicNarrow(stream.streamId, eg.t('A'))).equals(9); }); test('countInDmNarrow', () { @@ -219,7 +252,7 @@ void main() { test('countInMentionsNarrow', () async { final stream = eg.stream(); prepare(); - await channelStore.addStream(stream); + await store.addStream(stream); fillWithMessages([ eg.streamMessage(stream: stream, flags: []), eg.streamMessage(stream: stream, flags: [MessageFlag.mentioned]), @@ -231,7 +264,7 @@ void main() { test('countInStarredMessagesNarrow', () async { final stream = eg.stream(); prepare(); - await channelStore.addStream(stream); + await store.addStream(stream); fillWithMessages([ eg.streamMessage(stream: stream, flags: []), eg.streamMessage(stream: stream, flags: [MessageFlag.starred]), @@ -243,12 +276,12 @@ void main() { group('isUnread', () { final unreadDmMessage = eg.dmMessage( from: eg.otherUser, to: [eg.selfUser], flags: []); + final unreadChannelMessage = eg.streamMessage(flags: []); final readDmMessage = eg.dmMessage( from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.read]); - final unreadChannelMessage = eg.streamMessage(flags: []); final readChannelMessage = eg.streamMessage(flags: [MessageFlag.read]); - final allMessages = [ + final allMessages = [ unreadDmMessage, unreadChannelMessage, readDmMessage, readChannelMessage, ]; @@ -315,10 +348,10 @@ void main() { if (isDirectMentioned) MessageFlag.mentioned, if (isWildcardMentioned) MessageFlag.wildcardMentioned, ]; - final message = isStream + final Message message = isStream ? eg.streamMessage(flags: flags) : eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: flags); - model.handleMessageEvent(MessageEvent(id: 0, message: message)); + model.handleMessageEvent(eg.messageEvent(message)); if (isUnread) { checkNotifiedOnce(); } @@ -346,12 +379,30 @@ void main() { prepare(); fillWithMessages([oldMessage]); - model.handleMessageEvent(MessageEvent(id: 0, message: newMessage)); + model.handleMessageEvent(eg.messageEvent(newMessage)); checkNotifiedOnce(); checkMatchesMessages([oldMessage, newMessage]); }); } }); + + test('topics case-insensitive but case-preserving', () { + final stream = eg.stream(); + final message1 = eg.streamMessage(stream: stream, topic: 'aaa'); + final message2 = eg.streamMessage(stream: stream, topic: 'AaA'); + final message3 = eg.streamMessage(stream: stream, topic: 'aAa'); + prepare(); + fillWithMessages([message1]); + model.handleMessageEvent(eg.messageEvent(message2)); + model.handleMessageEvent(eg.messageEvent(message3)); + checkNotified(count: 2); + checkMatchesMessages([message1, message2, message3]); + // Redundant with checkMatchesMessages, but for explicitness here: + check(model).streams.values.single + .entries.single + ..key.equals(eg.t('aaa')) + ..value.length.equals(3); + }); }); group('DM messages', () { @@ -369,7 +420,7 @@ void main() { final message = eg.dmMessage(from: from, to: to, flags: []); prepare(); - model.handleMessageEvent(MessageEvent(id: 0, message: message)); + model.handleMessageEvent(eg.messageEvent(message)); checkNotifiedOnce(); checkMatchesMessages([message]); }); @@ -387,7 +438,7 @@ void main() { test('existing in $oldDesc narrow; new in ${oldNarrow == newNarrow ? 'same narrow' : 'different narrow ($newDesc)'}', () { prepare(); fillWithMessages([oldMessage]); - model.handleMessageEvent(MessageEvent(id: 0, message: newMessage)); + model.handleMessageEvent(eg.messageEvent(newMessage)); checkNotifiedOnce(); checkMatchesMessages([oldMessage, newMessage]); }); @@ -402,7 +453,7 @@ void main() { for (final isKnownToModel in [true, false]) { for (final isRead in [false, true]) { final baseFlags = [if (isRead) MessageFlag.read]; - for (final (messageDesc, message) in [ + for (final (messageDesc, message) in <(String, Message)>[ ('stream', eg.streamMessage(flags: baseFlags)), ('1:1 dm', eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: baseFlags)), ]) { @@ -469,6 +520,207 @@ void main() { } } }); + + group('moves', () { + final origChannel = eg.stream(); + const origTopic = 'origTopic'; + const newTopic = 'newTopic'; + + late List readMessages; + late List unreadMessages; + + Future prepareStore() async { + prepare(); + await store.addStream(origChannel); + await store.addSubscription(eg.subscription(origChannel)); + unreadMessages = List.generate(10, + (_) => eg.streamMessage(stream: origChannel, topic: origTopic)); + readMessages = List.generate(10, + (_) => eg.streamMessage(stream: origChannel, topic: origTopic, + flags: [MessageFlag.read])); + } + + List copyMessagesWith(Iterable messages, { + ZulipStream? newChannel, + String? newTopic, + }) { + assert(newChannel != null || newTopic != null); + return messages.map((message) => StreamMessage.fromJson( + message.toJson() + ..['stream_id'] = newChannel?.streamId ?? message.streamId + ..['subject'] = newTopic ?? message.topic + )).toList(); + } + + test('moved messages = unread messages', () async { + await prepareStore(); + final newChannel = eg.stream(); + await store.addStream(newChannel); + await store.addSubscription(eg.subscription(newChannel)); + fillWithMessages(unreadMessages); + final originalMessageIds = + model.streams[origChannel.streamId]![TopicName(origTopic)]!; + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: unreadMessages, + newStreamId: newChannel.streamId, + newTopicStr: newTopic)); + checkNotifiedOnce(); + checkMatchesMessages(copyMessagesWith(unreadMessages, + newChannel: newChannel, newTopic: newTopic)); + final newMessageIds = + model.streams[newChannel.streamId]![TopicName(newTopic)]!; + // Check we successfully avoided making a copy of the list. + check(originalMessageIds).identicalTo(newMessageIds); + }); + + test('moved messages ⊂ read messages', () async { + await prepareStore(); + final messagesToMove = readMessages.take(2).toList(); + fillWithMessages(unreadMessages + readMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: messagesToMove, + newTopicStr: newTopic)); + checkNotNotified(); + checkMatchesMessages(unreadMessages); + }); + + test('moved messages ⊂ unread messages', () async { + await prepareStore(); + final messagesToMove = unreadMessages.take(2).toList(); + fillWithMessages(unreadMessages + readMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: messagesToMove, + newTopicStr: newTopic)); + checkNotifiedOnce(); + checkMatchesMessages([ + ...copyMessagesWith(messagesToMove, newTopic: newTopic), + ...unreadMessages.skip(2), + ]); + }); + + test('moved messages ∩ unread messages ≠ Ø, moved messages ∩ read messages ≠ Ø, moved messages ⊅ unread messages', () async { + await prepareStore(); + final messagesToMove = [unreadMessages.first, readMessages.first]; + fillWithMessages(unreadMessages + readMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: messagesToMove, + newTopicStr: newTopic)); + checkNotifiedOnce(); + checkMatchesMessages([ + ...copyMessagesWith(unreadMessages.take(1), newTopic: newTopic), + ...unreadMessages.skip(1), + ]); + }); + + test('moved messages ⊃ unread messages', () async { + await prepareStore(); + final messagesToMove = unreadMessages + readMessages.take(2).toList(); + fillWithMessages(unreadMessages + readMessages); + final originalMessageIds = + model.streams[origChannel.streamId]![TopicName(origTopic)]!; + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: messagesToMove, + newTopicStr: newTopic)); + checkNotifiedOnce(); + checkMatchesMessages(copyMessagesWith(unreadMessages, newTopic: newTopic)); + final newMessageIds = + model.streams[origChannel.streamId]![TopicName(newTopic)]!; + // Check we successfully avoided making a copy of the list. + check(originalMessageIds).identicalTo(newMessageIds); + }); + + test('moving to unsubscribed channels drops the unreads', () async { + await prepareStore(); + final unsubscribedChannel = eg.stream(); + await store.addStream(unsubscribedChannel); + assert(!store.subscriptions.containsKey( + unsubscribedChannel.streamId)); + fillWithMessages(unreadMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: unreadMessages, + newStreamId: unsubscribedChannel.streamId)); + checkNotifiedOnce(); + checkMatchesMessages([]); + }); + + test('tolerates unsorted messages', () async { + await prepareStore(); + final unreadMessages = List.generate(10, (i) => + eg.streamMessage(stream: origChannel, topic: origTopic)); + fillWithMessages(unreadMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: unreadMessages.reversed.toList(), + newTopicStr: newTopic)); + checkNotifiedOnce(); + checkMatchesMessages(copyMessagesWith(unreadMessages, newTopic: newTopic)); + }); + + test('topics case-insensitive but case-preserving', () async { + final message1 = eg.streamMessage(stream: origChannel, topic: 'aaa', flags: []); + final message2 = eg.streamMessage(stream: origChannel, topic: 'aaa', flags: []); + final messages = [message1, message2]; + await prepareStore(); + fillWithMessages(messages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + // 'AAA' finds the key 'aaa' + origMessages: copyMessagesWith([message1], newTopic: 'AAA'), + newTopicStr: 'bbb')); + checkNotifiedOnce(); + checkMatchesMessages([ + ...copyMessagesWith([message1], newTopic: 'bbb'), + message2, + ]); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: [message2], + // 'BBB' finds the key 'bbb' + newTopicStr: 'BBB')); + checkNotifiedOnce(); + checkMatchesMessages([ + ...copyMessagesWith([message1], newTopic: 'bbb'), + ...copyMessagesWith([message2], newTopic: 'BBB'), + ]); + // Redundant with checkMatchesMessages, but for explicitness here: + check(model).streams.values.single + .entries.single + ..key.equals(eg.t('bbb')) + ..value.length.equals(2); + }); + + test('tolerates unreads unknown to the model', () async { + await prepareStore(); + fillWithMessages(unreadMessages); + + final unknownChannel = eg.stream(); + assert(!store.streams.containsKey(unknownChannel.streamId)); + final unknownUnreadMessage = eg.streamMessage( + stream: unknownChannel, topic: origTopic); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: [unknownUnreadMessage], + newTopicStr: newTopic)); + checkNotNotified(); + checkMatchesMessages(unreadMessages); + }); + + test('message edit but no move', () async { + await prepareStore(); + fillWithMessages(unreadMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEditEvent( + unreadMessages.first)); + checkNotNotified(); + checkMatchesMessages(unreadMessages); + }); + }); }); @@ -493,7 +745,7 @@ void main() { final message13 = eg.streamMessage(id: 13, stream: stream2, topic: 'b', flags: []); final message14 = eg.streamMessage(id: 14, stream: stream2, topic: 'b', flags: [MessageFlag.mentioned]); - final messages = [ + final messages = [ message1, message2, message3, message4, message5, message6, message7, message8, message9, message10, message11, message12, message13, message14, @@ -504,6 +756,7 @@ void main() { fillWithMessages(messages); final expectedRemainingMessages = Set.of(messages); + assert(messages.any((m) => m.id == 14)); for (final message in messages) { final event = switch (message) { StreamMessage() => DeleteMessageEvent( @@ -511,7 +764,12 @@ void main() { messageType: MessageType.stream, messageIds: [message.id], streamId: message.streamId, - topic: message.topic, + topic: () { + if (message.id != 14) return message.topic; + final uppercase = message.topic.apiName.toUpperCase(); + assert(message.topic.apiName != uppercase); + return eg.t(uppercase); // exercise case-insensitivity of topics + }(), ), DmMessage() => DeleteMessageEvent( id: 0, @@ -680,7 +938,7 @@ void main() { // That case is indistinguishable from an unread that's unknown to // the model, so we get coverage for that case too. test('add flag: ${mentionFlag.name}', () { - final messages = [ + final messages = [ eg.streamMessage(flags: []), eg.streamMessage(flags: [MessageFlag.read]), eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: []), @@ -717,7 +975,7 @@ void main() { // That case is indistinguishable from an unread that's unknown to // the model, so we get coverage for that case too. test('remove flag: ${mentionFlag.name}', () { - final messages = [ + final messages = [ eg.streamMessage(flags: [mentionFlag]), eg.streamMessage(flags: [mentionFlag, MessageFlag.read]), eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: [mentionFlag]), @@ -756,7 +1014,7 @@ void main() { final message2 = eg.streamMessage(id: 2, flags: [MessageFlag.mentioned]); final message3 = eg.dmMessage(id: 3, from: eg.otherUser, to: [eg.selfUser], flags: []); final message4 = eg.dmMessage(id: 4, from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.wildcardMentioned]); - final messages = [message1, message2, message3, message4]; + final messages = [message1, message2, message3, message4]; prepare(); fillWithMessages([message1, message2, message3, message4]); @@ -805,7 +1063,7 @@ void main() { final message13 = eg.streamMessage(id: 13, stream: stream2, topic: 'b', flags: []); final message14 = eg.streamMessage(id: 14, stream: stream2, topic: 'b', flags: [MessageFlag.mentioned]); - final messages = [ + final messages = [ message1, message2, message3, message4, message5, message6, message7, message8, message9, message10, message11, message12, message13, message14, @@ -917,7 +1175,7 @@ void main() { final message13 = eg.streamMessage(id: 13, stream: stream2, topic: 'b', flags: [MessageFlag.read]); final message14 = eg.streamMessage(id: 14, stream: stream2, topic: 'b', flags: [MessageFlag.mentioned, MessageFlag.read]); - final messages = [ + final messages = [ message1, message2, message3, message4, message5, message6, message7, message8, message9, message10, message11, message12, message13, message14, diff --git a/test/model/user_group_test.dart b/test/model/user_group_test.dart new file mode 100644 index 0000000000..0741f53b95 --- /dev/null +++ b/test/model/user_group_test.dart @@ -0,0 +1,207 @@ +import 'package:checks/checks.dart'; +import 'package:test_api/scaffolding.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/model/user_group.dart'; + +import '../api/model/model_checks.dart'; +import '../example_data.dart' as eg; +import '../stdlib_checks.dart'; + +void main() { + List sorted(Iterable groups) { + return groups.toList()..sort((a, b) => a.id.compareTo(b.id)); + } + + void checkGroupsEqual(UserGroupStore store, Iterable expected) { + check(sorted(store.allGroups)).jsonEquals(expected); + } + + test('initialize', () { + final groups = [eg.userGroup(), eg.userGroup()]; + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUserGroups: groups)); + checkGroupsEqual(store, groups); + }); + + test('getGroup', () { + final group1 = eg.userGroup(); + final group2 = eg.userGroup(); + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUserGroups: [group1, group2])); + check(store.getGroup(group1.id)).jsonEquals(group1); + check(store.getGroup(group2.id)).jsonEquals(group2); + check(store.getGroup(eg.userGroup().id)).isNull(); + }); + + test('activeGroups, allGroups', () async { + final group1 = eg.userGroup(deactivated: false); + final group2 = eg.userGroup(deactivated: true); + final group3 = eg.userGroup(deactivated: false); + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUserGroups: [group1, group2, group3])); + check(sorted(store.allGroups)).jsonEquals([group1, group2, group3]); + check(sorted(store.activeGroups)).jsonEquals([group1, group3]); + + await store.handleEvent(UserGroupUpdateEvent(id: 1, groupId: group1.id, + data: UserGroupUpdateData(name: null, description: null, deactivated: true))); + check(sorted(store.activeGroups)).jsonEquals([group3]); + }); + + test('UserGroupAddEvent, UserGroupRemoveEvent', () async { + final group1 = eg.userGroup(); + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUserGroups: [group1])); + checkGroupsEqual(store, [group1]); + + final group2 = eg.userGroup(); + await store.handleEvent(UserGroupAddEvent(id: 1, group: group2)); + checkGroupsEqual(store, [group1, group2]); + + await store.handleEvent(UserGroupRemoveEvent(id: 2, groupId: group1.id)); + checkGroupsEqual(store, [group2]); + }); + + test('UserGroupUpdateEvent', () async { + final store = eg.store(); + final group = eg.userGroup( + name: 'a group', description: 'is a group', deactivated: false); + await store.handleEvent(UserGroupAddEvent(id: 1, group: group)); + checkGroupsEqual(store, [group]); + + // Handles all the properties being updated at once. + await store.handleEvent(UserGroupUpdateEvent(id: 2, groupId: group.id, + data: UserGroupUpdateData(name: 'revised group', + description: 'different description', deactivated: true))); + checkGroupsEqual(store, [{ + ...group.toJson(), + 'name': 'revised group', + 'description': 'different description', + 'deactivated': true, + }]); + + // Handles some properties being null, still updating the one that's present. + await store.handleEvent(UserGroupUpdateEvent(id: 2, groupId: group.id, + data: UserGroupUpdateData(name: null, + description: null, deactivated: false))); + checkGroupsEqual(store, [{ + ...group.toJson(), + 'name': 'revised group', + 'description': 'different description', + 'deactivated': false, + }]); + }); + + group('membership', () { + // These tests exercise membership via selfInGroupSetting, because that's + // the main interface the app uses to consume group membership. + + late PerAccountStore store; + + void prepare(List groups, {List? users}) { + store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUsers: users, realmUserGroups: groups)); + } + + bool isMember(UserGroup group) { + return store.selfInGroupSetting(GroupSettingValueNamed(group.id)); + } + + test('initial', () { + final groups = []; + groups.add(eg.userGroup(members: [eg.selfUser.userId])); + groups.add(eg.userGroup(members: [eg.user().userId])); + groups.add(eg.userGroup(directSubgroupIds: [groups[0].id])); + groups.add(eg.userGroup(directSubgroupIds: [groups[2].id])); + groups.add(eg.userGroup(directSubgroupIds: [groups[1].id])); + + prepare(groups); + check(groups.map(isMember)).deepEquals([ + true, + false, + true, + true, + false, + ]); + }); + + test('UserGroupEvent', () async { + final groups = List.generate(4, (_) => eg.userGroup()); + prepare(groups); + check(groups.map(isMember)).deepEquals([false, false, false, false]); + + // Add a membership. + await store.handleEvent(UserGroupAddMembersEvent(id: 0, + groupId: groups[0].id, userIds: [eg.selfUser.userId])); + check(groups.map(isMember)).deepEquals([true, false, false, false]); + + // Add a chain of transitive memberships. + await store.handleEvent(UserGroupAddSubgroupsEvent(id: 0, + groupId: groups[1].id, directSubgroupIds: [groups[0].id])); + check(groups.map(isMember)).deepEquals([true, true, false, false]); + await store.handleEvent(UserGroupAddSubgroupsEvent(id: 0, + groupId: groups[2].id, directSubgroupIds: [groups[1].id])); + check(groups.map(isMember)).deepEquals([true, true, true, false]); + + // Cut the middle link of the chain. + await store.handleEvent(UserGroupRemoveSubgroupsEvent(id: 0, + groupId: groups[1].id, directSubgroupIds: [groups[0].id])); + check(groups.map(isMember)).deepEquals([true, false, false, false]); + + // Restore the middle link; cut the bottom link. + await store.handleEvent(UserGroupAddSubgroupsEvent(id: 0, + groupId: groups[1].id, directSubgroupIds: [groups[0].id])); + check(groups.map(isMember)).deepEquals([true, true, true, false]); + await store.handleEvent(UserGroupRemoveMembersEvent(id: 0, + groupId: groups[0].id, userIds: [eg.selfUser.userId])); + check(groups.map(isMember)).deepEquals([false, false, false, false]); + }); + + test('RealmUserUpdateEvent', () async { + // This test uses the membership data structure directly, because + // selfInGroupSetting would only be affected if the self-user were + // deactivated, and in that case we wouldn't be getting an event. + + final user = eg.user(); + final group = eg.userGroup(members: [user.userId]); + prepare(users: [eg.selfUser, user], [group]); + check(store.getGroup(group.id)!.members).deepEquals([user.userId]); + + // An update to a random irrelevant field has no effect. + await store.handleEvent(RealmUserUpdateEvent(id: 0, + userId: user.userId, fullName: 'New Name')); + check(store.getGroup(group.id)!.members).deepEquals([user.userId]); + + // But deactivating the user removes them from groups. + await store.handleEvent(RealmUserUpdateEvent(id: 0, + userId: user.userId, isActive: false)); + check(store.getGroup(group.id)!.members).isEmpty(); + }); + }); + + test('various fields make it through', () async { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUserGroups: [ + eg.userGroup(id: 3, name: 'some group', description: 'this is a group', + isSystemGroup: true, deactivated: false), + ])); + await store.handleEvent(UserGroupAddEvent(id: 1, group: eg.userGroup( + id: 5, name: 'a different group', description: 'also a group', + isSystemGroup: false, deactivated: true))); + check(sorted(store.allGroups)).deepEquals(>[ + (it) => it.isA() + ..id.equals(3) + ..name.equals('some group') + ..description.equals('this is a group') + ..isSystemGroup.isTrue() + ..deactivated.isFalse(), + (it) => it.isA() + ..id.equals(5) + ..name.equals('a different group') + ..description.equals('also a group') + ..isSystemGroup.isFalse() + ..deactivated.isTrue(), + ]); + }); +} diff --git a/test/model/user_test.dart b/test/model/user_test.dart new file mode 100644 index 0000000000..6d5e5c2519 --- /dev/null +++ b/test/model/user_test.dart @@ -0,0 +1,253 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/model/user.dart'; + +import '../api/model/model_checks.dart'; +import '../example_data.dart' as eg; +import 'test_store.dart'; + +typedef StatusData = (String? statusText, String? emojiName, String? emojiCode, + String? reactionType); + +void main() { + group('userDisplayName', () { + test('on a known user', () async { + final user = eg.user(fullName: 'Some User'); + final store = eg.store(); + await store.addUser(user); + check(store.userDisplayName(user.userId)).equals('Some User'); + }); + + test('on an unknown user', () { + final store = eg.store(); + check(store.userDisplayName(eg.user().userId)).equals('(unknown user)'); + }); + }); + + group('senderDisplayName', () { + test('on a known user', () async { + final store = eg.store(); + final user = eg.user(fullName: 'Old Name'); + await store.addUser(user); + final message = eg.streamMessage(sender: user); + await store.addMessage(message); + check(store.senderDisplayName(message)).equals('Old Name'); + + // If the user's name changes, `store.senderDisplayName` should update... + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: user.userId, fullName: 'New Name')); + check(store.senderDisplayName(message)).equals('New Name'); + // ... even though the Message object itself still has the old name. + check(store.messages[message.id]!).senderFullName.equals('Old Name'); + }); + + test('on an unknown user', () async { + final store = eg.store(); + final message = eg.streamMessage(sender: eg.user(fullName: 'Some User')); + await store.addMessage(message); + // If the user is unknown, `store.senderDisplayName` should fall back + // to the name in the message... + check(store.senderDisplayName(message)).equals('Some User'); + // ... even though `store.userDisplayName` (with no message available + // for fallback) only has a generic fallback name. + check(store.userDisplayName(message.senderId)).equals('(unknown user)'); + }); + }); + + group('hasPassedWaitingPeriod', () { + final store = eg.store(initialSnapshot: + eg.initialSnapshot(realmWaitingPeriodThreshold: 2)); + + final testCases = [ + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 0, 10, 00), false), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1, 10, 00), false), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 09, 59), false), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 10, 00), true), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1000, 07, 00), true), + ]; + + for (final (String dateJoined, DateTime currentDate, bool hasPassedWaitingPeriod) in testCases) { + test('user joined at $dateJoined ${hasPassedWaitingPeriod ? 'has' : "hasn't"} ' + 'passed waiting period by $currentDate', () { + final user = eg.user(dateJoined: dateJoined); + check(store.hasPassedWaitingPeriod(user, byDate: currentDate)) + .equals(hasPassedWaitingPeriod); + }); + } + }); + + group('RealmUserUpdateEvent', () { + // TODO write more tests for handling RealmUserUpdateEvent + + test('deliveryEmail', () async { + final user = eg.user(deliveryEmail: 'a@mail.example'); + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUsers: [eg.selfUser, user])); + + User getUser() => store.getUser(user.userId)!; + + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: user.userId, + deliveryEmail: null)); + check(getUser()).deliveryEmail.equals('a@mail.example'); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: user.userId, + deliveryEmail: const JsonNullable(null))); + check(getUser()).deliveryEmail.isNull(); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: user.userId, + deliveryEmail: const JsonNullable('b@mail.example'))); + check(getUser()).deliveryEmail.equals('b@mail.example'); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: user.userId, + deliveryEmail: const JsonNullable('c@mail.example'))); + check(getUser()).deliveryEmail.equals('c@mail.example'); + }); + }); + + testWidgets('UserStatusEvent', (tester) async { + UserStatusChange userStatus(StatusData data) => UserStatusChange.fromJson({ + 'status_text': data.$1, + 'emoji_name': data.$2, + 'emoji_code': data.$3, + 'reaction_type': data.$4, + }); + + void checkUserStatus(UserStatus userStatus, StatusData expected) { + check(userStatus).text.equals(expected.$1); + + switch (expected) { + case (_, String emojiName, String emojiCode, String reactionType): + check(userStatus.emoji!) + ..emojiName.equals(emojiName) + ..emojiCode.equals(emojiCode) + ..reactionType.equals(ReactionType.fromApiValue(reactionType)); + default: + check(userStatus.emoji).isNull(); + } + } + + UserStatusEvent userStatusEvent(StatusData data, {required int userId}) => + UserStatusEvent( + id: 1, + userId: userId, + change: UserStatusChange.fromJson({ + 'status_text': data.$1, + 'emoji_name': data.$2, + 'emoji_code': data.$3, + 'reaction_type': data.$4, + })); + + final store = eg.store(initialSnapshot: eg.initialSnapshot( + userStatuses: { + 1: userStatus(('Busy', 'working_on_it', '1f6e0', 'unicode_emoji')), + 2: userStatus((null, 'calendar', '1f4c5', 'unicode_emoji')), + 3: userStatus(('Commuting', null, null, null)), + })); + checkUserStatus(store.getUserStatus(1), + ('Busy', 'working_on_it', '1f6e0', 'unicode_emoji')); + checkUserStatus(store.getUserStatus(2), + (null, 'calendar', '1f4c5', 'unicode_emoji')); + checkUserStatus(store.getUserStatus(3), + ('Commuting', null, null, null)); + check(store.getUserStatus(4))..text.isNull()..emoji.isNull(); + check(store.getUserStatus(5))..text.isNull()..emoji.isNull(); + + await store.handleEvent(userStatusEvent(userId: 1, + ('Out sick', 'sick', '1f912', 'unicode_emoji'))); + checkUserStatus(store.getUserStatus(1), + ('Out sick', 'sick', '1f912', 'unicode_emoji')); + + await store.handleEvent(userStatusEvent(userId: 2, + ('In a meeting', null, null, null))); + checkUserStatus(store.getUserStatus(2), + ('In a meeting', 'calendar', '1f4c5', 'unicode_emoji')); + + await store.handleEvent(userStatusEvent(userId: 3, + ('', 'bus', '1f68c', 'unicode_emoji'))); + checkUserStatus(store.getUserStatus(3), + (null, 'bus', '1f68c', 'unicode_emoji')); + + await store.handleEvent(userStatusEvent(userId: 4, + ('Vacationing', null, null, null))); + checkUserStatus(store.getUserStatus(4), + ('Vacationing', null, null, null)); + + await store.handleEvent(userStatusEvent(userId: 5, + ('Working remotely', '', '', ''))); + checkUserStatus(store.getUserStatus(5), + ('Working remotely', null, null, null)); + + await store.handleEvent(userStatusEvent(userId: 1, + ('', '', '', ''))); + checkUserStatus(store.getUserStatus(1), + (null, null, null, null)); + }); + + group('MutedUsersEvent', () { + testWidgets('smoke', (tester) async { + late PerAccountStore store; + + void checkDmConversationMuted(List otherUserIds, bool expected) { + final narrow = DmNarrow.withOtherUsers(otherUserIds, selfUserId: store.selfUserId); + check(store.shouldMuteDmConversation(narrow)).equals(expected); + } + + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final user3 = eg.user(userId: 3); + + store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUsers: [user1, user2, user3, eg.selfUser], + mutedUsers: [MutedUserItem(id: 2), MutedUserItem(id: 1)])); + check(store.isUserMuted(1)).isTrue(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isFalse(); + checkDmConversationMuted([1], true); + checkDmConversationMuted([1, 2], true); + checkDmConversationMuted([2, 3], false); + checkDmConversationMuted([1, 2, 3], false); + + await store.handleEvent(eg.mutedUsersEvent([2, 1, 3])); + check(store.isUserMuted(1)).isTrue(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isTrue(); + checkDmConversationMuted([1, 2, 3], true); + + await store.handleEvent(eg.mutedUsersEvent([2, 3])); + check(store.isUserMuted(1)).isFalse(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isTrue(); + checkDmConversationMuted([1], false); + checkDmConversationMuted([], false); + }); + + group('mightChangeShouldMuteDmConversation', () { + void doTest( + String description, + List before, + List after, + MutedUsersVisibilityEffect expected, + ) { + testWidgets(description, (tester) async { + final store = eg.store(); + await store.addUser(eg.selfUser); + await store.addUsers(before.map((id) => eg.user(userId: id))); + await store.setMutedUsers(before); + final event = eg.mutedUsersEvent(after); + check(store.mightChangeShouldMuteDmConversation(event)).equals(expected); + }); + } + + doTest('none', [1], [1], MutedUsersVisibilityEffect.none); + doTest('none (empty to empty)', [], [], MutedUsersVisibilityEffect.none); + doTest('muted', [1], [1, 2], MutedUsersVisibilityEffect.muted); + doTest('unmuted', [1, 2], [1], MutedUsersVisibilityEffect.unmuted); + doTest('mixed', [1, 2, 3], [1, 2, 4], MutedUsersVisibilityEffect.mixed); + doTest('mixed (all replaced)', [1], [2], MutedUsersVisibilityEffect.mixed); + }); + }); +} diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index 32d8254d6d..c4763b27ef 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; @@ -6,7 +5,6 @@ import 'package:checks/checks.dart'; import 'package:collection/collection.dart'; import 'package:fake_async/fake_async.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/material.dart' hide Notification; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; @@ -18,24 +16,15 @@ import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/display.dart'; +import 'package:zulip/notifications/open.dart'; import 'package:zulip/notifications/receive.dart'; -import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/color.dart'; -import 'package:zulip/widgets/home.dart'; -import 'package:zulip/widgets/message_list.dart'; -import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/theme.dart'; +import '../example_data.dart' as eg; import '../fake_async.dart'; import '../model/binding.dart'; -import '../example_data.dart' as eg; -import '../model/narrow_checks.dart'; -import '../stdlib_checks.dart'; import '../test_images.dart'; -import '../test_navigation.dart'; -import '../widgets/dialog_checks.dart'; -import '../widgets/message_list_checks.dart'; -import '../widgets/page_checks.dart'; MessageFcmMessage messageFcmMessage( Message zulipMessage, { @@ -114,7 +103,10 @@ void main() { return http.runWithClient(callback, httpClientFactory ?? () => fakeHttpClientGivingSuccess); } - Future init() async { + Future init({bool addSelfAccount = true}) async { + if (addSelfAccount) { + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + } addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; addTearDown(NotificationService.debugReset); @@ -218,8 +210,8 @@ void main() { NotificationChannelManager.kDefaultNotificationSound.resourceName; String fakeStoredUrl(String resourceName) => testBinding.androidNotificationHost.fakeStoredNotificationSoundUrl(resourceName); - String fakeResourceUrl(String resourceName) => - 'android.resource://com.zulip.flutter/raw/$resourceName'; + String fakeResourceUrl({required String resourceName, String? packageName}) => + 'android.resource://${packageName ?? eg.packageInfo().packageName}/raw/$resourceName'; test('on Android 28 (and lower) resource file is used for notification sound', () async { addTearDown(testBinding.reset); @@ -235,7 +227,30 @@ void main() { .isEmpty(); check(androidNotificationHost.takeCreatedChannels()) .single - .soundUrl.equals(fakeResourceUrl(defaultSoundResourceName)); + .soundUrl.equals(fakeResourceUrl(resourceName: defaultSoundResourceName)); + }); + + test('generates resource file URL from app package name', () async { + addTearDown(testBinding.reset); + final androidNotificationHost = testBinding.androidNotificationHost; + + testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.example.test'); + + // Force the default sound URL to be the resource file URL, by forcing + // the Android version to the one where we don't store sounds through the + // media store. + testBinding.deviceInfoResult = + const AndroidDeviceInfo(sdkInt: 28, release: '9'); + + await NotificationChannelManager.ensureChannel(); + check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()) + .isEmpty(); + check(androidNotificationHost.takeCreatedChannels()) + .single + .soundUrl.equals(fakeResourceUrl( + resourceName: defaultSoundResourceName, + packageName: 'com.example.test', + )); }); test('notification sound resource files are being copied to the media store', () async { @@ -323,7 +338,7 @@ void main() { .isEmpty(); check(androidNotificationHost.takeCreatedChannels()) .single - .soundUrl.equals(fakeResourceUrl(defaultSoundResourceName)); + .soundUrl.equals(fakeResourceUrl(resourceName: defaultSoundResourceName)); }); }); @@ -351,7 +366,7 @@ void main() { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); final messageStyleMessagesChecks = messageStyleMessages.mapIndexed((i, messageData) { @@ -872,7 +887,8 @@ void main() { }))); test('remove: different realm URLs but same user-ids and same message-ids', () => runWithHttpClient(() => awaitFakeAsync((async) async { - await init(); + await init(addSelfAccount: false); + final stream = eg.stream(); const topic = 'Some Topic'; final conversationKey = 'stream:${stream.streamId}:some topic'; @@ -881,6 +897,7 @@ void main() { realmUrl: Uri.parse('https://1.chat.example'), id: 1001, user: eg.user(userId: 1001)); + await testBinding.globalStore.add(account1, eg.initialSnapshot()); final message1 = eg.streamMessage(id: 1000, stream: stream, topic: topic); final data1 = messageFcmMessage(message1, account: account1, streamName: stream.name); @@ -890,6 +907,7 @@ void main() { realmUrl: Uri.parse('https://2.chat.example'), id: 1002, user: eg.user(userId: 1001)); + await testBinding.globalStore.add(account2, eg.initialSnapshot()); final message2 = eg.streamMessage(id: 1000, stream: stream, topic: topic); final data2 = messageFcmMessage(message2, account: account2, streamName: stream.name); @@ -917,19 +935,21 @@ void main() { }))); test('remove: different user-ids but same realm URL and same message-ids', () => runWithHttpClient(() => awaitFakeAsync((async) async { - await init(); + await init(addSelfAccount: false); 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); + await testBinding.globalStore.add(account1, eg.initialSnapshot()); final message1 = eg.streamMessage(id: 1000, stream: stream, topic: topic); final data1 = messageFcmMessage(message1, account: account1, streamName: stream.name); final groupKey1 = '${account1.realmUrl}|${account1.userId}'; final account2 = eg.account(id: 1002, user: eg.user(userId: 1002), realmUrl: realmUrl); + await testBinding.globalStore.add(account2, eg.initialSnapshot()); final message2 = eg.streamMessage(id: 1000, stream: stream, topic: topic); final data2 = messageFcmMessage(message2, account: account2, streamName: stream.name); @@ -955,376 +975,76 @@ void main() { receiveFcmMessage(async, removeFcmMessage([message2], account: account2)); check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); }))); - }); - group('NotificationDisplayManager open', () { - late List> pushedRoutes; - - void takeStartingRoutes({bool withAccount = true}) { - final expected = >[ - if (withAccount) - (it) => it.isA() - ..accountId.equals(eg.selfAccount.id) - ..page.isA() - else - (it) => it.isA().page.isA(), - ]; - check(pushedRoutes.take(expected.length)).deepEquals(expected); - pushedRoutes.removeRange(0, expected.length); - } - - Future prepare(WidgetTester tester, - {bool early = false, bool withAccount = true}) async { + test('removeNotificationsForAccount: removes notifications', () => runWithHttpClient(() => awaitFakeAsync((async) async { await init(); - pushedRoutes = []; - final testNavObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); - // This uses [ZulipApp] instead of [TestZulipApp] because notification - // logic uses `await ZulipApp.navigator`. - await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); - if (early) { - check(pushedRoutes).isEmpty(); - return; - } - await tester.pump(); - takeStartingRoutes(withAccount: withAccount); - check(pushedRoutes).isEmpty(); - } - - Future openNotification(WidgetTester tester, Account account, Message message) async { - final data = messageFcmMessage(message, account: account); - final intentDataUrl = NotificationOpenPayload( - realmUrl: data.realmUrl, - userId: data.userId, - narrow: switch (data.recipient) { - FcmMessageChannelRecipient(:var streamId, :var topic) => - TopicNarrow(streamId, topic), - FcmMessageDmRecipient(:var allRecipientIds) => - DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); - unawaited( - WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); - await tester.idle(); // let navigateForNotification find navigator - } - - void matchesNavigation(Subject> route, Account account, Message message) { - route.isA() - ..accountId.equals(account.id) - ..page.isA() - .initNarrow.equals(SendableNarrow.ofMessage(message, - selfUserId: account.userId)); - } - - Future checkOpenNotification(WidgetTester tester, Account account, Message message) async { - await openNotification(tester, account, message); - matchesNavigation(check(pushedRoutes).single, account, message); - pushedRoutes.clear(); - } - - testWidgets('stream message', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester); - await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); - }); - - testWidgets('direct message', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester); - await checkOpenNotification(tester, eg.selfAccount, - eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); - }); - - testWidgets('no accounts', (tester) async { - await prepare(tester, withAccount: false); - await openNotification(tester, eg.selfAccount, eg.streamMessage()); - await tester.pump(); - check(pushedRoutes.single).isA>(); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountMissing))); - }); - - testWidgets('mismatching account', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester); - await openNotification(tester, eg.otherAccount, eg.streamMessage()); - await tester.pump(); - check(pushedRoutes.single).isA>(); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountMissing))); - }); - - testWidgets('find account among several', (tester) async { - addTearDown(testBinding.reset); - final realmUrlA = Uri.parse('https://a-chat.example/'); - final realmUrlB = Uri.parse('https://chat-b.example/'); - final user1 = eg.user(); - final user2 = eg.user(); - final accounts = [ - eg.account(id: 1001, realmUrl: realmUrlA, user: user1), - eg.account(id: 1002, realmUrl: realmUrlA, user: user2), - eg.account(id: 1003, realmUrl: realmUrlB, user: user1), - eg.account(id: 1004, realmUrl: realmUrlB, user: user2), - ]; - for (final account in accounts) { - await testBinding.globalStore.add(account, eg.initialSnapshot()); - } - await prepare(tester); - - await checkOpenNotification(tester, accounts[0], eg.streamMessage()); - await checkOpenNotification(tester, accounts[1], eg.streamMessage()); - await checkOpenNotification(tester, accounts[2], eg.streamMessage()); - await checkOpenNotification(tester, accounts[3], eg.streamMessage()); - }); - - testWidgets('wait for app to become ready', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester, early: true); - final message = eg.streamMessage(); - await openNotification(tester, eg.selfAccount, message); - // The app should still not be ready (or else this test won't work right). - check(ZulipApp.ready.value).isFalse(); - check(ZulipApp.navigatorKey.currentState).isNull(); - // And the openNotification hasn't caused any navigation yet. - check(pushedRoutes).isEmpty(); - - // Now let the GlobalStore get loaded and the app's main UI get mounted. - await tester.pump(); - // The navigator first pushes the starting routes… - takeStartingRoutes(); - // … and then the one the notification leads to. - matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); - }); + final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); + receiveFcmMessage(async, messageFcmMessage(message)); + check(testBinding.androidNotificationHost.activeNotifications).isNotEmpty(); - testWidgets('at app launch', (tester) async { - addTearDown(testBinding.reset); - // Set up a value for `PlatformDispatcher.defaultRouteName` to return, - // for determining the intial route. - final account = eg.selfAccount; - final message = eg.streamMessage(); - final data = messageFcmMessage(message, account: account); - final intentDataUrl = NotificationOpenPayload( - realmUrl: data.realmUrl, - userId: data.userId, - narrow: switch (data.recipient) { - FcmMessageChannelRecipient(:var streamId, :var topic) => - TopicNarrow(streamId, topic), - FcmMessageDmRecipient(:var allRecipientIds) => - DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); - tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); - - // Now start the app. - await testBinding.globalStore.add(account, eg.initialSnapshot()); - await prepare(tester, early: true); - check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet - - // Once the app is ready, we navigate to the conversation. - await tester.pump(); - takeStartingRoutes(); - matchesNavigation(check(pushedRoutes).single, account, message); - }); - }); + await NotificationDisplayManager.removeNotificationsForAccount( + eg.selfAccount.realmUrl, eg.selfAccount.userId); + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + }))); - group('NotificationOpenPayload', () { - test('smoke round-trip', () { - // DM narrow - var payload = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), - ); - var url = payload.buildUrl(); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(payload.realmUrl) - ..userId.equals(payload.userId) - ..narrow.equals(payload.narrow); - - // Topic narrow - payload = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: eg.topicNarrow(1, 'topic A'), - ); - url = payload.buildUrl(); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(payload.realmUrl) - ..userId.equals(payload.userId) - ..narrow.equals(payload.narrow); - }); + test('removeNotificationsForAccount: leaves notifications for other accounts (same realm URL)', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(addSelfAccount: false); - test('buildUrl: smoke DM', () { - final url = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), - ).buildUrl(); - check(url) - ..scheme.equals('zulip') - ..host.equals('notification') - ..queryParameters.deepEquals({ - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }); - }); + final realmUrl = eg.realmUrl; + final account1 = eg.account(id: 1001, user: eg.user(userId: 1001), realmUrl: realmUrl); + final account2 = eg.account(id: 1002, user: eg.user(userId: 1002), realmUrl: realmUrl); + await testBinding.globalStore.add(account1, eg.initialSnapshot()); + await testBinding.globalStore.add(account2, eg.initialSnapshot()); - test('buildUrl: smoke topic', () { - final url = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: eg.topicNarrow(1, 'topic A'), - ).buildUrl(); - check(url) - ..scheme.equals('zulip') - ..host.equals('notification') - ..queryParameters.deepEquals({ - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - }); + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); - test('parse: smoke DM', () { - final url = Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(Uri.parse('http://chat.example')) - ..userId.equals(1001) - ..narrow.which((it) => it.isA() - ..allRecipientIds.deepEquals([1001, 1002]) - ..otherRecipientIds.deepEquals([1002])); - }); + final message1 = eg.streamMessage(); + final message2 = eg.streamMessage(); + receiveFcmMessage(async, messageFcmMessage(message1, account: account1)); + receiveFcmMessage(async, messageFcmMessage(message2, account: account2)); + check(testBinding.androidNotificationHost.activeNotifications) + .length.equals(4); + + await NotificationDisplayManager.removeNotificationsForAccount( + realmUrl, account1.userId); + check(testBinding.androidNotificationHost.activeNotifications) + ..length.equals(2) + ..first.notification.group.equals('$realmUrl|${account2.userId}'); + }))); - test('parse: smoke topic', () { - final url = Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(Uri.parse('http://chat.example')) - ..userId.equals(1001) - ..narrow.which((it) => it.isA() - ..streamId.equals(1) - ..topic.equals(eg.t('topic A'))); - }); + test('removeNotificationsForAccount leaves notifications for other accounts (same user-ids)', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(addSelfAccount: false); - test('parse: fails when missing any expected query parameters', () { - final testCases = >[ - { - // 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - // 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - // 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - // 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - // 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - // 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - // 'all_recipient_ids': '1001,1002', - }, - ]; - for (final params in testCases) { - check(() => NotificationOpenPayload.parseUrl(Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: params, - ))) - // Missing 'realm_url', 'user_id' and 'narrow_type' - // throws 'FormatException'. - // Missing 'channel_id', 'topic', when narrow_type == 'topic' - // throws 'TypeError'. - // Missing 'all_recipient_ids', when narrow_type == 'dm' - // throws 'TypeError'. - .throws(); - } - }); + final userId = 1001; + final account1 = eg.account( + id: 1001, user: eg.user(userId: userId), + realmUrl: Uri.parse('https://realm1.example')); + final account2 = eg.account( + id: 1002, user: eg.user(userId: userId), + realmUrl: Uri.parse('https://realm2.example')); + await testBinding.globalStore.add(account1, eg.initialSnapshot()); + await testBinding.globalStore.add(account2, eg.initialSnapshot()); + + final message1 = eg.streamMessage(); + final message2 = eg.streamMessage(); + receiveFcmMessage(async, messageFcmMessage(message1, account: account1)); + receiveFcmMessage(async, messageFcmMessage(message2, account: account2)); + check(testBinding.androidNotificationHost.activeNotifications) + .length.equals(4); + + await NotificationDisplayManager.removeNotificationsForAccount(account1.realmUrl, userId); + check(testBinding.androidNotificationHost.activeNotifications) + ..length.equals(2) + ..first.notification.group.equals('${account2.realmUrl}|$userId'); + }))); - test('parse: fails when scheme is not "zulip"', () { - final url = Uri( - scheme: 'http', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(() => NotificationOpenPayload.parseUrl(url)) - .throws(); - }); + test('removeNotificationsForAccount does nothing if there are no notifications', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(); + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); - test('parse: fails when host is not "notification"', () { - final url = Uri( - scheme: 'zulip', - host: 'example', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(() => NotificationOpenPayload.parseUrl(url)) - .throws(); - }); + await NotificationDisplayManager.removeNotificationsForAccount(eg.selfAccount.realmUrl, eg.selfAccount.userId); + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + }))); }); } @@ -1405,9 +1125,3 @@ extension on Subject { Subject get notification => has((x) => x.notification, 'notification'); Subject get tag => has((x) => x.tag, 'tag'); } - -extension on Subject { - Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); - Subject get userId => has((x) => x.userId, 'userId'); - Subject get narrow => has((x) => x.narrow, 'narrow'); -} diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart new file mode 100644 index 0000000000..ef034f29d6 --- /dev/null +++ b/test/notifications/open_test.dart @@ -0,0 +1,624 @@ +import 'dart:async'; + +import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/notifications.dart'; +import 'package:zulip/host/notifications.dart'; +import 'package:zulip/model/database.dart'; +import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/notifications/open.dart'; +import 'package:zulip/notifications/receive.dart'; +import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/home.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; + +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/narrow_checks.dart'; +import '../model/store_checks.dart'; +import '../stdlib_checks.dart'; +import '../test_navigation.dart'; +import '../widgets/checks.dart'; +import '../widgets/dialog_checks.dart'; +import 'display_test.dart'; + +Map messageApnsPayload( + Message zulipMessage, { + String? streamName, + Account? account, +}) { + account ??= eg.selfAccount; + return { + "aps": { + "alert": { + "title": "test", + "subtitle": "test", + "body": zulipMessage.content, + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulip.example.cloud", + "realm_id": 4, + "realm_uri": account.realmUrl.toString(), + "realm_url": account.realmUrl.toString(), + "realm_name": "Test", + "user_id": account.userId, + "sender_id": zulipMessage.senderId, + "sender_email": zulipMessage.senderEmail, + "time": zulipMessage.timestamp, + "message_ids": [zulipMessage.id], + ...(switch (zulipMessage) { + StreamMessage(:var streamId, :var topic) => { + "recipient_type": "stream", + "stream_id": streamId, + if (streamName != null) "stream": streamName, + "topic": topic, + }, + DmMessage(allRecipientIds: [_, _, _, ...]) => { + "recipient_type": "private", + "pm_users": zulipMessage.allRecipientIds.join(","), + }, + DmMessage() => {"recipient_type": "private"}, + }), + }, + }; +} + +void main() { + TestZulipBinding.ensureInitialized(); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + Future init() async { + addTearDown(testBinding.reset); + testBinding.firebaseMessagingInitialToken = '012abc'; + addTearDown(NotificationService.debugReset); + NotificationService.debugBackgroundIsolateIsLive = false; + await NotificationService.instance.start(); + } + + group('NotificationOpenService', () { + late List> pushedRoutes; + + void takeHomePageRouteForAccount(int accountId) { + check(pushedRoutes).first.which( + (it) => it.isA() + ..accountId.equals(accountId) + ..page.isA()); + pushedRoutes.removeAt(0); + } + + void takeChooseAccountPageRoute() { + check(pushedRoutes).first.which( + (it) => it.isA().page.isA()); + pushedRoutes.removeAt(0); + } + + Future prepare(WidgetTester tester, {bool early = false}) async { + await init(); + pushedRoutes = []; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + // This uses [ZulipApp] instead of [TestZulipApp] because notification + // logic uses `await ZulipApp.navigator`. + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + if (early) { + check(pushedRoutes).isEmpty(); + return; + } + await tester.pump(); + final lastVisitedAccountId = testBinding.globalStore.lastVisitedAccount?.id; + if (lastVisitedAccountId == null) { + takeChooseAccountPageRoute(); + } else { + takeHomePageRouteForAccount(lastVisitedAccountId); + } + check(pushedRoutes).isEmpty(); + } + + Uri androidNotificationUrlForMessage(Account account, Message message) { + final data = messageFcmMessage(message, account: account); + return NotificationOpenPayload( + realmUrl: data.realmUrl, + userId: data.userId, + narrow: switch (data.recipient) { + FcmMessageChannelRecipient(:var streamId, :var topic) => + TopicNarrow(streamId, topic), + FcmMessageDmRecipient(:var allRecipientIds) => + DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), + }).buildAndroidNotificationUrl(); + } + + Future openNotification(WidgetTester tester, Account account, Message message) async { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + final intentDataUrl = androidNotificationUrlForMessage(account, message); + unawaited( + WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); + await tester.idle(); // let navigateForNotification find navigator + + case TargetPlatform.iOS: + final payload = messageApnsPayload(message, account: account); + testBinding.notificationPigeonApi.addNotificationTapEvent( + NotificationTapEvent(payload: payload)); + await tester.idle(); // let navigateForNotification find navigator + + default: + throw UnsupportedError('Unsupported target platform: "$defaultTargetPlatform"'); + } + } + + void setupNotificationDataForLaunch(WidgetTester tester, Account account, Message message) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + // Set up a value for `PlatformDispatcher.defaultRouteName` to return, + // for determining the initial route. + final intentDataUrl = androidNotificationUrlForMessage(account, message); + addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); + tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); + + case TargetPlatform.iOS: + // Set up a value to return for + // `notificationPigeonApi.getNotificationDataFromLaunch`. + final payload = messageApnsPayload(message, account: account); + testBinding.notificationPigeonApi.setNotificationDataFromLaunch( + NotificationDataFromLaunch(payload: payload)); + + default: + throw UnsupportedError('Unsupported target platform: "$defaultTargetPlatform"'); + } + } + + void matchesNavigation(Subject> route, Account account, Message message) { + route.isA() + ..accountId.equals(account.id) + ..page.isA() + .initNarrow.equals(SendableNarrow.ofMessage(message, + selfUserId: account.userId)); + } + + Future checkOpenNotification(WidgetTester tester, Account account, Message message) async { + await openNotification(tester, account, message); + matchesNavigation(check(pushedRoutes).single, account, message); + pushedRoutes.clear(); + } + + testWidgets('stream message', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester); + await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('direct message', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester); + await checkOpenNotification(tester, eg.selfAccount, + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('account queried by realmUrl origin component', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add( + eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), + eg.initialSnapshot()); + await prepare(tester); + + await checkOpenNotification(tester, + eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example/')), + eg.streamMessage()); + await checkOpenNotification(tester, + eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), + eg.streamMessage()); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('no accounts', (tester) async { + await prepare(tester); + // (just to make sure the test is working) + check(testBinding.globalStore.accountIds).isEmpty(); + await openNotification(tester, eg.selfAccount, eg.streamMessage()); + await tester.pump(); + check(pushedRoutes.single).isA>(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorNotificationOpenTitle, + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('mismatching account', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester); + await openNotification(tester, eg.otherAccount, eg.streamMessage()); + await tester.pump(); + check(pushedRoutes.single).isA>(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorNotificationOpenTitle, + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('find account among several', (tester) async { + addTearDown(testBinding.reset); + final realmUrlA = Uri.parse('https://a-chat.example/'); + final realmUrlB = Uri.parse('https://chat-b.example/'); + final user1 = eg.user(); + final user2 = eg.user(); + final accounts = [ + eg.account(id: 1001, realmUrl: realmUrlA, user: user1), + eg.account(id: 1002, realmUrl: realmUrlA, user: user2), + eg.account(id: 1003, realmUrl: realmUrlB, user: user1), + eg.account(id: 1004, realmUrl: realmUrlB, user: user2), + ]; + await testBinding.globalStore.add( + accounts[0], eg.initialSnapshot(realmUsers: [user1])); + await testBinding.globalStore.add( + accounts[1], eg.initialSnapshot(realmUsers: [user2])); + await testBinding.globalStore.add( + accounts[2], eg.initialSnapshot(realmUsers: [user1])); + await testBinding.globalStore.add( + accounts[3], eg.initialSnapshot(realmUsers: [user2])); + await prepare(tester); + + await checkOpenNotification(tester, accounts[0], eg.streamMessage()); + await checkOpenNotification(tester, accounts[1], eg.streamMessage()); + await checkOpenNotification(tester, accounts[2], eg.streamMessage()); + await checkOpenNotification(tester, accounts[3], eg.streamMessage()); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('wait for app to become ready', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester, early: true); + final message = eg.streamMessage(); + await openNotification(tester, eg.selfAccount, message); + // The app should still not be ready (or else this test won't work right). + check(ZulipApp.ready.value).isFalse(); + check(ZulipApp.navigatorKey.currentState).isNull(); + // And the openNotification hasn't caused any navigation yet. + check(pushedRoutes).isEmpty(); + + // Now let the GlobalStore get loaded and the app's main UI get mounted. + await tester.pump(); + // The navigator first pushes the starting routes… + takeHomePageRouteForAccount(eg.selfAccount.id); // because last-visited + // … and then the one the notification leads to. + matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('at app launch', (tester) async { + addTearDown(testBinding.reset); + final account = eg.selfAccount; + final message = eg.streamMessage(); + setupNotificationDataForLaunch(tester, account, message); + + // Now start the app. + await testBinding.globalStore.add(account, eg.initialSnapshot()); + await prepare(tester, early: true); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + // Once the app is ready, we navigate to the conversation. + await tester.pump(); + takeHomePageRouteForAccount(account.id); // because associated account + matchesNavigation(check(pushedRoutes).single, account, message); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('uses associated account as initial account; if initial route', (tester) async { + addTearDown(testBinding.reset); + + final accountA = eg.selfAccount; + final accountB = eg.otherAccount; + final message = eg.streamMessage(); + await testBinding.globalStore.add(accountA, eg.initialSnapshot()); + await testBinding.globalStore.add(accountB, eg.initialSnapshot( + realmUsers: [eg.otherUser])); + setupNotificationDataForLaunch(tester, accountB, message); + + await prepare(tester, early: true); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + await tester.pump(); + takeHomePageRouteForAccount(accountB.id); // because associated account + matchesNavigation(check(pushedRoutes).single, accountB, message); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + group('changes last visited account', () { + testWidgets('app already opened, then notification is opened', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add( + eg.selfAccount, eg.initialSnapshot(realmUsers: [eg.selfUser])); + await testBinding.globalStore.add( + eg.otherAccount, eg.initialSnapshot(realmUsers: [eg.otherUser]), + markLastVisited: false); + await prepare(tester); + check(testBinding.globalStore).lastVisitedAccount.equals(eg.selfAccount); + + await checkOpenNotification(tester, eg.otherAccount, eg.streamMessage()); + check(testBinding.globalStore).lastVisitedAccount.equals(eg.otherAccount); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('app is opened through notification', (tester) async { + addTearDown(testBinding.reset); + + final accountA = eg.selfAccount; + final accountB = eg.otherAccount; + final message = eg.streamMessage(); + await testBinding.globalStore.add(accountA, eg.initialSnapshot()); + await testBinding.globalStore.add( + accountB, eg.initialSnapshot(realmUsers: [eg.otherUser]), + markLastVisited: false); + check(testBinding.globalStore).lastVisitedAccount.equals(accountA); + setupNotificationDataForLaunch(tester, accountB, message); + + await prepare(tester, early: true); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + await tester.pump(); + takeHomePageRouteForAccount(accountB.id); // because associated account + matchesNavigation(check(pushedRoutes).single, accountB, message); + check(testBinding.globalStore).lastVisitedAccount.equals(accountB); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + }); + }); + + group('NotificationOpenPayload', () { + test('android: smoke round-trip', () { + // DM narrow + var payload = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), + ); + var url = payload.buildAndroidNotificationUrl(); + check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + ..realmUrl.equals(payload.realmUrl) + ..userId.equals(payload.userId) + ..narrow.equals(payload.narrow); + + // Topic narrow + payload = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: eg.topicNarrow(1, 'topic A'), + ); + url = payload.buildAndroidNotificationUrl(); + check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + ..realmUrl.equals(payload.realmUrl) + ..userId.equals(payload.userId) + ..narrow.equals(payload.narrow); + }); + + group('parseIosApnsPayload', () { + test('smoke one-one DM', () { + final userA = eg.user(userId: 1001); + final userB = eg.user(userId: 1002); + final account = eg.account( + realmUrl: Uri.parse('http://chat.example'), + user: userA); + final payload = messageApnsPayload(eg.dmMessage(from: userB, to: [userA]), + account: account); + check(NotificationOpenPayload.parseIosApnsPayload(payload)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..otherRecipientIds.deepEquals([1002])); + }); + + test('smoke group DM', () { + final userA = eg.user(userId: 1001); + final userB = eg.user(userId: 1002); + final userC = eg.user(userId: 1003); + final account = eg.account( + realmUrl: Uri.parse('http://chat.example'), + user: userA); + final payload = messageApnsPayload(eg.dmMessage(from: userC, to: [userA, userB]), + account: account); + check(NotificationOpenPayload.parseIosApnsPayload(payload)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..otherRecipientIds.deepEquals([1002, 1003])); + }); + + test('smoke topic message', () { + final userA = eg.user(userId: 1001); + final account = eg.account( + realmUrl: Uri.parse('http://chat.example'), + user: userA); + final payload = messageApnsPayload(eg.streamMessage( + stream: eg.stream(streamId: 1), + topic: 'topic A'), + account: account); + check(NotificationOpenPayload.parseIosApnsPayload(payload)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..streamId.equals(1) + ..topic.equals(TopicName('topic A'))); + }); + }); + + group('buildAndroidNotificationUrl', () { + test('smoke DM', () { + final url = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), + ).buildAndroidNotificationUrl(); + check(url) + ..scheme.equals('zulip') + ..host.equals('notification') + ..queryParameters.deepEquals({ + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'dm', + 'all_recipient_ids': '1001,1002', + }); + }); + + test('smoke topic', () { + final url = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: eg.topicNarrow(1, 'topic A'), + ).buildAndroidNotificationUrl(); + check(url) + ..scheme.equals('zulip') + ..host.equals('notification') + ..queryParameters.deepEquals({ + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + }); + }); + + group('parseAndroidNotificationUrl', () { + test('smoke DM', () { + final url = Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'dm', + 'all_recipient_ids': '1001,1002', + }); + check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..allRecipientIds.deepEquals([1001, 1002]) + ..otherRecipientIds.deepEquals([1002])); + }); + + test('smoke topic', () { + final url = Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..streamId.equals(1) + ..topic.equals(eg.t('topic A'))); + }); + + test('fails when missing any expected query parameters', () { + final testCases = >[ + { + // 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + // 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + // 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + // 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + // 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + // 'narrow_type': 'dm', + 'all_recipient_ids': '1001,1002', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'dm', + // 'all_recipient_ids': '1001,1002', + }, + ]; + for (final params in testCases) { + check(() => NotificationOpenPayload.parseAndroidNotificationUrl(Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: params, + ))) + // Missing 'realm_url', 'user_id' and 'narrow_type' + // throws 'FormatException'. + // Missing 'channel_id', 'topic', when narrow_type == 'topic' + // throws 'TypeError'. + // Missing 'all_recipient_ids', when narrow_type == 'dm' + // throws 'TypeError'. + .throws(); + } + }); + + test('fails when scheme is not "zulip"', () { + final url = Uri( + scheme: 'http', + host: 'notification', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + check(() => NotificationOpenPayload.parseAndroidNotificationUrl(url)) + .throws(); + }); + + test('fails when host is not "notification"', () { + final url = Uri( + scheme: 'zulip', + host: 'example', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + check(() => NotificationOpenPayload.parseAndroidNotificationUrl(url)) + .throws(); + }); + }); + }); +} + +extension on Subject { + Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); + Subject get userId => has((x) => x.userId, 'userId'); + Subject get narrow => has((x) => x.narrow, 'narrow'); +} diff --git a/test/stdlib_checks.dart b/test/stdlib_checks.dart index 2d55ba0c37..28b5f5e5f2 100644 --- a/test/stdlib_checks.dart +++ b/test/stdlib_checks.dart @@ -5,6 +5,7 @@ /// part of the Dart standard library. library; +import 'dart:async'; import 'dart:convert'; import 'package:checks/checks.dart'; @@ -15,6 +16,11 @@ extension ListChecks on Subject> { Subject operator [](int index) => has((l) => l[index], '[$index]'); } +extension MapEntryChecks on Subject> { + Subject get key => has((e) => e.key, 'key'); + Subject get value => has((e) => e.value, 'value'); +} + extension NullableMapChecks on Subject?> { void deepEquals(Map? expected) { if (expected == null) { @@ -25,6 +31,10 @@ extension NullableMapChecks on Subject?> { } } +extension ErrorChecks on Subject { + Subject get asString => has((x) => x.toString(), 'toString'); // TODO(checks): what's a good convention for this? +} + /// Convert [object] to a pure JSON-like value. /// /// The result is similar to `jsonDecode(jsonEncode(object))`, but without @@ -70,6 +80,10 @@ Object? deepToJson(Object? object) { return (result, true); } +extension CompleterChecks on Subject> { + Subject get isCompleted => has((x) => x.isCompleted, 'isCompleted'); +} + extension JsonChecks on Subject { /// Expects that the value is deeply equal to [expected], /// after calling [deepToJson] on both. diff --git a/test/test_navigation.dart b/test/test_navigation.dart index b5065d684c..15c6a345d6 100644 --- a/test/test_navigation.dart +++ b/test/test_navigation.dart @@ -1,11 +1,13 @@ import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; // Inspired by test code in the Flutter tree: // https://github.com/flutter/flutter/blob/53082f65b/packages/flutter/test/widgets/observer_tester.dart // https://github.com/flutter/flutter/blob/53082f65b/packages/flutter/test/widgets/navigator_test.dart /// A trivial observer for testing the navigator. -class TestNavigatorObserver extends NavigatorObserver { +class TestNavigatorObserver extends TransitionDurationObserver{ + void Function(Route topRoute, Route? previousTopRoute)? onChangedTop; void Function(Route route, Route? previousRoute)? onPushed; void Function(Route route, Route? previousRoute)? onPopped; void Function(Route route, Route? previousRoute)? onRemoved; @@ -13,33 +15,45 @@ class TestNavigatorObserver extends NavigatorObserver { void Function(Route route, Route? previousRoute)? onStartUserGesture; void Function()? onStopUserGesture; + @override + void didChangeTop(Route topRoute, Route? previousTopRoute) { + super.didChangeTop(topRoute, previousTopRoute); + onChangedTop?.call(topRoute, previousTopRoute); + } + @override void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); onPushed?.call(route, previousRoute); } @override void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); onPopped?.call(route, previousRoute); } @override void didRemove(Route route, Route? previousRoute) { + super.didRemove(route, previousRoute); onRemoved?.call(route, previousRoute); } @override void didReplace({ Route? oldRoute, Route? newRoute }) { + super.didReplace(oldRoute: oldRoute, newRoute: newRoute); onReplaced?.call(newRoute, oldRoute); } @override void didStartUserGesture(Route route, Route? previousRoute) { + super.didStartUserGesture(route, previousRoute); onStartUserGesture?.call(route, previousRoute); } @override void didStopUserGesture() { + super.didStopUserGesture(); onStopUserGesture?.call(); } } diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 2bb07b4946..8f4d614220 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -10,6 +10,7 @@ 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/model/narrow.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/binding.dart'; @@ -22,70 +23,123 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/action_sheet.dart'; import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/button.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; -import 'package:zulip/widgets/emoji.dart'; +import 'package:zulip/widgets/emoji_reaction.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/inbox.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart'; +import 'package:zulip/widgets/read_receipts.dart'; +import 'package:zulip/widgets/subscription_list.dart'; +import 'package:zulip/widgets/topic_list.dart'; +import 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; +import '../model/content_test.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_clipboard.dart'; +import '../test_images.dart'; import '../test_share_plus.dart'; -import 'compose_box_checks.dart'; +import 'checks.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; late PerAccountStore store; late FakeApiConnection connection; +late TransitionDurationObserver transitionDurationObserver; /// Simulates loading a [MessageListPage] and long-pressing on [message]. Future setupToMessageActionSheet(WidgetTester tester, { required Message message, required Narrow narrow, + User? selfUser, + User? sender, + List? mutedUserIds, + bool? realmAllowMessageEditing, + int? realmMessageContentEditLimitSeconds, + bool? realmEnableReadReceipts, + bool shouldSetServerEmojiData = true, + bool useLegacyServerEmojiData = false, + Future Function()? beforeLongPress, }) async { addTearDown(testBinding.reset); - assert(narrow.containsMessage(message)); - - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + // TODO(#1667) will be null in a search narrow; remove `!`. + assert(narrow.containsMessage(message)!); + + selfUser ??= eg.selfUser; + final selfAccount = eg.account(user: selfUser); + await testBinding.globalStore.add( + selfAccount, + eg.initialSnapshot( + realmUsers: [selfUser], + realmAllowMessageEditing: realmAllowMessageEditing, + realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, + realmEnableReadReceipts: realmEnableReadReceipts, + )); + store = await testBinding.globalStore.perAccount(selfAccount.id); await store.addUsers([ - eg.selfUser, - eg.user(userId: message.senderId), + selfUser, + sender ?? eg.user(userId: message.senderId), if (narrow is DmNarrow) ...narrow.otherRecipientIds.map((id) => eg.user(userId: id)), ]); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } if (message is StreamMessage) { final stream = eg.stream(streamId: message.streamId); await store.addStream(stream); await store.addSubscription(eg.subscription(stream)); } connection = store.connection as FakeApiConnection; + if (shouldSetServerEmojiData) { + store.setServerEmojiData(useLegacyServerEmojiData + ? eg.serverEmojiDataPopularLegacy + : eg.serverEmojiDataPopular); + } + + transitionDurationObserver = TransitionDurationObserver(); connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [message]).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + await tester.pumpWidget(TestZulipApp( + accountId: selfAccount.id, + navigatorObservers: [transitionDurationObserver], child: MessageListPage(initNarrow: narrow))); // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); - // request the message action sheet - await tester.longPress(find.byType(MessageContent)); + await beforeLongPress?.call(); + + // Request the message action sheet. + // + // We use `warnIfMissed: false` to suppress warnings in cases where + // MessageContent itself didn't hit-test as true but the action sheet still + // opened. The action sheet still opens because the gesture handler is an + // ancestor of MessageContent, but MessageContent might not hit-test as true + // because its render box effectively has HitTestBehavior.deferToChild, and + // the long-press might land where no child hit-tests as true, + // like if it's in padding around a Paragraph. + await tester.longPress(find.byType(MessageContent), warnIfMissed: false); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); + // Check the action sheet did in fact open, so we don't defeat any tests that + // use simple `find.byIcon`-style checks to test presence/absence of a button. + check(find.byType(BottomSheet)).findsOne(); } void main() { TestZulipBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; void prepareRawContentResponseSuccess({ required Message message, @@ -100,14 +154,442 @@ void main() { } void prepareRawContentResponseError() { - final fakeResponseJson = { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }; - connection.prepare(httpStatus: 400, json: fakeResponseJson); + connection.prepare(apiException: eg.apiBadRequest(message: 'Invalid message(s)')); } + group('channel action sheet', () { + late ZulipStream someChannel; + const someTopic = 'my topic'; + late StreamMessage someMessage; + + Future prepare({bool hasUnreadMessages = true}) async { + someChannel = eg.stream(); + someMessage = eg.streamMessage( + stream: someChannel, topic: someTopic, sender: eg.otherUser, + flags: hasUnreadMessages ? [] : [MessageFlag.read]); + addTearDown(testBinding.reset); + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + + await store.addUser(eg.selfUser); + await store.addUser(eg.otherUser); + await store.addStream(someChannel); + await store.addSubscription(eg.subscription(someChannel)); + await store.addMessage(someMessage); + } + + Future showFromInbox(WidgetTester tester) async { + transitionDurationObserver = TransitionDurationObserver(); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + navigatorObservers: [transitionDurationObserver], + child: const HomePage())); + await tester.pump(); + check(find.byType(InboxPageBody)).findsOne(); + + await tester.longPress(find.text(someChannel.name).hitTestable()); + await tester.pump(const Duration(milliseconds: 250)); + } + + Future showFromSubscriptionList(WidgetTester tester) async { + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: const HomePage())); + await tester.pump(); + await tester.tap(find.byIcon(ZulipIcons.hash_italic)); + await tester.pump(); + check(find.byType(SubscriptionListPageBody)).findsOne(); + + await tester.longPress(find.text(someChannel.name).hitTestable()); + await tester.pump(const Duration(milliseconds: 250)); + } + + Future showFromMsglistAppBar(WidgetTester tester, { + ZulipStream? channel, + required Narrow narrow, + }) async { + channel ??= someChannel; + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: []).toJson()); + if (narrow case ChannelNarrow()) { + // We auto-focus the topic input when there are no messages; + // this is for topic autocomplete. + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + } + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: MessageListPage( + initNarrow: narrow))); + await tester.pumpAndSettle(); + + await tester.longPress(find.descendant( + of: find.byType(ZulipAppBar), + matching: find.text(channel.name))); + await tester.pump(const Duration(milliseconds: 250)); + } + + Future showFromRecipientHeader(WidgetTester tester, { + StreamMessage? message, + }) async { + message ??= someMessage; + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: const MessageListPage(initNarrow: CombinedFeedNarrow()))); + await tester.pumpAndSettle(); + + await tester.longPress(find.descendant( + of: find.byType(RecipientHeader), + matching: find.text(message.displayRecipient ?? ''))); + await tester.pump(const Duration(milliseconds: 250)); + } + + Future showFromTopicListAppBar(WidgetTester tester) async { + final transitionDurationObserver = TransitionDurationObserver(); + + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + await tester.pumpWidget(TestZulipApp( + navigatorObservers: [transitionDurationObserver], + accountId: eg.selfAccount.id, + child: TopicListPage(streamId: someChannel.streamId))); + await tester.pump(); + + await tester.longPress(find.descendant( + of: find.byType(ZulipAppBar), + matching: find.text(someChannel.name))); + await transitionDurationObserver.pumpPastTransition(tester); + } + + final actionSheetFinder = find.byType(BottomSheet); + Finder findButtonForLabel(String label) => + find.descendant(of: actionSheetFinder, matching: find.text(label)); + + void checkButton(String label) { + check(findButtonForLabel(label)).findsOne(); + } + + void checkNoButton(String label) { + check(findButtonForLabel(label)).findsNothing(); + } + + group('showChannelActionSheet', () { + void checkButtons() { + check(actionSheetFinder).findsOne(); + checkButton('Mark channel as read'); + checkButton('Copy link to channel'); + } + + testWidgets('show from inbox', (tester) async { + await prepare(); + await showFromInbox(tester); + checkButtons(); + }); + + testWidgets('show from subscription list', (tester) async { + await prepare(); + await showFromSubscriptionList(tester); + checkButtons(); + }); + + testWidgets('show with no unread messages', (tester) async { + await prepare(hasUnreadMessages: false); + await showFromSubscriptionList(tester); + check(findButtonForLabel('Mark channel as read')).findsNothing(); + }); + + testWidgets('show from message-list app bar in channel narrow', (tester) async { + await prepare(); + final narrow = ChannelNarrow(someChannel.streamId); + await showFromMsglistAppBar(tester, narrow: narrow); + checkButtons(); + }); + + testWidgets('show from message-list app bar in topic narrow', (tester) async { + await prepare(); + final narrow = eg.topicNarrow(someChannel.streamId, someTopic); + await showFromMsglistAppBar(tester, narrow: narrow); + checkButtons(); + }); + + testWidgets('show from recipient header', (tester) async { + await prepare(); + await showFromRecipientHeader(tester, message: someMessage); + checkButtons(); + }); + + testWidgets('show from topic-list app bar', (tester) async { + await prepare(); + await showFromTopicListAppBar(tester); + checkButtons(); + }); + }); + + group('SubscribeButton', () { + Future tapButton(WidgetTester tester) async { + await tester.tap(findButtonForLabel('Subscribe')); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + testWidgets('channel not subscribed', (tester) async { + await prepare(); + final narrow = ChannelNarrow(someChannel.streamId); + await store.removeSubscription(narrow.streamId); + await showFromMsglistAppBar(tester, narrow: narrow); + checkButton('Subscribe'); + }); + + testWidgets('channel subscribed', (tester) async { + await prepare(); + final narrow = ChannelNarrow(someChannel.streamId); + check(store.subscriptions[narrow.streamId]).isNotNull(); + await showFromMsglistAppBar(tester, narrow: narrow); + checkNoButton('Subscribe'); + }); + + testWidgets('smoke', (tester) async { + await prepare(); + final narrow = ChannelNarrow(someChannel.streamId); + await store.removeSubscription(narrow.streamId); + await showFromMsglistAppBar(tester, narrow: narrow); + + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/subscriptions') + ..bodyFields.deepEquals({ + 'subscriptions': jsonEncode([{'name': someChannel.name}]), + }); + }); + }); + + group('MarkChannelAsReadButton', () { + void checkRequest(int channelId) { + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode([ + {'operator': 'channel', 'operand': channelId}, + {'operator': 'is', 'operand': 'unread'}, + ]), + 'op': 'add', + 'flag': 'read', + }); + } + + testWidgets('happy path from inbox', (tester) async { + await prepare(); + final message = eg.streamMessage(stream: someChannel, topic: someTopic); + await store.addMessage(message); + await showFromInbox(tester); + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 1, updatedCount: 1, + firstProcessedId: message.id, lastProcessedId: message.id, + foundOldest: true, foundNewest: true).toJson()); + await tester.tap(findButtonForLabel('Mark channel as read')); + await tester.pumpAndSettle(); + checkRequest(someChannel.streamId); + checkNoDialog(tester); + }); + + testWidgets('request fails', (tester) async { + await prepare(); + await showFromInbox(tester); + connection.prepare(httpException: http.ClientException('Oops')); + await tester.tap(findButtonForLabel('Mark channel as read')); + await tester.pumpAndSettle(); + checkRequest(someChannel.streamId); + checkErrorDialog(tester, + expectedTitle: "Mark as read failed"); + }); + }); + + group('TopicListButton', () { + testWidgets('not visible from app bar on topic list', (tester) async { + await prepare(); + await showFromTopicListAppBar(tester); + checkNoButton('List of topics'); + }); + + testWidgets('happy path from msglist app bar', (tester) async { + await prepare(); + await showFromMsglistAppBar(tester, + narrow: ChannelNarrow(someChannel.streamId)); + + connection.prepare(json: GetStreamTopicsResult(topics: [ + eg.getStreamTopicsEntry(name: 'some topic foo'), + ]).toJson()); + await tester.tap(findButtonForLabel('List of topics')); + await tester.pumpAndSettle(); + check(find.text('some topic foo')).findsOne(); + }); + }); + + group('ChannelFeedButton', () { + Future tapButtonAndPump(WidgetTester tester) async { + await tester.tap(findButtonForLabel('Channel feed')); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + testWidgets('from inbox: visible', (tester) async { + await prepare(); + await showFromInbox(tester); + checkButton('Channel feed'); + }); + + testWidgets('from subscription list: visible', (tester) async { + await prepare(); + await showFromSubscriptionList(tester); + checkButton('Channel feed'); + }); + + testWidgets('from recipient header in combined feed: visible', (tester) async { + await prepare(); + await showFromRecipientHeader(tester); + checkButton('Channel feed'); + }); + + testWidgets('from app bar on topic list: visible', (tester) async { + await prepare(); + await showFromTopicListAppBar(tester); + checkButton('Channel feed'); + }); + + testWidgets('from msglist app bar on channel feed: not visible', (tester) async { + await prepare(); + await showFromMsglistAppBar(tester, narrow: ChannelNarrow(someChannel.streamId)); + checkNoButton('Channel feed'); + }); + + // (The channel action sheet isn't reached from a recipient header + // in the channel feed.) + + testWidgets('navigates to channel feed', (tester) async { + await prepare(); + await showFromInbox(tester); + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: []).toJson()); + // for topic autocomplete + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + await tapButtonAndPump(tester); + await transitionDurationObserver.pumpPastTransition(tester); + + final appBar = tester.widget(find.byType(MessageListAppBarTitle)) as MessageListAppBarTitle; + check(appBar.narrow).equals(ChannelNarrow(someChannel.streamId)); + }); + }); + + group('CopyChannelLinkButton', () { + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + MockClipboard().handleMethodCall, + ); + }); + + Future tapCopyChannelLinkButton(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(ZulipIcons.link, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.link)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + testWidgets('copies channel link to clipboard', (tester) async { + await prepare(); + final narrow = ChannelNarrow(someChannel.streamId); + await showFromMsglistAppBar(tester, narrow: narrow); + + await tapCopyChannelLinkButton(tester); + await tester.pump(Duration.zero); + final expectedLink = narrowLink(store, narrow).toString(); + check(await Clipboard.getData('text/plain')).isNotNull().text.equals(expectedLink); + }); + }); + + group('UnsubscribeButton', () { + Future tapButton(WidgetTester tester) async { + await tester.ensureVisible(findButtonForLabel('Unsubscribe')); + await tester.tap(findButtonForLabel('Unsubscribe')); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + testWidgets('channel subscribed', (tester) async { + await prepare(); + final narrow = ChannelNarrow(someChannel.streamId); + check(store.subscriptions[narrow.streamId]).isNotNull(); + await showFromMsglistAppBar(tester, narrow: narrow); + checkButton('Unsubscribe'); + }); + + testWidgets('channel not subscribed', (tester) async { + await prepare(); + final narrow = ChannelNarrow(someChannel.streamId); + await store.removeSubscription(narrow.streamId); + await showFromMsglistAppBar(tester, narrow: narrow); + checkNoButton('Unsubscribe'); + }); + + testWidgets('smoke, public channel', (tester) async { + final channel = eg.stream(inviteOnly: false); + await prepare(); + await store.addStream(channel); + await store.addSubscription(eg.subscription(channel)); + final narrow = ChannelNarrow(channel.streamId); + await showFromMsglistAppBar(tester, channel: channel, narrow: narrow); + + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); + + checkNoDialog(tester); + + check(connection.lastRequest).isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/users/me/subscriptions') + ..bodyFields.deepEquals({ + 'subscriptions': jsonEncode([channel.name]), + }); + }); + + testWidgets('smoke, private channel', (tester) async { + final channel = eg.stream(inviteOnly: true); + await prepare(); + await store.addStream(channel); + await store.addSubscription(eg.subscription(channel)); + final narrow = ChannelNarrow(channel.streamId); + await showFromMsglistAppBar(tester, channel: channel, narrow: narrow); + connection.takeRequests(); + + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(); + + final (unsubscribeButton, cancelButton) = checkSuggestedActionDialog(tester, + expectedTitle: 'Unsubscribe from ${channel.name}?', + expectedMessage: 'Once you leave this channel, you might not be able to rejoin.', + expectedActionButtonText: 'Unsubscribe'); + await tester.tap(find.byWidget(unsubscribeButton)); + await tester.pump(Duration.zero); + + check(connection.takeRequests()).single.isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/users/me/subscriptions') + ..bodyFields.deepEquals({ + 'subscriptions': jsonEncode([channel.name]), + }); + }); + }); + }); + group('topic action sheet', () { final someChannel = eg.stream(); const someTopic = 'my topic'; @@ -172,24 +654,26 @@ void main() { Future showFromAppBar(WidgetTester tester, { ZulipStream? channel, - String topic = someTopic, + TopicName? topic, List? messages, }) async { final effectiveChannel = channel ?? someChannel; + final effectiveTopic = topic ?? TopicName(someTopic); final effectiveMessages = messages ?? [someMessage]; - assert(effectiveMessages.every((m) => m.topic.apiName == topic)); + assert(effectiveMessages.every((m) => m.topic.apiName == effectiveTopic.apiName)); connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: effectiveMessages).toJson()); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage( - initNarrow: eg.topicNarrow(effectiveChannel.streamId, topic)))); + initNarrow: TopicNarrow(effectiveChannel.streamId, effectiveTopic)))); // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); final topicRow = find.descendant( of: find.byType(ZulipAppBar), - matching: find.text(topic)); + matching: find.text( + effectiveTopic.displayName ?? eg.defaultRealmEmptyTopicDisplayName)); await tester.longPress(topicRow); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); @@ -209,7 +693,7 @@ void main() { await tester.longPress(find.descendant( of: find.byType(RecipientHeader), - matching: find.text(effectiveMessage.topic.displayName))); + matching: find.text(effectiveMessage.topic.displayName!))); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); } @@ -227,9 +711,11 @@ void main() { } checkButton('Follow topic'); + checkButton('Mark as resolved'); + checkButton('Copy link to topic'); } - testWidgets('show from inbox', (tester) async { + 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, @@ -237,6 +723,17 @@ void main() { 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(); }); @@ -246,6 +743,23 @@ void main() { 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 app bar: resolve/unresolve not offered when topic is empty', (tester) async { + await prepare(); + final message = eg.streamMessage(stream: someChannel, topic: ''); + await showFromAppBar(tester, + topic: TopicName(''), + messages: [message]); + check(findButtonForLabel('Mark as resolved')).findsNothing(); + check(findButtonForLabel('Mark as unresolved')).findsNothing(); + }); + testWidgets('show from recipient header', (tester) async { await prepare(); await showFromRecipientHeader(tester); @@ -285,14 +799,10 @@ void main() { final message = eg.streamMessage( stream: someChannel, topic: topic, sender: eg.otherUser); await showFromAppBar(tester, - channel: someChannel, topic: topic, messages: [message]); + channel: someChannel, topic: TopicName(topic), messages: [message]); } void checkButtons(List expectedButtonFinders) { - if (expectedButtonFinders.isEmpty) { - check(actionSheetFinder).findsNothing(); - return; - } check(actionSheetFinder).findsOne(); for (final buttonFinder in expectedButtonFinders) { @@ -362,8 +872,7 @@ void main() { isChannelMuted: false, visibilityPolicy: UserTopicVisibilityPolicy.followed); - connection.prepare(httpStatus: 400, json: { - 'result': 'error', 'code': 'BAD_REQUEST', 'msg': ''}); + connection.prepare(apiException: eg.apiBadRequest()); await tester.tap(unfollow); await tester.pumpAndSettle(); @@ -450,80 +959,461 @@ void main() { } }); }); + + 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(); + + checkNoDialog(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(); + + checkNoDialog(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: TopicName('✔ zulip'), messages: [message]); + connection.takeRequests(); + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(findButtonForLabel('Mark as unresolved')); + await tester.pumpAndSettle(); + + checkNoDialog(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: TopicName('✔ ✔ zulip'), messages: [message]); + connection.takeRequests(); + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(findButtonForLabel('Mark as unresolved')); + await tester.pumpAndSettle(); + + checkNoDialog(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(httpException: 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(httpException: 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('MarkTopicAsReadButton', () { + testWidgets('visible if topic has unread messages', (tester) async { + await prepare(); + final message = eg.streamMessage(stream: someChannel, topic: someTopic, + flags: []); + await store.addMessage(message); + await showFromAppBar(tester, messages: [message]); + check(find.text('Mark topic as read')).findsOne(); + }); + + testWidgets('not visible if topic has no unread messages', (tester) async { + await prepare(); + final message = eg.streamMessage(stream: someChannel, topic: someTopic, + flags: [MessageFlag.read]); + await store.addMessage(message); + await showFromAppBar(tester, messages: [message]); + check(find.text('Mark topic as read')).findsNothing(); + }); + + testWidgets('marks topic as read when pressed', (tester) async { + await prepare(); + final message = eg.streamMessage(stream: someChannel, topic: someTopic, + flags: []); + await store.addMessage(message); + await showFromAppBar(tester, messages: [message]); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 1, updatedCount: 1, + firstProcessedId: message.id, lastProcessedId: message.id, + foundOldest: true, foundNewest: true).toJson()); + await tester.tap(find.text('Mark topic as read')); + await tester.pumpAndSettle(); + + check(connection.lastRequest).isA() + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields['narrow'].equals(jsonEncode([ + ...resolveApiNarrowForServer( + eg.topicNarrow(someChannel.streamId, someTopic).apiEncode(), + connection.zulipFeatureLevel!), + ApiNarrowIs(IsOperand.unread), + ])) + ..bodyFields['op'].equals('add') + ..bodyFields['flag'].equals('read'); + }); + }); + + group('CopyTopicLinkButton', () { + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + MockClipboard().handleMethodCall, + ); + }); + + Future tapCopyTopicLinkButton(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(ZulipIcons.link, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.link)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + testWidgets('copies topic link to clipboard', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: someTopic); + await prepare(channel: someChannel, topic: someTopic, + zulipFeatureLevel: eg.recentZulipFeatureLevel); + await showFromAppBar(tester, channel: someChannel, + topic: TopicName(someTopic), messages: [message]); + + await tapCopyTopicLinkButton(tester); + await tester.pump(Duration.zero); + final expectedLink = narrowLink(store, + TopicNarrow(someChannel.streamId, TopicName(someTopic), with_: message.id)); + check(expectedLink.toString().contains('/with/')).isTrue(); + check((await Clipboard.getData('text/plain'))!) + .text.equals(expectedLink.toString()); + }); + + testWidgets('FL < 271 -> link doesn\'t contain "with" operator', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: someTopic); + await prepare(channel: someChannel, topic: someTopic, + zulipFeatureLevel: 270); + await showFromAppBar(tester, channel: someChannel, + topic: TopicName(someTopic), messages: [message]); + + await tapCopyTopicLinkButton(tester); + await tester.pump(Duration.zero); + final expectedLink = narrowLink(store, + TopicNarrow(someChannel.streamId, TopicName(someTopic))); + check(expectedLink.toString().contains('/with/')).isFalse(); + check((await Clipboard.getData('text/plain'))!) + .text.equals(expectedLink.toString()); + }); + }); }); group('message action sheet', () { + group('header', () { + void checkSenderAndTimestampShown(WidgetTester tester, {required int senderId}) { + check(find.descendant( + of: find.byType(BottomSheet), + matching: find.byWidgetPredicate( + (widget) => widget is Avatar && widget.userId == senderId)) + ).findsOne(); + final expectedTimestampColor = MessageListTheme.of( + tester.element(find.byType(BottomSheet))).labelTime; + // TODO check the timestamp text itself, when it's convenient to do so: + // https://github.com/zulip/zulip-flutter/pull/1624#discussion_r2181383754 + check(find.descendant( + of: find.byType(BottomSheet), + matching: find.byWidgetPredicate((widget) => + widget is Text + && widget.style?.color == expectedTimestampColor + && (widget.style?.fontFeatures?.contains(FontFeature.enable('c2sc')) ?? false))) + ).findsOne(); + } + + testWidgets('message sender and content shown', (tester) async { + final message = eg.streamMessage( + timestamp: 1671409088, + content: ContentExample.userMentionPlain.html); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message)); + checkSenderAndTimestampShown(tester, senderId: message.senderId); + check(find.descendant( + of: find.byType(BottomSheet), + matching: find.byType(UserMention)) + ).findsOne(); + }); + + testWidgets('muted sender also shown', (tester) async { + final message = eg.streamMessage( + timestamp: 1671409088, + content: ContentExample.userMentionPlain.html); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + mutedUserIds: [message.senderId], + beforeLongPress: () async { + check(find.byType(MessageContent)).findsNothing(); + await tester.tap( + find.widgetWithText(ZulipWebUiKitButton, 'Reveal message')); + await tester.pump(); + check(find.byType(MessageContent)).findsOne(); + }, + ); + checkSenderAndTimestampShown(tester, senderId: message.senderId); + check(find.descendant( + of: find.byType(BottomSheet), + matching: find.byType(UserMention)) + ).findsOne(); + }); + + testWidgets('poll is rendered', (tester) async { + final submessageContent = eg.pollWidgetData( + question: 'poll', options: ['First option', 'Second option']); + final message = eg.streamMessage( + timestamp: 1671409088, + sender: eg.selfUser, + submessages: [eg.submessage(content: submessageContent)]); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message)); + checkSenderAndTimestampShown(tester, senderId: message.senderId); + check(find.descendant( + of: find.byType(BottomSheet), + matching: find.text('First option')) + ).findsOne(); + }); + }); + group('ReactionButtons', () { - final popularCandidates = EmojiStore.popularEmojiCandidates; + testWidgets('absent if ServerEmojiData not loaded', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + shouldSetServerEmojiData: false); + check(find.byType(ReactionButtons)).findsNothing(); + }); + + for (final useLegacy in [false, true]) { + final popularCandidates = + (eg.store()..setServerEmojiData( + useLegacy + ? eg.serverEmojiDataPopularLegacy + : eg.serverEmojiDataPopular)) + .popularEmojiCandidates(); + for (final emoji in popularCandidates) { + final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; + + Future tapButton(WidgetTester tester) async { + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: find.text(emojiDisplay.emojiUnicode))); + } + + testWidgets('${emoji.emojiName} adding success; useLegacy: $useLegacy', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + useLegacyServerEmojiData: useLegacy); + + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..bodyFields.deepEquals({ + 'reaction_type': 'unicode_emoji', + 'emoji_code': emoji.emojiCode, + 'emoji_name': emoji.emojiName, + }); + }); - for (final emoji in popularCandidates) { - final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; + testWidgets('${emoji.emojiName} removing success; useLegacy: $useLegacy', (tester) async { + final message = eg.streamMessage( + reactions: [Reaction( + emojiName: emoji.emojiName, + emojiCode: emoji.emojiCode, + reactionType: ReactionType.unicodeEmoji, + userId: eg.selfAccount.userId)] + ); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + useLegacyServerEmojiData: useLegacy); + + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..bodyFields.deepEquals({ + 'reaction_type': 'unicode_emoji', + 'emoji_code': emoji.emojiCode, + 'emoji_name': emoji.emojiName, + }); + }); - Future tapButton(WidgetTester tester) async { - await tester.tap(find.descendant( - of: find.byType(BottomSheet), - matching: find.text(emojiDisplay.emojiUnicode))); + testWidgets('${emoji.emojiName} request has an error; useLegacy: $useLegacy', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + useLegacyServerEmojiData: useLegacy); + + connection.prepare( + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); + await tapButton(tester); + await tester.pump(Duration.zero); // error arrives; error dialog shows + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Adding reaction failed', + expectedMessage: 'Invalid message(s)'))); + }); } + } + }); - testWidgets('${emoji.emojiName} adding success', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + group('ViewReactionsButton', () { + final findButtonInSheet = find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.see_who_reacted)); - connection.prepare(json: {}); - await tapButton(tester); - await tester.pump(Duration.zero); + testWidgets('not visible if message has no reactions', (tester) async { + final message = eg.streamMessage(reactions: []); + await setupToMessageActionSheet(tester, + message: message, narrow: CombinedFeedNarrow()); - 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, - }); - }); + check(findButtonInSheet).findsNothing(); + }); - 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)); + Future tapButton(WidgetTester tester) async { + await tester.ensureVisible(findButtonInSheet); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + await tester.tap(findButtonInSheet); + } - connection.prepare(json: {}); - await tapButton(tester); - await tester.pump(Duration.zero); + testWidgets('smoke', (tester) async { + final message = eg.streamMessage(reactions: [eg.unicodeEmojiReaction]); + await setupToMessageActionSheet(tester, + message: message, narrow: CombinedFeedNarrow()); - check(connection.lastRequest).isA() - ..method.equals('DELETE') - ..url.path.equals('/api/v1/messages/${message.id}/reactions') - ..bodyFields.deepEquals({ - 'reaction_type': 'unicode_emoji', - 'emoji_code': emoji.emojiCode, - 'emoji_name': emoji.emojiName, - }); - }); + await tapButton(tester); - testWidgets('${emoji.emojiName} request has an error', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + // The message action sheet exits and the view-reactions sheet enters. + // + // This just pumps through twice the duration of the latest transition. + // Ideally we'd check that the two expected transitions were triggered + // and that they started at the same time, and pump through the + // longer of the two durations. + // TODO(upstream) support this in TransitionDurationObserver + await transitionDurationObserver.pumpPastTransition(tester); + await transitionDurationObserver.pumpPastTransition(tester); + + check(findButtonInSheet).findsNothing(); // the message action sheet exited + check(find.byType(ViewReactions)).findsOne(); + }); + }); - 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 + group('ViewReadReceiptsButton', () { + final findButtonInSheet = find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.check_check)); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Adding reaction failed', - expectedMessage: 'Invalid message(s)'))); - }); + Future tapButton(WidgetTester tester) async { + await tester.ensureVisible(findButtonInSheet); + await tester.tap(findButtonInSheet); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e } + + testWidgets('smoke', (tester) async { + await setupToMessageActionSheet(tester, + message: eg.streamMessage(), narrow: CombinedFeedNarrow()); + + await tapButton(tester); + + // The message action sheet exits and the view-reactions sheet enters. + // + // This just pumps through twice the duration of the latest transition. + // Ideally we'd check that the two expected transitions were triggered + // and that they started at the same time, and pump through the + // longer of the two durations. + // TODO(upstream) support this in TransitionDurationObserver + await transitionDurationObserver.pumpPastTransition(tester); + await transitionDurationObserver.pumpPastTransition(tester); + + // message action sheet exited + check(find.ancestor(of: find.byIcon(ZulipIcons.check_check), + matching: find.byType(BottomSheet))).findsNothing(); + + // receipts sheet opened + check(find.ancestor(of: find.byType(ReadReceipts), + matching: find.byType(BottomSheet))).findsOne(); + }); + + testWidgets('realm-level read receipts disabled -> button is absent', (tester) async { + await setupToMessageActionSheet(tester, + message: eg.streamMessage(), + narrow: CombinedFeedNarrow(), + realmEnableReadReceipts: false); + + check(findButtonInSheet).findsNothing(); + }); }); group('StarButton', () { @@ -580,11 +1470,8 @@ void main() { 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', - }); + connection.prepare( + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); await tapButton(tester); await tester.pump(Duration.zero); // error arrives; error dialog shows @@ -598,11 +1485,8 @@ void main() { 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', - }); + connection.prepare( + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); await tapButton(tester, starred: true); await tester.pump(Duration.zero); // error arrives; error dialog shows @@ -706,11 +1590,13 @@ void main() { }); testWidgets('no error if user lost posting permission after action sheet opened', (tester) async { + final selfUser = eg.user(role: UserRole.member); final stream = eg.stream(); final message = eg.streamMessage(stream: stream); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + await setupToMessageActionSheet(tester, selfUser: selfUser, + message: message, narrow: TopicNarrow.ofMessage(message)); - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: selfUser.userId, role: UserRole.guest)); await store.handleEvent(eg.channelUpdateEvent(stream, property: ChannelPropertyName.channelPostPolicy, @@ -742,7 +1628,8 @@ void main() { }); testWidgets('no error if recipient was deactivated while raw-content request in progress', (tester) async { - final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + final otherUser = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [otherUser]); await setupToMessageActionSheet(tester, message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); @@ -755,7 +1642,7 @@ void main() { await tapQuoteAndReplyButton(tester); await tester.pump(const Duration(seconds: 1)); // message not yet fetched - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.otherUser.userId, + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: otherUser.userId, isActive: false)); await tester.pump(); // no error @@ -808,6 +1695,18 @@ void main() { await setupToMessageActionSheet(tester, message: message, narrow: const StarredMessagesNarrow()); check(findQuoteAndReplyButton(tester)).isNull(); }); + + testWidgets('handle empty topic', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, narrow: TopicNarrow.ofMessage(message)); + + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + check(connection.lastRequest).isA() + .url.queryParameters['allow_empty_topic_name'].equals('true'); + await tester.pump(Duration.zero); + }); }); group('MarkAsUnread', () { @@ -846,7 +1745,9 @@ void main() { 'include_anchor': 'true', 'num_before': '0', 'num_after': '1000', - 'narrow': jsonEncode(TopicNarrow.ofMessage(message).apiEncode()), + 'narrow': jsonEncode(resolveApiNarrowForServer( + TopicNarrow.ofMessage(message).apiEncode(), + connection.zulipFeatureLevel!)), 'op': 'remove', 'flag': 'read', }); @@ -891,14 +1792,16 @@ void main() { ..method.equals('POST') ..url.path.equals('/api/v1/messages/flags/narrow') ..bodyFields['narrow'].equals( - jsonEncode(eg.topicNarrow(newStream.streamId, newTopic).apiEncode())); + jsonEncode(resolveApiNarrowForServer( + eg.topicNarrow(newStream.streamId, newTopic).apiEncode(), + connection.zulipFeatureLevel!))); }); testWidgets('shows error when fails', (tester) async { final message = eg.streamMessage(flags: [MessageFlag.read]); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - connection.prepare(exception: http.ClientException('Oops')); + connection.prepare(httpException: http.ClientException('Oops')); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); @@ -911,6 +1814,79 @@ void main() { }); }); + group('UnrevealMutedMessageButton', () { + final user = eg.user(userId: 1, fullName: 'User', avatarUrl: '/foo.png'); + final message = eg.streamMessage(sender: user, + content: '

        A message

        ', reactions: [eg.unicodeEmojiReaction]); + + final revealButtonFinder = find.widgetWithText(ZulipWebUiKitButton, + 'Reveal message'); + + final contentFinder = find.descendant( + of: find.byType(MessageContent), + matching: find.text('A message', findRichText: true)); + + testWidgets('not visible if message is from normal sender (not muted)', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + sender: user); + check(store.isUserMuted(user.userId)).isFalse(); + + check(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('visible if message is from muted sender and revealed', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + sender: user, + mutedUserIds: [user.userId], + beforeLongPress: () async { + check(contentFinder).findsNothing(); + await tester.tap(revealButtonFinder); + await tester.pump(); + check(contentFinder).findsOne(); + }, + ); + + check(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('when pressed, unreveals the message', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + sender: user, + mutedUserIds: [user.userId], + beforeLongPress: () async { + check(contentFinder).findsNothing(); + await tester.tap(revealButtonFinder); + await tester.pump(); + check(contentFinder).findsOne(); + }); + + await tester.ensureVisible(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.eye_off)); + await tester.pumpAndSettle(); + + check(contentFinder).findsNothing(); + check(revealButtonFinder).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + }); + group('CopyMessageTextButton', () { setUp(() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( @@ -984,8 +1960,8 @@ void main() { }); Future tapCopyMessageLinkButton(WidgetTester tester) async { - await tester.ensureVisible(find.byIcon(Icons.link, skipOffstage: false)); - await tester.tap(find.byIcon(Icons.link)); + await tester.ensureVisible(find.byIcon(ZulipIcons.link, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.link)); await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e } @@ -1062,6 +2038,180 @@ void main() { }); }); + group('EditButton', () { + Future tapEdit(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(ZulipIcons.edit, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.edit)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + Future takeErrorDialogAndPump(WidgetTester tester) async { + final errorDialog = checkErrorDialog(tester, expectedTitle: 'Message not saved'); + await tester.tap(find.byWidget(errorDialog)); + await tester.pump(); + } + + group('present/absent appropriately', () { + /// Test whether the edit-message button is visible, given params. + /// + /// The message timestamp is 60s before the current time + /// ([TestZulipBinding.utcNow]) as of the start of the test run. + /// + /// The message has streamId: 1 and topic: 'topic'. + /// The message list is for that [TopicNarrow] unless [narrow] is passed. + void testVisibility(bool expected, { + bool self = true, + Narrow? narrow, + bool allowed = true, + int? limit, + bool boxInEditMode = false, + bool? errorStatus, + bool poll = false, + }) { + // It's inconvenient here to set up a state where the compose box + // is in edit mode and the action sheet is opened for a message + // with an edit request that's in progress or in the error state. + // In the setup, we'd need to either use two messages or (via an edge + // case) two MessageListPages. It should suffice to test the + // boxInEditMode and errorStatus states separately. + assert(!boxInEditMode || errorStatus == null); + + final description = [ + 'from self: $self', + 'narrow: $narrow', + 'realm allows: $allowed', + 'edit limit: $limit', + 'compose box is in editing mode: $boxInEditMode', + 'edit-message error status: $errorStatus', + 'has poll: $poll', + ].join(', '); + + void checkButtonIsPresent(bool expected) { + if (expected) { + check(find.byIcon(ZulipIcons.edit, skipOffstage: false)).findsOne(); + } else { + check(find.byIcon(ZulipIcons.edit, skipOffstage: false)).findsNothing(); + } + } + + testWidgets(description, (tester) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final message = eg.streamMessage( + stream: eg.stream(streamId: 1), + topic: 'topic', + sender: self ? eg.selfUser : eg.otherUser, + timestamp: eg.utcTimestamp(testBinding.utcNow()) - 60, + submessages: poll + ? [eg.submessage(content: eg.pollWidgetData(question: 'poll', options: ['A']))] + : null, + ); + + await setupToMessageActionSheet(tester, + message: message, + narrow: narrow ?? TopicNarrow.ofMessage(message), + realmAllowMessageEditing: allowed, + realmMessageContentEditLimitSeconds: limit, + ); + + if (!boxInEditMode && errorStatus == null) { + // The state we're testing is present on the original action sheet. + checkButtonIsPresent(expected); + return; + } + // The state we're testing requires a previous "edit message" action + // in order to set up. Use the first action sheet for that setup step. + + connection.prepare(json: GetMessageResult( + message: eg.streamMessage(content: 'foo')).toJson()); + await tapEdit(tester); + await tester.pump(); + // Default duration of bottom-sheet exit animation, + // plus 1ms fudge factor (why needed?) + // TODO(#1668) get this dynamically instead of hard-coding + await tester.pump(Duration(milliseconds: 200 + 1)); + await tester.enterText(find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller?.text == 'foo'), + 'bar'); + + if (errorStatus == true) { + // We're testing the request-failed state. Prepare a failure + // and tap Save. + connection.prepare(apiException: eg.apiBadRequest()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration.zero); + await takeErrorDialogAndPump(tester); + } else if (errorStatus == false) { + // We're testing the request-in-progress state. Prepare a delay, + // tap Save, and wait through only part of the delay. + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration(milliseconds: 500)); + } else { + // We're testing the state where the compose box is in + // edit-message mode. Keep it that way by not tapping Save. + } + + // See comment in setupToMessageActionSheet about warnIfMissed: false + await tester.longPress(find.byType(MessageContent), warnIfMissed: false); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + check(find.byType(BottomSheet)).findsOne(); + checkButtonIsPresent(expected); + + await tester.pump(Duration(milliseconds: 500)); // flush timers + }); + } + + testVisibility(true); + // TODO(server-6) limit 0 not expected on 6.0+ + testVisibility(true, limit: 0); + testVisibility(true, limit: 600); + testVisibility(true, narrow: ChannelNarrow(1)); + + testVisibility(false, self: false); + testVisibility(false, narrow: CombinedFeedNarrow()); + testVisibility(false, allowed: false); + testVisibility(false, limit: 10); + testVisibility(false, boxInEditMode: true); + testVisibility(false, errorStatus: false); + testVisibility(false, errorStatus: true); + testVisibility(false, poll: true); + }); + + group('tap button', () { + ComposeBoxController? findComposeBoxController(WidgetTester tester) { + return tester.stateList(find.byType(ComposeBox)) + .singleOrNull?.controller; + } + + testWidgets('smoke', (tester) async { + final message = eg.streamMessage(sender: eg.selfUser); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + realmAllowMessageEditing: true, + realmMessageContentEditLimitSeconds: null, + ); + + check(findComposeBoxController(tester)) + .isA(); + + connection.prepare(json: GetMessageResult( + message: eg.streamMessage(content: 'foo')).toJson()); + await tapEdit(tester); + await tester.pump(Duration.zero); + + check(findComposeBoxController(tester)) + .isA() + ..messageId.equals(message.id) + ..originalRawContent.equals('foo'); + }); + }); + }); + group('MessageActionSheetCancelButton', () { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; @@ -1086,7 +2236,3 @@ void main() { }); }); } - -extension UnicodeEmojiWidgetChecks on Subject { - Subject get emojiDisplay => has((x) => x.emojiDisplay, 'emojiDisplay'); -} diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart index 5a957c44e0..2aa7432280 100644 --- a/test/widgets/actions_test.dart +++ b/test/widgets/actions_test.dart @@ -1,574 +1,409 @@ -import 'dart:async'; import 'dart:convert'; 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/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; -import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/binding.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; -import 'package:zulip/notifications/receive.dart'; import 'package:zulip/widgets/actions.dart'; -import 'package:zulip/widgets/app.dart'; -import 'package:zulip/widgets/inbox.dart'; -import 'package:zulip/widgets/page.dart'; 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 '../model/unreads_checks.dart'; import '../stdlib_checks.dart'; -import '../test_navigation.dart'; +import '../test_clipboard.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; void main() { - TestZulipBinding.ensureInitialized(); - - late PerAccountStore store; - late FakeApiConnection connection; - late BuildContext context; - - Future prepare(WidgetTester tester, { - UnreadMessagesSnapshot? unreadMsgs, - String? ackedPushToken = '123', - bool skipAssertAccountExists = false, - }) async { - addTearDown(testBinding.reset); - final selfAccount = eg.selfAccount.copyWith(ackedPushToken: Value(ackedPushToken)); - await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( - unreadMsgs: unreadMsgs)); - store = await testBinding.globalStore.perAccount(selfAccount.id); - connection = store.connection as FakeApiConnection; - - await tester.pumpWidget(TestZulipApp( - accountId: selfAccount.id, - skipAssertAccountExists: skipAssertAccountExists, - child: const Scaffold(body: Placeholder()))); - await tester.pump(); - context = tester.element(find.byType(Placeholder)); - } - - /// Creates and caches a new [FakeApiConnection] in [TestGlobalStore]. - /// - /// In live code, [unregisterToken] makes a new [ApiConnection] for the - /// unregister-token request instead of reusing the store's connection. - /// To enable callers to prepare responses for that request, this function - /// creates a new [FakeApiConnection] and caches it in [TestGlobalStore] - /// for [unregisterToken] to pick up. - /// - /// Call this instead of just turning on - /// [TestGlobalStore.useCachedApiConnections] so that [unregisterToken] - /// doesn't try to call `close` twice on the same connection instance, - /// which isn't allowed. (Once by the unregister-token code - /// and once as part of removing the account.) - FakeApiConnection separateConnection() { - testBinding.globalStore - ..clearCachedApiConnections() - ..useCachedApiConnections = true; - return testBinding.globalStore - .apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; - } - - String unregisterApiPathForPlatform(TargetPlatform platform) { - return switch (platform) { - TargetPlatform.android => '/api/v1/users/me/android_gcm_reg_id', - TargetPlatform.iOS => '/api/v1/users/me/apns_device_token', - _ => throw Error(), - }; - } - - void checkSingleUnregisterRequest( - FakeApiConnection connection, { - String? expectedToken, - }) { - final subject = check(connection.takeRequests()).single.isA() - ..method.equals('DELETE') - ..url.path.equals(unregisterApiPathForPlatform(defaultTargetPlatform)); - if (expectedToken != null) { - subject.bodyFields.deepEquals({'token': expectedToken}); - } - } - - group('logOutAccount', () { - testWidgets('smoke', (tester) async { - await prepare(tester, skipAssertAccountExists: true); - check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id); - const unregisterDelay = Duration(seconds: 5); - assert(unregisterDelay > TestGlobalStore.removeAccountDuration); - final newConnection = separateConnection() - ..prepare(delay: unregisterDelay, json: {'msg': '', 'result': 'success'}); - - final future = logOutAccount(context, eg.selfAccount.id); - // Unregister-token request and account removal dispatched together - checkSingleUnregisterRequest(newConnection); - check(testBinding.globalStore.takeDoRemoveAccountCalls()) - .single.equals(eg.selfAccount.id); - - await tester.pump(TestGlobalStore.removeAccountDuration); - await future; - // Account removal not blocked on unregister-token response - check(testBinding.globalStore).accountIds.isEmpty(); - check(connection.isOpen).isFalse(); - check(newConnection.isOpen).isTrue(); // still busy with unregister-token - - await tester.pump(unregisterDelay - TestGlobalStore.removeAccountDuration); - check(newConnection.isOpen).isFalse(); - }); - - testWidgets('unregister request has an error', (tester) async { - await prepare(tester, skipAssertAccountExists: true); - check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id); - const unregisterDelay = Duration(seconds: 5); - assert(unregisterDelay > TestGlobalStore.removeAccountDuration); - final exception = ZulipApiException( - httpStatus: 401, - code: 'UNAUTHORIZED', - data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"}, - routeName: 'removeEtcEtcToken', - message: 'Invalid API key', - ); - final newConnection = separateConnection() - ..prepare(delay: unregisterDelay, exception: exception); - - final future = logOutAccount(context, eg.selfAccount.id); - // Unregister-token request and account removal dispatched together - checkSingleUnregisterRequest(newConnection); - check(testBinding.globalStore.takeDoRemoveAccountCalls()) - .single.equals(eg.selfAccount.id); - - await tester.pump(TestGlobalStore.removeAccountDuration); - await future; - // Account removal not blocked on unregister-token response - check(testBinding.globalStore).accountIds.isEmpty(); - check(connection.isOpen).isFalse(); - check(newConnection.isOpen).isTrue(); // for the unregister-token request - - await tester.pump(unregisterDelay - TestGlobalStore.removeAccountDuration); - check(newConnection.isOpen).isFalse(); - }); - - testWidgets("logged-out account's routes removed from nav; other accounts' remain", (tester) async { - Future makeUnreadTopicInInbox(int accountId, String topic) async { - final stream = eg.stream(); - final message = eg.streamMessage(stream: stream, topic: topic); - final store = await testBinding.globalStore.perAccount(accountId); - await store.addStream(stream); - await store.addSubscription(eg.subscription(stream)); - await store.addMessage(message); - await tester.pump(); - } - + group('ZulipActions', () { + TestZulipBinding.ensureInitialized(); + + late PerAccountStore store; + late FakeApiConnection connection; + late BuildContext context; + + Future prepare(WidgetTester tester, { + UnreadMessagesSnapshot? unreadMsgs, + String? ackedPushToken = '123', + bool skipAssertAccountExists = false, + }) async { addTearDown(testBinding.reset); - - final account1 = eg.account(id: 1, user: eg.user()); - final account2 = eg.account(id: 2, user: eg.user()); - await testBinding.globalStore.add(account1, eg.initialSnapshot()); - await testBinding.globalStore.add(account2, eg.initialSnapshot()); - - final testNavObserver = TestNavigatorObserver(); - await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + final selfAccount = eg.selfAccount.copyWith(ackedPushToken: Value(ackedPushToken)); + await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( + unreadMsgs: unreadMsgs)); + store = await testBinding.globalStore.perAccount(selfAccount.id); + connection = store.connection as FakeApiConnection; + + await tester.pumpWidget(TestZulipApp( + accountId: selfAccount.id, + skipAssertAccountExists: skipAssertAccountExists, + child: const Scaffold(body: Placeholder()))); await tester.pump(); - final navigator = await ZulipApp.navigator; - navigator.popUntil((_) => false); // clear starting routes - await tester.pumpAndSettle(); - - final pushedRoutes = >[]; - testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route); - // TODO(#737): switch to a realistic setup: - // https://github.com/zulip/zulip-flutter/pull/1076#discussion_r1874124363 - final account1Route = MaterialAccountWidgetRoute( - accountId: account1.id, page: const InboxPageBody()); - final account2Route = MaterialAccountWidgetRoute( - accountId: account2.id, page: const InboxPageBody()); - unawaited(navigator.push(account1Route)); - unawaited(navigator.push(account2Route)); - await tester.pumpAndSettle(); - check(pushedRoutes).deepEquals([account1Route, account2Route]); - - await makeUnreadTopicInInbox(account1.id, 'topic in account1'); - final findAccount1PageContent = find.text('topic in account1', skipOffstage: false); - - await makeUnreadTopicInInbox(account2.id, 'topic in account2'); - final findAccount2PageContent = find.text('topic in account2', skipOffstage: false); - - final findLoadingPage = find.byType(LoadingPlaceholderPage, skipOffstage: false); - - check(findAccount1PageContent).findsOne(); - check(findLoadingPage).findsNothing(); - - final removedRoutes = >[]; - testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route); - - final context = tester.element(find.byType(MaterialApp)); - final future = logOutAccount(context, account1.id); - await tester.pump(TestGlobalStore.removeAccountDuration); - await future; - check(removedRoutes).single.identicalTo(account1Route); - check(findAccount1PageContent).findsNothing(); - check(findLoadingPage).findsOne(); - - await tester.pump(); - check(findAccount1PageContent).findsNothing(); - check(findLoadingPage).findsNothing(); - check(findAccount2PageContent).findsOne(); - }); - }); - - group('unregisterToken', () { - testWidgets('smoke, happy path', (tester) async { - await prepare(tester, ackedPushToken: '123'); - - final newConnection = separateConnection() - ..prepare(json: {'msg': '', 'result': 'success'}); - final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); - await tester.pump(Duration.zero); - await future; - checkSingleUnregisterRequest(newConnection, expectedToken: '123'); - check(newConnection.isOpen).isFalse(); - }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); - - testWidgets('fallback to current token if acked is missing', (tester) async { - await prepare(tester, ackedPushToken: null); - NotificationService.instance.token = ValueNotifier('asdf'); - - final newConnection = separateConnection() - ..prepare(json: {'msg': '', 'result': 'success'}); - final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); - await tester.pump(Duration.zero); - await future; - checkSingleUnregisterRequest(newConnection, expectedToken: 'asdf'); - check(newConnection.isOpen).isFalse(); - }); - - testWidgets('no error if acked token and current token both missing', (tester) async { - await prepare(tester, ackedPushToken: null); - NotificationService.instance.token = ValueNotifier(null); - - final newConnection = separateConnection(); - final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); - await tester.pumpAndSettle(); - await future; - check(newConnection.takeRequests()).isEmpty(); - }); + context = tester.element(find.byType(Placeholder)); + } - testWidgets('connection closed if request errors', (tester) async { - await prepare(tester, ackedPushToken: '123'); - - final newConnection = separateConnection() - ..prepare(exception: ZulipApiException( - httpStatus: 401, - code: 'UNAUTHORIZED', - data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"}, - routeName: 'removeEtcEtcToken', - message: 'Invalid API key', - )); - final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); - await tester.pump(Duration.zero); - await future; - checkSingleUnregisterRequest(newConnection, expectedToken: '123'); - check(newConnection.isOpen).isFalse(); + group('markNarrowAsRead', () { + testWidgets('smoke test on modern server', (tester) async { + final narrow = TopicNarrow.ofMessage(eg.streamMessage()); + await prepare(tester); + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: true).toJson()); + final future = ZulipAction.markNarrowAsRead(context, narrow); + await tester.pump(Duration.zero); + await future; + final apiNarrow = narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(resolveApiNarrowForServer(apiNarrow, connection.zulipFeatureLevel!)), + 'op': 'add', + 'flag': 'read', + }); + }); + + testWidgets('use is:unread optimization', (tester) async { + const narrow = CombinedFeedNarrow(); + await prepare(tester); + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: true).toJson()); + final future = ZulipAction.markNarrowAsRead(context, narrow); + await tester.pump(Duration.zero); + await future; + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': json.encode([{'operator': 'is', 'operand': 'unread'}]), + 'op': 'add', + 'flag': 'read', + }); + }); + + testWidgets('on mark-all-as-read when Unreads.oldUnreadsMissing: true', (tester) async { + const narrow = CombinedFeedNarrow(); + await prepare(tester); + store.unreads.oldUnreadsMissing = true; + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: true).toJson()); + final future = ZulipAction.markNarrowAsRead(context, narrow); + await tester.pump(Duration.zero); + await future; + check(store.unreads.oldUnreadsMissing).isFalse(); + }); }); - }); - group('markNarrowAsRead', () { - testWidgets('smoke test on modern server', (tester) async { + group('updateMessageFlagsStartingFromAnchor', () { + String onCompletedMessage(int count) => 'onCompletedMessage($count)'; + const progressMessage = 'progressMessage'; + const onFailedTitle = 'onFailedTitle'; final narrow = TopicNarrow.ofMessage(eg.streamMessage()); - await prepare(tester); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: true).toJson()); - final future = markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; final apiNarrow = narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': 'oldest', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); - }); - testWidgets('use is:unread optimization', (tester) async { - const narrow = CombinedFeedNarrow(); - await prepare(tester); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: true).toJson()); - final future = markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': 'oldest', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': json.encode([{'operator': 'is', 'operand': 'unread'}]), - 'op': 'add', - 'flag': 'read', - }); - }); - - testWidgets('on mark-all-as-read when Unreads.oldUnreadsMissing: true', (tester) async { - const narrow = CombinedFeedNarrow(); - await prepare(tester); - store.unreads.oldUnreadsMissing = true; - - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: true).toJson()); - final future = markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(store.unreads.oldUnreadsMissing).isFalse(); + Future invokeUpdateMessageFlagsStartingFromAnchor() => + ZulipAction.updateMessageFlagsStartingFromAnchor( + context: context, + apiNarrow: apiNarrow, + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read, + includeAnchor: false, + anchor: AnchorCode.oldest, + onCompletedMessage: onCompletedMessage, + onFailedTitle: onFailedTitle, + progressMessage: progressMessage); + + testWidgets('smoke test', (tester) async { + await prepare(tester); + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: 1, lastProcessedId: 1980, + foundOldest: true, foundNewest: true).toJson()); + final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); + await tester.pump(Duration.zero); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(resolveApiNarrowForServer(apiNarrow, connection.zulipFeatureLevel!)), + 'op': 'add', + 'flag': 'read', + }); + check(await didPass).isTrue(); + }); + + testWidgets('pagination', (tester) async { + // Check that `lastProcessedId` returned from an initial + // response is used as `anchorId` for the subsequent request. + await prepare(tester); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 1000, updatedCount: 890, + firstProcessedId: 1, lastProcessedId: 1989, + foundOldest: true, foundNewest: false).toJson()); + final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(resolveApiNarrowForServer(apiNarrow, connection.zulipFeatureLevel!)), + 'op': 'add', + 'flag': 'read', + }); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 20, updatedCount: 10, + firstProcessedId: 2000, lastProcessedId: 2023, + foundOldest: false, foundNewest: true).toJson()); + await tester.pump(Duration.zero); + check(find.bySubtype().evaluate()).length.equals(1); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': '1989', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(resolveApiNarrowForServer(apiNarrow, connection.zulipFeatureLevel!)), + 'op': 'add', + 'flag': 'read', + }); + check(await didPass).isTrue(); + }); + + testWidgets('on invalid response', (tester) async { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + await prepare(tester); + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 1000, updatedCount: 0, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: false).toJson()); + final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); + await tester.pump(Duration.zero); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(resolveApiNarrowForServer(apiNarrow, connection.zulipFeatureLevel!)), + 'op': 'add', + 'flag': 'read', + }); + checkErrorDialog(tester, + expectedTitle: onFailedTitle, + expectedMessage: zulipLocalizations.errorInvalidResponse); + check(await didPass).isFalse(); + }); + + testWidgets('catch-all api errors', (tester) async { + await prepare(tester); + connection.prepare(httpException: http.ClientException('Oops')); + final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); + await tester.pump(Duration.zero); + checkErrorDialog(tester, + expectedTitle: onFailedTitle, + expectedMessage: 'NetworkException: Oops (ClientException: Oops)'); + check(await didPass).isFalse(); + }); }); + }); - testWidgets('CombinedFeedNarrow on legacy server', (tester) async { - const narrow = CombinedFeedNarrow(); - await prepare(tester); - // Might as well test with oldUnreadsMissing: true. - store.unreads.oldUnreadsMissing = true; - - connection.zulipFeatureLevel = 154; - connection.prepare(json: {}); - final future = markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_all_as_read') - ..bodyFields.deepEquals({}); - - // Check that [Unreads.handleAllMessagesReadSuccess] wasn't called; - // in the legacy protocol, that'd be redundant with the mark-read event. - check(store.unreads).oldUnreadsMissing.isTrue(); - }); + group('PlatformActions', () { + TestZulipBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('ChannelNarrow on legacy server', (tester) async { - final stream = eg.stream(); - final narrow = ChannelNarrow(stream.streamId); - await prepare(tester); - connection.zulipFeatureLevel = 154; - connection.prepare(json: {}); - final future = markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_stream_as_read') - ..bodyFields.deepEquals({ - 'stream_id': stream.streamId.toString(), - }); + tearDown(() async { + testBinding.reset(); }); - testWidgets('TopicNarrow on legacy server', (tester) async { - final narrow = TopicNarrow.ofMessage(eg.streamMessage()); - await prepare(tester); - connection.zulipFeatureLevel = 154; - connection.prepare(json: {}); - final future = markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_topic_as_read') - ..bodyFields.deepEquals({ - 'stream_id': narrow.streamId.toString(), - 'topic_name': narrow.topic, - }); - }); + group('copyWithPopup', () { + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + MockClipboard().handleMethodCall, + ); + }); + + Future call(WidgetTester tester, {required String text}) async { + await tester.pumpWidget(TestZulipApp( + child: Scaffold( + body: Builder(builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + PlatformActions.copyWithPopup(context: context, + successContent: const Text('Text copied'), + data: ClipboardData(text: text)); + }, + child: const Text('Copy'))))))); + await tester.pump(); + await tester.tap(find.text('Copy')); + await tester.pump(); // copy + await tester.pump(Duration.zero); // await platform info (awkwardly async) + } - testWidgets('DmNarrow on legacy server', (tester) async { - final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); - final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); - final unreadMsgs = eg.unreadMsgs(dms: [ - UnreadDmSnapshot(otherUserId: eg.otherUser.userId, - unreadMessageIds: [message.id]), - ]); - await prepare(tester, unreadMsgs: unreadMsgs); - connection.zulipFeatureLevel = 154; - connection.prepare(json: - UpdateMessageFlagsResult(messages: [message.id]).toJson()); - final future = markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags') - ..bodyFields.deepEquals({ - 'messages': jsonEncode([message.id]), - 'op': 'add', - 'flag': 'read', - }); - }); + Future checkSnackBar(WidgetTester tester, {required bool expected}) async { + if (!expected) { + check(tester.widgetList(find.byType(SnackBar))).isEmpty(); + return; + } + final snackBar = tester.widget(find.byType(SnackBar)); + check(snackBar.behavior).equals(SnackBarBehavior.floating); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(snackBar.content), matching: find.text('Text copied'))); + } - testWidgets('MentionsNarrow on legacy server', (tester) async { - const narrow = MentionsNarrow(); - final message = eg.streamMessage(flags: [MessageFlag.mentioned]); - final unreadMsgs = eg.unreadMsgs(mentions: [message.id]); - await prepare(tester, unreadMsgs: unreadMsgs); - connection.zulipFeatureLevel = 154; - connection.prepare(json: - UpdateMessageFlagsResult(messages: [message.id]).toJson()); - final future = markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags') - ..bodyFields.deepEquals({ - 'messages': jsonEncode([message.id]), - 'op': 'add', - 'flag': 'read', - }); - }); - }); + Future checkClipboardText(String expected) async { + check(await Clipboard.getData('text/plain')).isNotNull().text.equals(expected); + } - group('updateMessageFlagsStartingFromAnchor', () { - String onCompletedMessage(int count) => 'onCompletedMessage($count)'; - const progressMessage = 'progressMessage'; - const onFailedTitle = 'onFailedTitle'; - final narrow = TopicNarrow.ofMessage(eg.streamMessage()); - final apiNarrow = narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)); - - Future invokeUpdateMessageFlagsStartingFromAnchor() => - updateMessageFlagsStartingFromAnchor( - context: context, - apiNarrow: apiNarrow, - op: UpdateMessageFlagsOp.add, - flag: MessageFlag.read, - includeAnchor: false, - anchor: AnchorCode.oldest, - onCompletedMessage: onCompletedMessage, - onFailedTitle: onFailedTitle, - progressMessage: progressMessage); - - testWidgets('smoke test', (tester) async { - await prepare(tester); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: 1, lastProcessedId: 1980, - foundOldest: true, foundNewest: true).toJson()); - final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); - await tester.pump(Duration.zero); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': 'oldest', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); - check(await didPass).isTrue(); + testWidgets('iOS', (tester) async { + testBinding.deviceInfoResult = const IosDeviceInfo(systemVersion: '16.0'); + await call(tester, text: 'asdf'); + await checkClipboardText('asdf'); + await checkSnackBar(tester, expected: true); + }); + + testWidgets('Android', (tester) async { + testBinding.deviceInfoResult = const AndroidDeviceInfo(sdkInt: 33, release: '13'); + await call(tester, text: 'asdf'); + await checkClipboardText('asdf'); + await checkSnackBar(tester, expected: false); + }); + + testWidgets('Android <13', (tester) async { + testBinding.deviceInfoResult = const AndroidDeviceInfo(sdkInt: 32, release: '12'); + await call(tester, text: 'asdf'); + await checkClipboardText('asdf'); + await checkSnackBar(tester, expected: true); + }); }); - testWidgets('pagination', (tester) async { - // Check that `lastProcessedId` returned from an initial - // response is used as `anchorId` for the subsequent request. - await prepare(tester); - - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 1000, updatedCount: 890, - firstProcessedId: 1, lastProcessedId: 1989, - foundOldest: true, foundNewest: false).toJson()); - final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': 'oldest', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); - - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 20, updatedCount: 10, - firstProcessedId: 2000, lastProcessedId: 2023, - foundOldest: false, foundNewest: true).toJson()); - await tester.pump(Duration.zero); - check(find.bySubtype().evaluate()).length.equals(1); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': '1989', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); - check(await didPass).isTrue(); - }); + group('launchUrl', () { + Future call(WidgetTester tester, {required Uri url}) async { + await tester.pumpWidget(TestZulipApp( + child: Builder(builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + await PlatformActions.launchUrl(context, url); + }, + child: const Text('link')))))); + await tester.pump(); + await tester.tap(find.text('link')); + await tester.pump(Duration.zero); + } - testWidgets('on invalid response', (tester) async { - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - await prepare(tester); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 1000, updatedCount: 0, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: false).toJson()); - final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); - await tester.pump(Duration.zero); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': 'oldest', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); - checkErrorDialog(tester, - expectedTitle: onFailedTitle, - expectedMessage: zulipLocalizations.errorInvalidResponse); - check(await didPass).isFalse(); - }); + final httpUrl = Uri.parse('https://chat.example'); + final nonHttpUrl = Uri.parse('mailto:chat@example'); + + Future runAndCheckSuccess(WidgetTester tester, { + required Uri url, + required UrlLaunchMode expectedModeAndroid, + required UrlLaunchMode expectedModeIos, + }) async { + await call(tester, url: url); + + final expectedMode = switch (defaultTargetPlatform) { + TargetPlatform.android => expectedModeAndroid, + TargetPlatform.iOS => expectedModeIos, + _ => throw StateError('attempted to test with $defaultTargetPlatform'), + }; + check(testBinding.takeLaunchUrlCalls()).single + .equals((url: url, mode: expectedMode)); + } - testWidgets('catch-all api errors', (tester) async { - await prepare(tester); - connection.prepare(exception: http.ClientException('Oops')); - final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); - await tester.pump(Duration.zero); - checkErrorDialog(tester, - expectedTitle: onFailedTitle, - expectedMessage: 'NetworkException: Oops (ClientException: Oops)'); - check(await didPass).isFalse(); + final androidIosVariant = TargetPlatformVariant({TargetPlatform.iOS, TargetPlatform.android}); + + testWidgets('globalSettings.browserPreference is null; use our per-platform defaults for HTTP links', (tester) async { + await testBinding.globalStore.settings.setBrowserPreference(null); + await runAndCheckSuccess(tester, + url: httpUrl, + expectedModeAndroid: UrlLaunchMode.inAppBrowserView, + expectedModeIos: UrlLaunchMode.externalApplication); + }, variant: androidIosVariant); + + testWidgets('globalSettings.browserPreference is null; use our per-platform defaults for non-HTTP links', (tester) async { + await testBinding.globalStore.settings.setBrowserPreference(null); + await runAndCheckSuccess(tester, + url: nonHttpUrl, + expectedModeAndroid: UrlLaunchMode.platformDefault, + expectedModeIos: UrlLaunchMode.externalApplication); + }, variant: androidIosVariant); + + testWidgets('globalSettings.browserPreference is inApp; follow the user preference for http links', (tester) async { + await testBinding.globalStore.settings.setBrowserPreference(BrowserPreference.inApp); + await runAndCheckSuccess(tester, + url: httpUrl, + expectedModeAndroid: UrlLaunchMode.inAppBrowserView, + expectedModeIos: UrlLaunchMode.inAppBrowserView); + }, variant: androidIosVariant); + + testWidgets('globalSettings.browserPreference is inApp; use platform default for non-http links', (tester) async { + await testBinding.globalStore.settings.setBrowserPreference(BrowserPreference.inApp); + await runAndCheckSuccess(tester, + url: nonHttpUrl, + expectedModeAndroid: UrlLaunchMode.platformDefault, + expectedModeIos: UrlLaunchMode.platformDefault); + }, variant: androidIosVariant); + + testWidgets('globalSettings.browserPreference is external; follow the user preference', (tester) async { + await testBinding.globalStore.settings.setBrowserPreference(BrowserPreference.external); + await runAndCheckSuccess(tester, + url: httpUrl, + expectedModeAndroid: UrlLaunchMode.externalApplication, + expectedModeIos: UrlLaunchMode.externalApplication); + }, variant: androidIosVariant); + + testWidgets('ZulipBinding.launchUrl returns false', (tester) async { + testBinding.launchUrlResult = false; + await call(tester, url: httpUrl); + checkErrorDialog(tester, expectedTitle: 'Unable to open link'); + }, variant: androidIosVariant); + + testWidgets('ZulipBinding.launchUrl throws PlatformException', (tester) async { + testBinding.launchUrlException = PlatformException(code: 'code', message: 'error message'); + await call(tester, url: httpUrl); + checkErrorDialog(tester, + expectedTitle: 'Unable to open link', + expectedMessage: 'Link could not be opened: ${httpUrl.toString()}\n\nerror message'); + }, variant: androidIosVariant); }); }); } diff --git a/test/widgets/app_bar_test.dart b/test/widgets/app_bar_test.dart index 099471d4f7..f4178b455a 100644 --- a/test/widgets/app_bar_test.dart +++ b/test/widgets/app_bar_test.dart @@ -31,7 +31,7 @@ void main() { await tester.pumpAndSettle(); final rectBefore = tester.getRect(find.byType(ZulipAppBar)); check(finder.evaluate()).isEmpty(); - store.isLoading = true; + store.isRecoveringEventStream = true; await tester.pump(); check(tester.getRect(find.byType(ZulipAppBar))).equals(rectBefore); diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart index af386bfdf1..efebcc34fa 100644 --- a/test/widgets/app_test.dart +++ b/test/widgets/app_test.dart @@ -4,6 +4,7 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/log.dart'; +import 'package:zulip/model/actions.dart'; import 'package:zulip/model/database.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/home.dart'; @@ -16,7 +17,7 @@ import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; import 'dialog_checks.dart'; -import 'page_checks.dart'; +import 'checks.dart'; import 'test_app.dart'; void main() { @@ -36,24 +37,167 @@ void main() { } testWidgets('when no accounts, go to choose account', (tester) async { + check(testBinding.globalStore).accounts.isEmpty(); + check(testBinding.globalStore).lastVisitedAccount.isNull(); await prepare(tester); check(pushedRoutes).deepEquals(>[ (it) => it.isA().page.isA(), ]); }); - testWidgets('when have accounts, go to home page for first account', (tester) async { - // We'll need per-account data for the account that a page will be opened - // for, but not for the other account. + group('when have accounts', () { + testWidgets('with account(s) visited, go to home page for the last visited account', (tester) async { + await testBinding.globalStore.insertAccount(eg.otherAccount.toCompanion(false)); + // We'll need per-account data for the account that a page will be opened + // for, but not for the other accounts. + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await testBinding.globalStore.insertAccount(eg.thirdAccount.toCompanion(false)); + check(testBinding.globalStore).lastVisitedAccount.equals(eg.selfAccount); + await prepare(tester); + + check(pushedRoutes).deepEquals(>[ + (it) => it.isA() + ..accountId.equals(eg.selfAccount.id) + ..page.isA(), + ]); + }); + + testWidgets('with last visited account logged out, go to choose account', (tester) async { + await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false)); + await testBinding.globalStore.setLastVisitedAccount(eg.selfAccount.id); + await testBinding.globalStore.insertAccount(eg.otherAccount.toCompanion(false)); + check(testBinding.globalStore).lastVisitedAccount.equals(eg.selfAccount); + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); + await tester.pump(TestGlobalStore.removeAccountDuration); + await future; + check(testBinding.globalStore).lastVisitedAccount.isNull(); + check(testBinding.globalStore).accounts.isNotEmpty(); + await prepare(tester); + + check(pushedRoutes).deepEquals(>[ + (it) => it.isA().page.isA(), + ]); + }); + }); + }); + + group('_PreventEmptyStack', () { + late List> pushedRoutes; + late List> removedRoutes; + late List> poppedRoutes; + + Future prepare(WidgetTester tester) async { + addTearDown(testBinding.reset); + + pushedRoutes = []; + removedRoutes = []; + poppedRoutes = []; + final testNavObserver = TestNavigatorObserver(); + testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route); + testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route); + testNavObserver.onPopped = (route, prevRoute) => poppedRoutes.add(route); + + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + await tester.pump(); // start to load account + check(pushedRoutes).single.isA().page.isA(); + pushedRoutes.clear(); + } + + testWidgets('push route when removing last route on stack', (tester) async { await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await testBinding.globalStore.insertAccount(eg.otherAccount.toCompanion(false)); await prepare(tester); + // The navigator stack should contain only a home page route. + + // Log out, causing the home page to be removed from the stack. + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); + await tester.pump(TestGlobalStore.removeAccountDuration); + await future; + check(testBinding.globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + // The choose-account page should appear. + check(removedRoutes).single.isA().page.isA(); + check(pushedRoutes).single.isA().page.isA(); + }); - check(pushedRoutes).deepEquals(>[ - (it) => it.isA() - ..accountId.equals(eg.selfAccount.id) - ..page.isA(), - ]); + testWidgets('push route when popping last route on stack', (tester) async { + // Set up the loading of per-account data to fail. + await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false)); + await testBinding.globalStore.setLastVisitedAccount(eg.selfAccount.id); + testBinding.globalStore.loadPerAccountDuration = Duration.zero; + testBinding.globalStore.loadPerAccountException = eg.apiExceptionUnauthorized(); + await prepare(tester); + // The navigator stack should contain only a home page route. + + // Await the failed load, causing the home page to be removed + // and an error dialog pushed in its place. + await tester.pump(Duration.zero); + await tester.pump(TestGlobalStore.removeAccountDuration); + check(testBinding.globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + check(removedRoutes).single.isA().page.isA(); + check(poppedRoutes).isEmpty(); + check(pushedRoutes).single.isA>(); + pushedRoutes.clear(); + + // Dismiss the error dialog, causing it to be popped from the stack. + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Could not connect', + expectedMessage: + 'Your account at ${eg.selfAccount.realmUrl} could not be authenticated.' + ' Please try logging in again or use another account.'))); + // The choose-account page should appear, because the error dialog + // was the only route remaining. + check(poppedRoutes).single.isA>(); + check(pushedRoutes).single.isA().page.isA(); + }); + + testWidgets('do not push route to non-empty navigator stack', (tester) async { + // Set up the loading of per-account data to fail, but only after a + // long enough time for the "Try another account" button to appear. + const loadPerAccountDuration = Duration(seconds: 30); + assert(loadPerAccountDuration > kTryAnotherAccountWaitPeriod); + await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false)); + await testBinding.globalStore.setLastVisitedAccount(eg.selfAccount.id); + testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; + testBinding.globalStore.loadPerAccountException = eg.apiExceptionUnauthorized(); + await prepare(tester); + // The navigator stack should contain only a home page route. + + // Await the "Try another account" button, and tap it. + await tester.pump(kTryAnotherAccountWaitPeriod); + await tester.tap(find.text('Try another account')); + await tester.pump(); + // The navigator stack should contain the home page route + // and a choose-account page route. + check(removedRoutes).isEmpty(); + check(poppedRoutes).isEmpty(); + check(pushedRoutes).single.isA().page.isA(); + pushedRoutes.clear(); + + // Now await the failed load, causing the home page to be removed + // and an error dialog pushed, while the choose-account page remains. + await tester.pump(loadPerAccountDuration); + await tester.pump(TestGlobalStore.removeAccountDuration); + check(testBinding.globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + check(removedRoutes).single.isA().page.isA(); + check(poppedRoutes).isEmpty(); + check(pushedRoutes).single.isA>(); + pushedRoutes.clear(); + // The navigator stack should now contain the choose-account page route + // and the dialog route. + + // Dismiss the error dialog, causing it to be popped from the stack. + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Could not connect', + expectedMessage: + 'Your account at ${eg.selfAccount.realmUrl} could not be authenticated.' + ' Please try logging in again or use another account.'))); + // No routes should be pushed after dismissing the error dialog, + // because there was already another route remaining on the stack + // (namely the choose-account page route). + check(poppedRoutes).single.isA>(); + check(pushedRoutes).isEmpty(); }); }); @@ -162,7 +306,9 @@ void main() { testWidgets('choosing an account clears the navigator stack', (tester) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await testBinding.globalStore.add(eg.otherAccount, eg.initialSnapshot()); + await testBinding.globalStore.add( + eg.otherAccount, eg.initialSnapshot(realmUsers: [eg.otherUser]), + markLastVisited: false); final pushedRoutes = >[]; final poppedRoutes = >[]; @@ -199,6 +345,27 @@ void main() { ..page.isA(); }); + testWidgets('choosing an account changes the last visited account', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await testBinding.globalStore.add( + eg.otherAccount, eg.initialSnapshot(realmUsers: [eg.otherUser]), + markLastVisited: false); + + await tester.pumpWidget(ZulipApp()); + await tester.pump(); + + final navigator = await ZulipApp.navigator; + unawaited(navigator.push(MaterialWidgetRoute(page: const ChooseAccountPage()))); + await tester.pump(); + await tester.pump(); + + check(testBinding.globalStore).lastVisitedAccount.equals(eg.selfAccount); + await tester.tap(find.text(eg.otherAccount.email)); + await tester.pump(); + check(testBinding.globalStore).lastVisitedAccount.equals(eg.otherAccount); + }); + group('log out', () { Future<(Widget, Widget)> prepare(WidgetTester tester, {required Account account}) async { await setupChooseAccountPage(tester, accounts: [account]); @@ -245,7 +412,9 @@ void main() { check(ZulipApp.scaffoldMessenger).isNotNull(); check(ZulipApp.ready).value.isTrue(); }); + }); + group('error reporting', () { Finder findSnackBarByText(String text) => find.descendant( of: find.byType(SnackBar), matching: find.text(text)); @@ -280,14 +449,14 @@ void main() { check(ZulipApp.ready).value.isFalse(); await tester.pump(); check(findSnackBarByText(message).evaluate()).isEmpty(); - checkNoErrorDialog(tester); + checkNoDialog(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; - checkNoErrorDialog(tester); + checkNoDialog(tester); // Open the error details dialog. await tester.tap(find.text('Details')); @@ -307,7 +476,7 @@ void main() { check(findSnackBarByText(message).evaluate()).single; } - testWidgets('reportErrorToUser dismissing SnackBar', (tester) async { + testWidgets('reportErrorToUserBriefly dismissing SnackBar', (tester) async { const message = 'test error message'; const details = 'error details'; await prepareSnackBarWithDetails(tester, message, details); @@ -361,5 +530,24 @@ void main() { await tester.pumpAndSettle(); check(findSnackBarByText('unrelated').evaluate()).single; }); + + testWidgets('reportErrorToUserModally', (tester) async { + addTearDown(testBinding.reset); + await tester.pumpWidget(const ZulipApp()); + const title = 'test title'; + const message = 'test message'; + + // Prior to app startup, reportErrorToUserModally only logs. + reportErrorToUserModally(title, message: message); + check(ZulipApp.ready).value.isFalse(); + await tester.pump(); + checkNoDialog(tester); + + check(ZulipApp.ready).value.isTrue(); + // After app startup, reportErrorToUserModally displays an [AlertDialog]. + reportErrorToUserModally(title, message: message); + await tester.pump(); + checkErrorDialog(tester, expectedTitle: title, expectedMessage: message); + }); }); } diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 3f3c32bd59..ddcfc26036 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -7,15 +7,17 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; +import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/compose_box.dart'; -import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -25,6 +27,8 @@ import '../model/test_store.dart'; import '../test_images.dart'; import 'test_app.dart'; +late PerAccountStore store; + /// Simulates loading a [MessageListPage] and tapping to focus the compose input. /// /// Also adds [users] to the [PerAccountStore], @@ -44,7 +48,7 @@ Future setupToComposeInput(WidgetTester tester, { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([eg.selfUser, eg.otherUser]); await store.addUsers(users); final connection = store.connection as FakeApiConnection; @@ -99,9 +103,11 @@ Future setupToComposeInput(WidgetTester tester, { /// Returns a [Finder] for the topic input's [TextField]. Future setupToTopicInput(WidgetTester tester, { required List topics, + String? realmEmptyTopicDisplayName, }) async { addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + realmEmptyTopicDisplayName: realmEmptyTopicDisplayName)); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUser(eg.selfUser); final connection = store.connection as FakeApiConnection; @@ -143,13 +149,16 @@ typedef ExpectedEmoji = (String label, EmojiDisplay display); void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; group('@-mentions', () { - void checkUserShown(User user, PerAccountStore store, {required bool expected}) { - check(find.text(user.fullName).evaluate().length).equals(expected ? 1 : 0); - final avatarFinder = - findNetworkImage(store.tryResolveUrl(user.avatarUrl!).toString()); - check(avatarFinder.evaluate().length).equals(expected ? 1 : 0); + + Finder findAvatarImage(int userId) => + find.byWidgetPredicate((widget) => widget is AvatarImage && widget.userId == userId); + + void checkUserShown(User user, {required bool expected}) { + check(find.text(user.fullName)).findsExactly(expected ? 1 : 0); + check(findAvatarImage(user.userId)).findsExactly(expected ? 1 : 0); } testWidgets('user options appear, disappear, and change correctly', (tester) async { @@ -166,43 +175,88 @@ void main() { await tester.pumpAndSettle(); // async computation; options appear // "User Two" and "User Three" appear, but not "User One" - checkUserShown(user1, store, expected: false); - checkUserShown(user2, store, expected: true); - checkUserShown(user3, store, expected: true); + checkUserShown(user1, expected: false); + checkUserShown(user2, expected: true); + checkUserShown(user3, expected: true); // Finishing autocomplete updates compose box; causes options to disappear await tester.tap(find.text('User Three')); await tester.pump(); check(tester.widget(composeInputFinder).controller!.text) - .contains(userMention(user3, users: store.users)); - checkUserShown(user1, store, expected: false); - checkUserShown(user2, store, expected: false); - checkUserShown(user3, store, expected: false); + .contains(userMention(user3, users: store)); + checkUserShown(user1, expected: false); + checkUserShown(user2, expected: false); + checkUserShown(user3, expected: false); // Then a new autocomplete intent brings up options again // TODO(#226): Remove this extra edit when this bug is fixed. await tester.enterText(composeInputFinder, 'hello @user tw'); await tester.enterText(composeInputFinder, 'hello @user two'); await tester.pumpAndSettle(); // async computation; options appear - checkUserShown(user2, store, expected: true); + checkUserShown(user2, expected: true); // Removing autocomplete intent causes options to disappear // TODO(#226): Remove one of these edits when this bug is fixed. await tester.enterText(composeInputFinder, ''); await tester.enterText(composeInputFinder, ' '); - checkUserShown(user1, store, expected: false); - checkUserShown(user2, store, expected: false); - checkUserShown(user3, store, expected: false); + checkUserShown(user1, expected: false); + checkUserShown(user2, expected: false); + checkUserShown(user3, expected: false); debugNetworkImageHttpClientProvider = null; }); + group('User status', () { + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(MentionAutocompleteItem))).findsOne(); + } + + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(fullName: 'User'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + // // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @u'); + await tester.enterText(composeInputFinder, 'hello @'); + await tester.pumpAndSettle(); // async computation; options appear + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(fullName: 'User'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + // // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @u'); + await tester.enterText(composeInputFinder, 'hello @'); + await tester.pumpAndSettle(); // async computation; options appear + + check(find.textContaining('Busy')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + }); + void checkWildcardShown(WildcardMentionOption wildcard, {required bool expected}) { - 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); + check(find.text(wildcard.canonicalString)).findsExactly(expected ? 1 : 0); } testWidgets('wildcard options appear, disappear, and change correctly', (tester) async { @@ -223,8 +277,7 @@ void main() { 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.tap(find.text(WildcardMentionOption.channel.canonicalString)); await tester.pump(); check(tester.widget(composeInputFinder).controller!.text) .contains(wildcardMention(WildcardMentionOption.channel, store: store)); @@ -236,6 +289,67 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + + group('sublabel', () { + Finder findLabelsForItem({required Finder itemFinder}) { + final itemColumn = find.ancestor( + of: itemFinder, + matching: find.byType(Column), + ).first; + return find.descendant(of: itemColumn, matching: find.byType(Text)); + } + + testWidgets('no sublabel when delivery email is unavailable', (tester) async { + final user = eg.user(fullName: 'User One', deliveryEmail: null); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @user '); + await tester.enterText(composeInputFinder, 'hello @user o'); + await tester.pumpAndSettle(); // async computation; options appear + + checkUserShown(user, expected: true); + check(find.text(user.email)).findsNothing(); + check(findLabelsForItem( + itemFinder: find.text(user.fullName))).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('show sublabel when delivery email is available', (tester) async { + final user = eg.user(fullName: 'User One', deliveryEmail: 'email1@email.com'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @user '); + await tester.enterText(composeInputFinder, 'hello @user o'); + await tester.pumpAndSettle(); // async computation; options appear + + checkUserShown(user, expected: true); + check(find.text(user.deliveryEmail!)).findsOne(); + check(findLabelsForItem( + itemFinder: find.text(user.fullName))).findsExactly(2); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('show sublabel for wildcard mention items', (tester) async { + final composeInputFinder = await setupToComposeInput(tester, + narrow: const ChannelNarrow(1)); + + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, '@chann'); + await tester.enterText(composeInputFinder, '@channe'); + await tester.pumpAndSettle(); // async computation; options appear + + checkWildcardShown(WildcardMentionOption.channel, expected: true); + check(find.text('Notify channel')).findsOne(); + check(findLabelsForItem( + itemFinder: find.text('channel'))).findsExactly(2); + + debugNetworkImageHttpClientProvider = null; + }); + }); }); group('emoji', () { @@ -334,16 +448,11 @@ void main() { }); group('TopicAutocomplete', () { - void checkTopicShown(GetStreamTopicsEntry topic, PerAccountStore store, {required bool expected}) { - check(find.text(topic.name.displayName).evaluate().length).equals(expected ? 1 : 0); - } - testWidgets('options appear, disappear, and change correctly', (WidgetTester tester) async { final topic1 = eg.getStreamTopicsEntry(maxId: 1, name: 'Topic one'); final topic2 = eg.getStreamTopicsEntry(maxId: 2, name: 'Topic two'); final topic3 = eg.getStreamTopicsEntry(maxId: 3, name: 'Topic three'); final topicInputFinder = await setupToTopicInput(tester, topics: [topic1, topic2, topic3]); - 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. @@ -352,24 +461,24 @@ void main() { await tester.pumpAndSettle(); // "topic three" and "topic two" appear, but not "topic one" - checkTopicShown(topic1, store, expected: false); - checkTopicShown(topic2, store, expected: true); - checkTopicShown(topic3, store, expected: true); + check(find.text('Topic one' )).findsNothing(); + check(find.text('Topic two' )).findsOne(); + check(find.text('Topic three')).findsOne(); // Finishing autocomplete updates topic box; causes options to disappear await tester.tap(find.text('Topic three')); await tester.pumpAndSettle(); check(tester.widget(topicInputFinder).controller!.text) - .equals(topic3.name.displayName); - checkTopicShown(topic1, store, expected: false); - checkTopicShown(topic2, store, expected: false); - checkTopicShown(topic3, store, expected: true); // shown in `_TopicInput` once + .equals(topic3.name.displayName!); + check(find.text('Topic one' )).findsNothing(); + check(find.text('Topic two' )).findsNothing(); + check(find.text('Topic three')).findsOne(); // shown in `_TopicInput` once // Then a new autocomplete intent brings up options again await tester.enterText(topicInputFinder, 'Topic'); await tester.enterText(topicInputFinder, 'Topic T'); await tester.pumpAndSettle(); - checkTopicShown(topic2, store, expected: true); + check(find.text('Topic two')).findsOne(); }); testWidgets('text selection is reset on choosing an option', (tester) async { @@ -406,5 +515,47 @@ void main() { await tester.pump(Duration.zero); }); + + testWidgets('display realmEmptyTopicDisplayName for empty topic', (tester) async { + final topic = eg.getStreamTopicsEntry(name: ''); + final topicInputFinder = await setupToTopicInput(tester, topics: [topic], + realmEmptyTopicDisplayName: 'some display name'); + + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(topicInputFinder, ' '); + await tester.enterText(topicInputFinder, ''); + await tester.pumpAndSettle(); + + check(find.text('some display name')).findsOne(); + }); + + testWidgets('match realmEmptyTopicDisplayName in autocomplete', (tester) async { + final topic = eg.getStreamTopicsEntry(name: ''); + final topicInputFinder = await setupToTopicInput(tester, topics: [topic], + realmEmptyTopicDisplayName: 'general chat'); + + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(topicInputFinder, 'general ch'); + await tester.enterText(topicInputFinder, 'general cha'); + await tester.pumpAndSettle(); + + check(find.text('general chat')).findsOne(); + }); + + testWidgets('autocomplete to realmEmptyTopicDisplayName sets topic to empty string', (tester) async { + final topic = eg.getStreamTopicsEntry(name: ''); + final topicInputFinder = await setupToTopicInput(tester, topics: [topic], + realmEmptyTopicDisplayName: 'general chat'); + final controller = tester.widget(topicInputFinder).controller!; + + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(topicInputFinder, 'general ch'); + await tester.enterText(topicInputFinder, 'general cha'); + await tester.pumpAndSettle(); + + await tester.tap(find.text('general chat')); + await tester.pump(Duration.zero); + check(controller.value).text.equals(''); + }); }); } diff --git a/test/widgets/button_test.dart b/test/widgets/button_test.dart new file mode 100644 index 0000000000..62f2fad7d1 --- /dev/null +++ b/test/widgets/button_test.dart @@ -0,0 +1,99 @@ +import 'dart:math'; + +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:legacy_checks/legacy_checks.dart'; +import 'package:zulip/widgets/button.dart'; + +import '../flutter_checks.dart'; +import '../model/binding.dart'; +import 'test_app.dart'; +import 'text_test.dart'; + + +void main() { + TestZulipBinding.ensureInitialized(); + + group('ZulipWebUiKitButton', () { + void testVerticalOuterPadding({required ZulipWebUiKitButtonSize sizeVariant}) { + final textScaleFactorVariants = ValueVariant(Set.of(kTextScaleFactors)); + T forSizeVariant(T small, T normal) => + switch (sizeVariant) { + ZulipWebUiKitButtonSize.small => small, + ZulipWebUiKitButtonSize.normal => normal, + }; + + testWidgets('vertical outer padding is preserved as text scales; $sizeVariant', (tester) async { + addTearDown(testBinding.reset); + tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final buttonFinder = find.byType(ZulipWebUiKitButton); + + await tester.pumpWidget(TestZulipApp( + child: UnconstrainedBox( + child: ZulipWebUiKitButton( + label: 'Cancel', + onPressed: () {}, + size: sizeVariant)))); + await tester.pump(); + + final element = tester.element(buttonFinder); + final renderObject = element.renderObject as RenderBox; + final size = renderObject.size; + check(size).height.equals(44); // includes outer padding + + final textScaler = TextScaler.linear(textScaleFactorVariants.currentValue!) + .clamp(maxScaleFactor: 1.5); + final expectedButtonHeight = max(forSizeVariant(24.0, 28.0), // configured min height + (textScaler.scale(forSizeVariant(16, 17) * forSizeVariant(1, 1.20)).roundToDouble() // text height + + 4 + 4)); // vertical padding + + // Rounded rectangle paints with the intended height… + final expectedRRect = RRect.fromLTRBR( + 0, 0, // zero relative to the position at this paint step + size.width, expectedButtonHeight, Radius.circular(forSizeVariant(6, 4))); + check(renderObject).legacyMatcher( + // `paints` isn't a [Matcher] so we wrap it with `equals`; + // awkward but it works + equals(paints..drrect(outer: expectedRRect))); + + // …and that height leaves at least 4px for vertical outer padding. + check(expectedButtonHeight).isLessOrEqual(44 - 2 - 2); + }, variant: textScaleFactorVariants); + + testWidgets('vertical outer padding responds to taps, not just painted area', (tester) async { + addTearDown(testBinding.reset); + tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final buttonFinder = find.byType(ZulipWebUiKitButton); + + int numTapsHandled = 0; + await tester.pumpWidget(TestZulipApp( + child: UnconstrainedBox( + child: ZulipWebUiKitButton( + label: 'Cancel', + onPressed: () => numTapsHandled++)))); + await tester.pump(); + + final element = tester.element(buttonFinder); + final renderObject = element.renderObject as RenderBox; + final size = renderObject.size; + check(size).height.equals(44); // includes outer padding + + // Outer padding responds to taps, not just the painted part. + final buttonCenter = tester.getCenter(buttonFinder); + int numTaps = 0; + for (double y = -22; y < 22; y++) { + await tester.tapAt(buttonCenter + Offset(0, y)); + numTaps++; + } + check(numTapsHandled).equals(numTaps); + }, variant: textScaleFactorVariants); + } + testVerticalOuterPadding(sizeVariant: ZulipWebUiKitButtonSize.small); + testVerticalOuterPadding(sizeVariant: ZulipWebUiKitButtonSize.normal); + }); +} diff --git a/test/widgets/channel_colors_checks.dart b/test/widgets/channel_colors_checks.dart deleted file mode 100644 index 8c9e8e37ec..0000000000 --- a/test/widgets/channel_colors_checks.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'dart:ui'; - -import 'package:checks/checks.dart'; -import 'package:zulip/widgets/channel_colors.dart'; - -extension ChannelColorSwatchChecks on Subject { - Subject get base => has((s) => s.base, 'base'); - Subject get unreadCountBadgeBackground => has((s) => s.unreadCountBadgeBackground, 'unreadCountBadgeBackground'); - Subject get iconOnPlainBackground => has((s) => s.iconOnPlainBackground, 'iconOnPlainBackground'); - Subject get iconOnBarBackground => has((s) => s.iconOnBarBackground, 'iconOnBarBackground'); - Subject get barBackground => has((s) => s.barBackground, 'barBackground'); -} diff --git a/test/widgets/channel_colors_test.dart b/test/widgets/channel_colors_test.dart index 7657ffc236..c25707508a 100644 --- a/test/widgets/channel_colors_test.dart +++ b/test/widgets/channel_colors_test.dart @@ -4,8 +4,7 @@ import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/widgets/channel_colors.dart'; -import 'channel_colors_checks.dart'; -import 'colors_checks.dart'; +import 'checks.dart'; void main() { group('ChannelColorSwatches', () { diff --git a/test/widgets/checks.dart b/test/widgets/checks.dart new file mode 100644 index 0000000000..5383aa7583 --- /dev/null +++ b/test/widgets/checks.dart @@ -0,0 +1,104 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/widgets.dart'; +import 'package:zulip/api/route/realm.dart'; + +import 'package:zulip/model/emoji.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/widgets/channel_colors.dart'; +import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/emoji.dart'; +import 'package:zulip/widgets/emoji_reaction.dart'; +import 'package:zulip/widgets/login.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; +import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/store.dart'; +import 'package:zulip/widgets/unread_count_badge.dart'; +import 'package:zulip/widgets/user.dart'; + +extension ChannelColorSwatchChecks on Subject { + Subject get base => has((s) => s.base, 'base'); + Subject get unreadCountBadgeBackground => has((s) => s.unreadCountBadgeBackground, 'unreadCountBadgeBackground'); + Subject get iconOnPlainBackground => has((s) => s.iconOnPlainBackground, 'iconOnPlainBackground'); + Subject get iconOnBarBackground => has((s) => s.iconOnBarBackground, 'iconOnBarBackground'); + Subject get barBackground => has((s) => s.barBackground, 'barBackground'); +} + +extension ComposeBoxStateChecks on Subject { + Subject get controller => has((c) => c.controller, 'controller'); +} + +extension ComposeBoxControllerChecks on Subject { + Subject get content => has((c) => c.content, 'content'); + Subject get contentFocusNode => has((c) => c.contentFocusNode, 'contentFocusNode'); +} + +extension StreamComposeBoxControllerChecks on Subject { + Subject get topic => has((c) => c.topic, 'topic'); + Subject get topicFocusNode => has((c) => c.topicFocusNode, 'topicFocusNode'); +} + +extension EditMessageComposeBoxControllerChecks on Subject { + Subject get messageId => has((c) => c.messageId, 'messageId'); + Subject get originalRawContent => has((c) => c.originalRawContent, 'originalRawContent'); +} + +extension ComposeContentControllerChecks on Subject { + Subject> get validationErrors => has((c) => c.validationErrors, 'validationErrors'); +} + +extension RealmContentNetworkImageChecks on Subject { + Subject get src => has((i) => i.src, 'src'); + // TODO others +} + +extension AvatarImageChecks on Subject { + Subject get userId => has((i) => i.userId, 'userId'); +} + +extension AvatarShapeChecks on Subject { + Subject get size => has((i) => i.size, 'size'); + Subject get borderRadius => has((i) => i.borderRadius, 'borderRadius'); + Subject get child => has((i) => i.child, 'child'); +} + +extension MessageListPageChecks on Subject { + Subject get initNarrow => has((x) => x.initNarrow, 'initNarrow'); + Subject get initAnchorMessageId => has((x) => x.initAnchorMessageId, 'initAnchorMessageId'); +} + +extension WidgetRouteChecks on Subject> { + Subject get page => has((x) => x.page, 'page'); +} + +extension AccountRouteChecks on Subject> { + Subject get accountId => has((x) => x.accountId, 'accountId'); +} + +extension LoginPageChecks on Subject { + Subject get serverSettings => has((x) => x.serverSettings, 'serverSettings'); +} + +extension ProfilePageChecks on Subject { + Subject get userId => has((x) => x.userId, 'userId'); +} + +extension PerAccountStoreWidgetChecks on Subject { + Subject get accountId => has((x) => x.accountId, 'accountId'); + Subject get child => has((x) => x.child, 'child'); +} + +extension UnreadCountBadgeChecks on Subject { + Subject get count => has((b) => b.count, 'count'); + Subject get bold => has((b) => b.bold, 'bold'); + Subject get backgroundColor => has((b) => b.backgroundColor, 'backgroundColor'); +} + +extension UnicodeEmojiWidgetChecks on Subject { + Subject get emojiDisplay => has((x) => x.emojiDisplay, 'emojiDisplay'); +} + +extension EmojiPickerListEntryChecks on Subject { + Subject get emoji => has((x) => x.emoji, 'emoji'); +} diff --git a/test/widgets/clipboard_test.dart b/test/widgets/clipboard_test.dart deleted file mode 100644 index 1c0da04284..0000000000 --- a/test/widgets/clipboard_test.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:zulip/model/binding.dart'; -import 'package:zulip/widgets/clipboard.dart'; - -import '../flutter_checks.dart'; -import '../model/binding.dart'; -import '../test_clipboard.dart'; -import 'test_app.dart'; - -void main() { - TestZulipBinding.ensureInitialized(); - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - MockClipboard().handleMethodCall, - ); - }); - - tearDown(() async { - testBinding.reset(); - }); - - group('copyWithPopup', () { - Future call(WidgetTester tester, {required String text}) async { - await tester.pumpWidget(TestZulipApp( - child: Scaffold( - body: Builder(builder: (context) => Center( - child: ElevatedButton( - onPressed: () async { - copyWithPopup(context: context, successContent: const Text('Text copied'), - data: ClipboardData(text: text)); - }, - child: const Text('Copy'))))))); - await tester.pump(); - await tester.tap(find.text('Copy')); - await tester.pump(); // copy - await tester.pump(Duration.zero); // await platform info (awkwardly async) - } - - Future checkSnackBar(WidgetTester tester, {required bool expected}) async { - if (!expected) { - check(tester.widgetList(find.byType(SnackBar))).isEmpty(); - return; - } - final snackBar = tester.widget(find.byType(SnackBar)); - check(snackBar.behavior).equals(SnackBarBehavior.floating); - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(snackBar.content), matching: find.text('Text copied'))); - } - - Future checkClipboardText(String expected) async { - check(await Clipboard.getData('text/plain')).isNotNull().text.equals(expected); - } - - testWidgets('iOS', (tester) async { - testBinding.deviceInfoResult = const IosDeviceInfo(systemVersion: '16.0'); - await call(tester, text: 'asdf'); - await checkClipboardText('asdf'); - await checkSnackBar(tester, expected: true); - }); - - testWidgets('Android', (tester) async { - testBinding.deviceInfoResult = const AndroidDeviceInfo(sdkInt: 33, release: '13'); - await call(tester, text: 'asdf'); - await checkClipboardText('asdf'); - await checkSnackBar(tester, expected: false); - }); - - testWidgets('Android <13', (tester) async { - testBinding.deviceInfoResult = const AndroidDeviceInfo(sdkInt: 32, release: '12'); - await call(tester, text: 'asdf'); - await checkClipboardText('asdf'); - await checkSnackBar(tester, expected: true); - }); - }); -} diff --git a/test/widgets/colors_checks.dart b/test/widgets/colors_checks.dart deleted file mode 100644 index 10af11810e..0000000000 --- a/test/widgets/colors_checks.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:flutter_test/flutter_test.dart' as flutter_matcher; -import 'package:flutter/painting.dart'; -import 'package:legacy_checks/legacy_checks.dart'; - -extension ColorSwatchChecks on Subject> { - /// package:checks-style wrapper for [flutter_matcher.isSameColorSwatchAs]. - void isSameColorSwatchAs(ColorSwatch colorSwatch) { - legacyMatcher(flutter_matcher.isSameColorSwatchAs(colorSwatch)); - } -} diff --git a/test/widgets/compose_box_checks.dart b/test/widgets/compose_box_checks.dart deleted file mode 100644 index 8008b510d3..0000000000 --- a/test/widgets/compose_box_checks.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:zulip/widgets/compose_box.dart'; - -extension ComposeContentControllerChecks on Subject { - Subject> get validationErrors => has((c) => c.validationErrors, 'validationErrors'); -} diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index f1eb9bb3ba..816ade42a7 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -3,23 +3,29 @@ import 'dart:convert'; import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; +import 'package:crypto/crypto.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/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:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; import 'package:image_picker/image_picker.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/button.dart'; import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/theme.dart'; @@ -28,17 +34,24 @@ 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 '../model/typing_status_test.dart'; import '../stdlib_checks.dart'; +import 'checks.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; late PerAccountStore store; late FakeApiConnection connection; + late ComposeBoxState state; + + // Caution: when testing edit-message UI, this will often be stale; + // read state.controller instead. late ComposeBoxController? controller; Future prepareComposeBox(WidgetTester tester, { @@ -46,46 +59,65 @@ void main() { User? selfUser, List otherUsers = const [], List streams = const [], + List? messages, bool? mandatoryTopics, + int? zulipFeatureLevel, }) async { if (narrow case ChannelNarrow(:var streamId) || TopicNarrow(: var streamId)) { - assert(streams.any((stream) => stream.streamId == streamId), + final channel = streams.firstWhereOrNull((s) => s.streamId == streamId); + assert(channel != null, 'Add a channel with "streamId" the same as of $narrow.streamId to the store.'); + if (narrow is ChannelNarrow) { + // By default, bypass the complexity where the topic input is autofocused + // on an empty fetch, by making the fetch not empty. (In particular that + // complexity includes a getStreamTopics fetch for topic autocomplete.) + messages ??= [eg.streamMessage(stream: channel)]; + } } addTearDown(testBinding.reset); + messages ??= []; selfUser ??= eg.selfUser; - final selfAccount = eg.account(user: selfUser); + zulipFeatureLevel ??= eg.futureZulipFeatureLevel; + final selfAccount = eg.account(user: selfUser, zulipFeatureLevel: zulipFeatureLevel); await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( + realmUsers: [selfUser, ...otherUsers], + streams: streams, + zulipFeatureLevel: zulipFeatureLevel, realmMandatoryTopics: mandatoryTopics, + realmAllowMessageEditing: true, + realmMessageContentEditLimitSeconds: null, )); store = await testBinding.globalStore.perAccount(selfAccount.id); - await store.addUsers([selfUser, ...otherUsers]); - await store.addStreams(streams); connection = store.connection as FakeApiConnection; + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: messages).toJson()); + if (narrow is ChannelNarrow && messages.isEmpty) { + // The topic input will autofocus, triggering a getStreamTopics request. + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + } await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, - child: Column( - // This positions the compose box at the bottom of the screen, - // simulating the layout of the message list page. - children: [ - const Expanded(child: SizedBox.expand()), - ComposeBox(narrow: narrow), - ]))); + child: MessageListPage(initNarrow: narrow))); await tester.pumpAndSettle(); + connection.takeRequests(); - controller = tester.state(find.byType(ComposeBox)).controller; + state = tester.state(find.byType(ComposeBox)); + controller = state.controller; } + /// A [Finder] for the topic input. + /// + /// To enter some text, use [enterTopic]. + final topicInputFinder = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller is ComposeTopicController); + /// Set the topic input's text to [topic], using [WidgetTester.enterText]. Future enterTopic(WidgetTester tester, { required ChannelNarrow narrow, required String topic, }) async { - final topicInputFinder = find.byWidgetPredicate( - (widget) => widget is TextField && widget.controller is ComposeTopicController); - connection.prepare(body: jsonEncode(GetStreamTopicsResult(topics: [eg.getStreamTopicsEntry()]).toJson())); await tester.enterText(topicInputFinder, topic); @@ -105,12 +137,77 @@ void main() { await tester.enterText(contentInputFinder, content); } + void checkContentInputValue(WidgetTester tester, String expected) { + check(tester.widget(contentInputFinder)) + .controller.isNotNull().value.text.equals(expected); + } + + final sendButtonFinder = find.byIcon(ZulipIcons.send); + Future tapSendButton(WidgetTester tester) async { connection.prepare(json: SendMessageResult(id: 123).toJson()); - await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.tap(sendButtonFinder); await tester.pump(Duration.zero); } + group('auto focus', () { + testWidgets('ChannelNarrow, non-empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + messages: [eg.streamMessage(stream: channel)]); + check(controller).isA() + ..topicFocusNode.hasFocus.isFalse() + ..contentFocusNode.hasFocus.isFalse(); + }); + + testWidgets('ChannelNarrow, empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + messages: []); + check(controller).isA() + .topicFocusNode.hasFocus.isTrue(); + }); + + testWidgets('TopicNarrow, non-empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: TopicNarrow(channel.streamId, eg.t('topic')), + streams: [channel], + messages: [eg.streamMessage(stream: channel, topic: 'topic')]); + check(controller).isNotNull().contentFocusNode.hasFocus.isFalse(); + }); + + testWidgets('TopicNarrow, empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: TopicNarrow(channel.streamId, eg.t('topic')), + streams: [channel], + messages: []); + check(controller).isNotNull().contentFocusNode.hasFocus.isTrue(); + }); + + testWidgets('DmNarrow, non-empty fetch', (tester) async { + final user = eg.user(); + await prepareComposeBox(tester, + selfUser: eg.selfUser, + narrow: DmNarrow.withUser(user.userId, selfUserId: eg.selfUser.userId), + messages: [eg.dmMessage(from: user, to: [eg.selfUser])]); + check(controller).isNotNull().contentFocusNode.hasFocus.isFalse(); + }); + + testWidgets('DmNarrow, empty fetch', (tester) async { + await prepareComposeBox(tester, + selfUser: eg.selfUser, + narrow: DmNarrow.withUser(eg.user().userId, selfUserId: eg.selfUser.userId), + messages: []); + check(controller).isNotNull().contentFocusNode.hasFocus.isTrue(); + }); + }); + group('ComposeBoxTheme', () { test('lerp light to dark, no crash', () { final a = ComposeBoxTheme.light; @@ -220,6 +317,33 @@ void main() { '\n\n^\n\n', 'a\n', '\n\na\n\n^\n'); }); }); + + group('ContentValidationError.empty', () { + late ComposeContentController controller; + + void checkCountsAsEmpty(String text, bool expected) { + controller.value = TextEditingValue(text: text); + expected + ? check(controller).validationErrors.contains(ContentValidationError.empty) + : check(controller).validationErrors.not((it) => it.contains(ContentValidationError.empty)); + } + + testWidgets('requireNotEmpty: true (default)', (tester) async { + controller = ComposeContentController(); + addTearDown(controller.dispose); + checkCountsAsEmpty('', true); + checkCountsAsEmpty(' ', true); + checkCountsAsEmpty('a', false); + }); + + testWidgets('requireNotEmpty: false', (tester) async { + controller = ComposeContentController(requireNotEmpty: false); + addTearDown(controller.dispose); + checkCountsAsEmpty('', false); + checkCountsAsEmpty(' ', false); + checkCountsAsEmpty('a', false); + }); + }); }); group('length validation', () { @@ -246,6 +370,8 @@ void main() { Future prepareWithContent(WidgetTester tester, String content) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); final narrow = ChannelNarrow(channel.streamId); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); @@ -270,7 +396,7 @@ void main() { await prepareWithContent(tester, makeStringWithCodePoints(kMaxMessageLengthCodePoints)); await tapSendButton(tester); - checkNoErrorDialog(tester); + checkNoDialog(tester); }); testWidgets('code points not counted unnecessarily', (tester) async { @@ -283,6 +409,8 @@ void main() { Future prepareWithTopic(WidgetTester tester, String topic) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); final narrow = ChannelNarrow(channel.streamId); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); @@ -307,7 +435,7 @@ void main() { await prepareWithTopic(tester, makeStringWithCodePoints(kMaxTopicLengthCodePoints)); await tapSendButton(tester); - checkNoErrorDialog(tester); + checkNoDialog(tester); }); testWidgets('code points not counted unnecessarily', (tester) async { @@ -318,6 +446,265 @@ void main() { }); }); + group('ComposeBox hintText', () { + final channel = eg.stream(); + + Future prepare(WidgetTester tester, { + required Narrow narrow, + bool? mandatoryTopics, + int? zulipFeatureLevel, + }) async { + await prepareComposeBox(tester, + narrow: narrow, + otherUsers: [eg.otherUser, eg.thirdUser], + streams: [channel], + mandatoryTopics: mandatoryTopics, + zulipFeatureLevel: zulipFeatureLevel); + } + + /// This checks the input's configured hint text without regard to whether + /// it's currently visible, as it won't be if the user has entered some text. + /// + /// If `topicHintText` is `null`, check that the topic input is not present. + void checkComposeBoxHintTexts(WidgetTester tester, { + String? topicHintText, + required String contentHintText, + }) { + if (topicHintText != null) { + check(tester.widget(topicInputFinder)) + .decoration.isNotNull().hintText.equals(topicHintText); + } else { + check(topicInputFinder).findsNothing(); + } + check(tester.widget(contentInputFinder)) + .decoration.isNotNull().hintText.equals(contentHintText); + } + + group('to ChannelNarrow, topics not mandatory', () { + final narrow = ChannelNarrow(channel.streamId); + + testWidgets('with empty topic, topic input has focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('legacy: with empty topic, topic input has focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false, + zulipFeatureLevel: 333); // TODO(server-10) + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic (skip for “(no topic)”)', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('with non-empty but vacuous topic, topic input has focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, + topic: eg.defaultRealmEmptyTopicDisplayName); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('with empty topic, topic input has focus, then content input gains focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + }); + + testWidgets('with empty topic, topic input has focus, then loses it', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + + FocusManager.instance.primaryFocus!.unfocus(); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('with empty topic, content input has focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + check(tester.widget(topicInputFinder)).decoration.isNotNull() + .hintStyle.isNotNull().fontStyle.equals(FontStyle.italic); + }); + + testWidgets('legacy: with empty topic, content input has focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false, + zulipFeatureLevel: 333); + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: '(no topic)', + contentHintText: 'Message #${channel.name} > (no topic)'); + check(tester.widget(topicInputFinder)).decoration.isNotNull() + .hintStyle.isNotNull().fontStyle.isNull(); + }); + + testWidgets('with empty topic, content input has focus, then topic input gains focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('with empty topic, content input has focus, then loses it', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + + FocusManager.instance.primaryFocus!.unfocus(); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + }); + + testWidgets('with non-empty topic', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, topic: 'new topic'); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name} > new topic'); + }); + }); + + group('to ChannelNarrow, mandatory topics', () { + final narrow = ChannelNarrow(channel.streamId); + + testWidgets('with empty topic', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: true); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('legacy: with empty topic', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: true, + zulipFeatureLevel: 333); // TODO(server-10) + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name}'); + }); + + group('with non-empty but vacuous topics', () { + testWidgets('realm_empty_topic_display_name', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: true); + await enterTopic(tester, narrow: narrow, + topic: eg.defaultRealmEmptyTopicDisplayName); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('"(no topic)"', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: true); + await enterTopic(tester, narrow: narrow, + topic: '(no topic)'); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name}'); + }); + }); + + testWidgets('with non-empty topic', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: true); + await enterTopic(tester, narrow: narrow, topic: 'new topic'); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name} > new topic'); + }); + }); + + group('to TopicNarrow', () { + testWidgets('with non-empty topic', (tester) async { + await prepare(tester, + narrow: TopicNarrow(channel.streamId, TopicName('topic'))); + checkComposeBoxHintTexts(tester, + contentHintText: 'Message #${channel.name} > topic'); + }); + + testWidgets('with empty topic', (tester) async { + await prepare(tester, + narrow: TopicNarrow(channel.streamId, TopicName(''))); + checkComposeBoxHintTexts(tester, contentHintText: + 'Message #${channel.name} > ${eg.defaultRealmEmptyTopicDisplayName}'); + }); + }); + + testWidgets('to DmNarrow with self', (tester) async { + await prepare(tester, narrow: DmNarrow.withUser( + eg.selfUser.userId, selfUserId: eg.selfUser.userId)); + checkComposeBoxHintTexts(tester, + contentHintText: 'Jot down something'); + }); + + testWidgets('to 1:1 DmNarrow', (tester) async { + await prepare(tester, narrow: DmNarrow.withUser( + eg.otherUser.userId, selfUserId: eg.selfUser.userId)); + checkComposeBoxHintTexts(tester, + contentHintText: 'Message @${eg.otherUser.fullName}'); + }); + + testWidgets('to group DmNarrow', (tester) async { + await prepare(tester, narrow: DmNarrow.withOtherUsers( + [eg.otherUser.userId, eg.thirdUser.userId], + selfUserId: eg.selfUser.userId)); + checkComposeBoxHintTexts(tester, + contentHintText: 'Message group'); + }); + }); + group('ComposeBox textCapitalization', () { void checkComposeBoxTextFields(WidgetTester tester, { required bool expectTopicTextField, @@ -375,7 +762,7 @@ void main() { await checkStartTyping(tester, narrow); connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); }); @@ -387,7 +774,7 @@ void main() { await checkStartTyping(tester, narrow); connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); }); @@ -400,7 +787,7 @@ void main() { await checkStartTyping(tester, destinationNarrow); connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, destinationNarrow); }); @@ -415,13 +802,15 @@ void main() { }); testWidgets('hitting send button sends a "typing stopped" notice', (tester) async { + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); connection.prepare(json: {}); connection.prepare(json: SendMessageResult(id: 123).toJson()); - await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.tap(sendButtonFinder); await tester.pump(Duration.zero); final requests = connection.takeRequests(); checkSetTypingStatusRequests([requests.first], [(TypingOp.stop, narrow)]); @@ -478,7 +867,7 @@ void main() { await checkStartTyping(tester, narrow); connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); connection.prepare(json: {}); @@ -488,7 +877,7 @@ void main() { // Ensures that a "typing stopped" notice is sent when the test ends. connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); }); @@ -521,6 +910,8 @@ void main() { }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await prepareComposeBox(tester, narrow: eg.topicNarrow(123, 'some topic'), @@ -548,18 +939,13 @@ void main() { await setupAndTapSend(tester, prepareResponse: (int messageId) { connection.prepare(json: SendMessageResult(id: messageId).toJson()); }); - checkNoErrorDialog(tester); + checkNoDialog(tester); }); testWidgets('ZulipApiException', (tester) async { await setupAndTapSend(tester, prepareResponse: (message) { - connection.prepare( - httpStatus: 400, - json: { - 'result': 'error', - 'code': 'BAD_REQUEST', - 'msg': 'You do not have permission to initiate direct message conversations.', - }); + connection.prepare(apiException: eg.apiBadRequest( + message: 'You do not have permission to initiate direct message conversations.')); }); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await tester.tap(find.byWidget(checkErrorDialog(tester, @@ -576,19 +962,23 @@ void main() { Future setupAndTapSend(WidgetTester tester, { required String topicInputText, required bool mandatoryTopics, + int? zulipFeatureLevel, }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); channel = eg.stream(); final narrow = ChannelNarrow(channel.streamId); await prepareComposeBox(tester, narrow: narrow, streams: [channel], - mandatoryTopics: mandatoryTopics); + mandatoryTopics: mandatoryTopics, + zulipFeatureLevel: zulipFeatureLevel); await enterTopic(tester, narrow: narrow, topic: topicInputText); await tester.enterText(contentInputFinder, 'test content'); - await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.tap(sendButtonFinder); await tester.pump(); } @@ -599,10 +989,21 @@ void main() { expectedMessage: 'Topics are required in this organization.'); } - testWidgets('empty topic -> "(no topic)"', (tester) async { + testWidgets('empty 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(''); + }); + + testWidgets('legacy: empty topic -> "(no topic)"', (tester) async { + await setupAndTapSend(tester, + topicInputText: '', + mandatoryTopics: false, + zulipFeatureLevel: 333); check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/messages') @@ -616,6 +1017,13 @@ void main() { checkMessageNotSent(tester); }); + testWidgets('if topics are mandatory, reject `realmEmptyTopicDisplayName`', (tester) async { + await setupAndTapSend(tester, + topicInputText: eg.defaultRealmEmptyTopicDisplayName, + mandatoryTopics: true); + checkMessageNotSent(tester); + }); + testWidgets('if topics are mandatory, reject "(no topic)"', (tester) async { await setupAndTapSend(tester, topicInputText: '(no topic)', @@ -627,7 +1035,7 @@ void main() { group('uploads', () { void checkAppearsLoading(WidgetTester tester, bool expected) { final sendButtonElement = tester.element(find.ancestor( - of: find.byIcon(ZulipIcons.send), + of: sendButtonFinder, matching: find.byType(IconButton))); final sendButtonWidget = sendButtonElement.widget as IconButton; final designVariables = DesignVariables.of(sendButtonElement); @@ -638,20 +1046,24 @@ void main() { .isA().color.isNotNull().isSameColorAs(expectedIconColor); } - group('attach from media library', () { - testWidgets('success', (tester) async { - TypingNotifier.debugEnable = false; - addTearDown(TypingNotifier.debugReset); + Future prepare(WidgetTester tester) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); - final channel = eg.stream(); - final narrow = ChannelNarrow(channel.streamId); - await prepareComposeBox(tester, narrow: narrow, streams: [channel]); + final channel = eg.stream(); + final narrow = ChannelNarrow(channel.streamId); + await prepareComposeBox(tester, narrow: narrow, streams: [channel]); - // (When we check that the send button looks disabled, it should be because - // the file is uploading, not a pre-existing reason.) - await enterTopic(tester, narrow: narrow, topic: 'some topic'); - controller!.content.value = const TextEditingValue(text: 'see image: '); - await tester.pump(); + // (When we check that the send button looks disabled, it should be because + // the file is uploading, not a pre-existing reason.) + await enterTopic(tester, narrow: narrow, topic: 'some topic'); + await enterContent(tester, 'see image: '); + await tester.pump(); + } + + group('attach from media library', () { + testWidgets('success', (tester) async { + await prepare(tester); checkAppearsLoading(tester, false); testBinding.pickFilesResult = FilePickerResult([PlatformFile( @@ -663,7 +1075,7 @@ void main() { size: 12345, )]); connection.prepare(delay: const Duration(seconds: 1), json: - UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); + UploadFileResult(url: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); await tester.tap(find.byIcon(ZulipIcons.image)); await tester.pump(); @@ -671,7 +1083,7 @@ void main() { check(call.allowMultiple).equals(true); check(call.type).equals(FileType.media); - checkNoErrorDialog(tester); + checkNoDialog(tester); check(controller!.content.text) .equals('see image: [Uploading image.jpg…]()\n\n'); @@ -699,18 +1111,7 @@ void main() { group('attach from camera', () { testWidgets('success', (tester) async { - TypingNotifier.debugEnable = false; - addTearDown(TypingNotifier.debugReset); - - final channel = eg.stream(); - final narrow = ChannelNarrow(channel.streamId); - await prepareComposeBox(tester, narrow: narrow, streams: [channel]); - - // (When we check that the send button looks disabled, it should be because - // the file is uploading, not a pre-existing reason.) - await enterTopic(tester, narrow: narrow, topic: 'some topic'); - controller!.content.value = const TextEditingValue(text: 'see image: '); - await tester.pump(); + await prepare(tester); checkAppearsLoading(tester, false); testBinding.pickImageResult = XFile.fromData( @@ -722,7 +1123,7 @@ void main() { path: '/private/var/mobile/Containers/Data/Application/foo/tmp/image.jpg', ); connection.prepare(delay: const Duration(seconds: 1), json: - UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); + UploadFileResult(url: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); await tester.tap(find.byIcon(ZulipIcons.camera)); await tester.pump(); @@ -730,7 +1131,7 @@ void main() { check(call.source).equals(ImageSource.camera); check(call.requestFullMetadata).equals(false); - checkNoErrorDialog(tester); + checkNoDialog(tester); check(controller!.content.text) .equals('see image: [Uploading image.jpg…]()\n\n'); @@ -761,6 +1162,134 @@ void main() { // target platform the test is simulating. // TODO(upstream): unskip after fix to https://github.com/flutter/flutter/issues/161073 skip: Platform.isWindows); + + testWidgets('use verbatim URL string from server, not re-encoded', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1709 + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final channel = eg.stream(); + final narrow = eg.topicNarrow(channel.streamId, 'a topic'); + await prepareComposeBox(tester, narrow: narrow, streams: [channel]); + + testBinding.pickFilesResult = FilePickerResult([PlatformFile( + readStream: Stream.fromIterable(['asdf'.codeUnits]), + path: '/some/path/한국어 파일.txt', + name: '한국어 파일.txt', + size: 4, + )]); + connection.prepare(json: UploadFileResult(url: + '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/한국어 파일.txt').toJson()); + await tester.tap(find.byIcon(ZulipIcons.image)); + await tester.pump(); + check(controller!.content.text) + .equals('[Uploading 한국어 파일.txt…]()\n\n'); + + await tester.pump(Duration.zero); + check(controller!.content.text) + .equals('[한국어 파일.txt](' + '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/한국어 파일.txt)\n\n'); + }); + + group('attach from keyboard', () { + // This is adapted from: + // https://github.com/flutter/flutter/blob/0ffc4ce00/packages/flutter/test/widgets/editable_text_test.dart#L724-L740 + Future insertContentFromKeyboard(WidgetTester tester, { + required List? data, + required String attachedFileUrl, + required String mimeType, + }) async { + await tester.showKeyboard(contentInputFinder); + // This invokes [EditableText.performAction] on the content [TextField], + // which did not expose an API for testing. + // TODO(upstream): support a better API for testing this + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall('TextInputClient.performAction', [ + -1, + 'TextInputAction.commitContent', + // This fakes data originally provided by the Flutter engine: + // https://github.com/flutter/flutter/blob/0ffc4ce00/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java#L497-L548 + { + "mimeType": mimeType, + "data": data, + "uri": attachedFileUrl, + }, + ])), + (ByteData? data) {}); + } + + testWidgets('success', (tester) async { + const fileContent = [1, 0, 1, 0, 0]; + await prepare(tester); + const uploadUrl = '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/test.gif'; + connection.prepare(json: UploadFileResult(url: uploadUrl).toJson()); + await insertContentFromKeyboard(tester, + data: fileContent, + attachedFileUrl: + 'content://com.zulip.android.zulipboard.provider' + '/root/com.zulip.android.zulipboard/candidate_temp/test.gif', + mimeType: 'image/gif'); + + await tester.pump(); + check(controller!.content.text) + .equals('see image: [Uploading test.gif…]()\n\n'); + // (the request is checked more thoroughly in API tests) + check(connection.lastRequest!).isA() + ..method.equals('POST') + ..files.single.which((it) => it + ..field.equals('file') + ..length.equals(fileContent.length) + ..filename.equals('test.gif') + ..contentType.asString.equals('image/gif') + ..has>>((f) => f.finalize().toBytes(), 'contents') + .completes((it) => it.deepEquals(fileContent)) + ); + checkAppearsLoading(tester, true); + + await tester.pump(Duration.zero); + check(controller!.content.text) + .equals('see image: [test.gif]($uploadUrl)\n\n'); + checkAppearsLoading(tester, false); + }); + + testWidgets('data is null', (tester) async { + await prepare(tester); + await insertContentFromKeyboard(tester, + data: null, + attachedFileUrl: + 'content://com.zulip.android.zulipboard.provider' + '/root/com.zulip.android.zulipboard/candidate_temp/test.gif', + mimeType: 'image/jpeg'); + + await tester.pump(); + check(controller!.content.text).equals('see image: '); + check(connection.takeRequests()).isEmpty(); + checkErrorDialog(tester, + expectedTitle: 'Content not inserted', + expectedMessage: 'The file to be inserted is empty or cannot be accessed.'); + checkAppearsLoading(tester, false); + }); + + testWidgets('data is empty', (tester) async { + await prepare(tester); + await insertContentFromKeyboard(tester, + data: [], + attachedFileUrl: + 'content://com.zulip.android.zulipboard.provider' + '/root/com.zulip.android.zulipboard/candidate_temp/test.gif', + mimeType: 'image/jpeg'); + + await tester.pump(); + check(controller!.content.text).equals('see image: '); + check(connection.takeRequests()).isEmpty(); + checkErrorDialog(tester, + expectedTitle: 'Content not inserted', + expectedMessage: 'The file to be inserted is empty or cannot be accessed.'); + checkAppearsLoading(tester, false); + }); + }); }); group('error banner', () { @@ -1033,4 +1562,694 @@ void main() { maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 6); }); }); + + group('ComposeBoxState new-event-queue transition', () { + testWidgets('content input not cleared when store changes', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1470 + + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: eg.topicNarrow(channel.streamId, 'topic'), streams: [channel]); + + await enterContent(tester, 'some content'); + checkContentInputValue(tester, 'some content'); + + // Encache a new connection; prepare it for the message-list fetch + final newConnection = (testBinding.globalStore + ..clearCachedApiConnections() + ..useCachedApiConnections = true) + .apiConnectionFromAccount(store.account) as FakeApiConnection; + newConnection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: []).toJson()); + + store.updateMachine! + ..debugPauseLoop() + ..poll() + ..debugPrepareLoopError( + eg.apiExceptionBadEventQueueId(queueId: store.queueId)) + ..debugAdvanceLoop(); + await tester.pump(); + await tester.pump(Duration.zero); + + final newStore = testBinding.globalStore.perAccountSync(store.accountId)!; + check(newStore) + // a new store has replaced the old one + ..not((it) => it.identicalTo(store)) + // new store has the same boring data, in order to present a compose box + // that allows composing, instead of a no-posting-permission banner + ..accountId.equals(store.accountId) + ..streams.containsKey(channel.streamId); + + checkContentInputValue(tester, 'some content'); + }); + }); + + /// Starts an edit interaction from the action sheet's 'Edit message' button. + /// + /// The fetch-raw-content request is prepared with [delay] (default 1s). + Future startEditInteractionFromActionSheet( + WidgetTester tester, { + required int messageId, + String originalRawContent = 'foo', + Duration delay = const Duration(seconds: 1), + bool fetchShouldSucceed = true, + }) async { + await tester.longPress(find.byWidgetPredicate((widget) => + widget is MessageWithPossibleSender && widget.item.message.id == messageId)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + final findEditButton = find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.edit, skipOffstage: false)); + await tester.ensureVisible(findEditButton); + if (fetchShouldSucceed) { + connection.prepare(delay: delay, + json: GetMessageResult(message: eg.streamMessage(content: originalRawContent)).toJson()); + } else { + connection.prepare(apiException: eg.apiBadRequest(), delay: delay); + } + await tester.tap(findEditButton); + await tester.pump(); + await tester.pump(); + connection.takeRequests(); + } + + Future expectAndHandleDiscardConfirmation( + WidgetTester tester, { + required String expectedMessage, + required bool shouldContinue, + }) async { + final (actionButton, cancelButton) = checkSuggestedActionDialog(tester, + expectedTitle: 'Discard the message you’re writing?', + expectedMessage: expectedMessage, + expectedActionButtonText: 'Discard'); + if (shouldContinue) { + await tester.tap(find.byWidget(actionButton)); + } else { + await tester.tap(find.byWidget(cancelButton)); + } + } + + group('restoreMessageNotSent', () { + final channel = eg.stream(); + final topic = 'topic'; + final topicNarrow = eg.topicNarrow(channel.streamId, topic); + + final failedMessageContent = 'failed message'; + final failedMessageFinder = find.widgetWithText( + OutboxMessageWithPossibleSender, failedMessageContent, skipOffstage: true); + + Future prepareMessageNotSent(WidgetTester tester, { + required Narrow narrow, + List otherUsers = const [], + }) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + await prepareComposeBox(tester, + narrow: narrow, streams: [channel], otherUsers: otherUsers); + + if (narrow is ChannelNarrow) { + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + await enterTopic(tester, narrow: narrow, topic: topic); + } + await enterContent(tester, failedMessageContent); + connection.prepare(httpException: SocketException('error')); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + check(state).controller.content.text.equals(''); + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Message not sent'))); + await tester.pump(); + check(failedMessageFinder).findsOne(); + } + + testWidgets('restore content in DM narrow', (tester) async { + final dmNarrow = DmNarrow.withUser( + eg.otherUser.userId, selfUserId: eg.selfUser.userId); + await prepareMessageNotSent(tester, narrow: dmNarrow, otherUsers: [eg.otherUser]); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller + ..content.text.equals(failedMessageContent) + ..contentFocusNode.hasFocus.isTrue(); + }); + + testWidgets('restore content in topic narrow', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller + ..content.text.equals(failedMessageContent) + ..contentFocusNode.hasFocus.isTrue(); + }); + + testWidgets('restore content and topic in channel narrow', (tester) async { + final channelNarrow = ChannelNarrow(channel.streamId); + await prepareMessageNotSent(tester, narrow: channelNarrow); + + await tester.enterText(topicInputFinder, 'topic before restoring'); + check(state).controller.isA() + ..topic.text.equals('topic before restoring') + ..content.text.isNotNull().isEmpty(); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.isA() + ..topic.text.equals(topic) + ..content.text.equals(failedMessageContent) + ..contentFocusNode.hasFocus.isTrue(); + }); + + Future expectAndHandleDiscardForMessageNotSentConfirmation( + WidgetTester tester, { + required bool shouldContinue, + }) { + return expectAndHandleDiscardConfirmation(tester, + expectedMessage: 'When you restore an unsent message, the content that was previously in the compose box is discarded.', + shouldContinue: shouldContinue); + } + + testWidgets('interrupting new-message compose: proceed through confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + await enterContent(tester, 'composing something'); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('composing something'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: true); + await tester.pump(); + check(state).controller.content.text.equals(failedMessageContent); + }); + + testWidgets('interrupting new-message compose: cancel confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + await enterContent(tester, 'composing something'); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('composing something'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: false); + await tester.pump(); + check(state).controller.content.text.equals('composing something'); + }); + + testWidgets('interrupting message edit: proceed through confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + + final messageToEdit = eg.streamMessage( + sender: eg.selfUser, stream: channel, topic: topic, + content: 'message to edit'); + await store.addMessage(messageToEdit); + await tester.pump(); + + await startEditInteractionFromActionSheet(tester, messageId: messageToEdit.id, + originalRawContent: 'message to edit', + delay: Duration.zero); + await tester.pump(const Duration(milliseconds: 250)); // bottom-sheet animation + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('message to edit'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: true); + await tester.pump(); + check(state).controller.content.text.equals(failedMessageContent); + }); + + testWidgets('interrupting message edit: cancel confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + + final messageToEdit = eg.streamMessage( + sender: eg.selfUser, stream: channel, topic: topic, + content: 'message to edit'); + await store.addMessage(messageToEdit); + await tester.pump(); + + await startEditInteractionFromActionSheet(tester, messageId: messageToEdit.id, + originalRawContent: 'message to edit', + delay: Duration.zero); + await tester.pump(const Duration(milliseconds: 250)); // bottom-sheet animation + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('message to edit'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: false); + await tester.pump(); + check(state).controller.content.text.equals('message to edit'); + }); + }); + + group('edit message', () { + final channel = eg.stream(); + final topic = 'topic'; + final message = eg.streamMessage(sender: eg.selfUser, stream: channel, topic: topic); + final dmMessage = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + + final channelNarrow = ChannelNarrow(channel.streamId); + final topicNarrow = eg.topicNarrow(channel.streamId, topic); + final dmNarrow = DmNarrow.ofMessage(dmMessage, selfUserId: eg.selfUser.userId); + + Message msgInNarrow(Narrow narrow) { + final List messages = [message, dmMessage]; + return messages.where( + // TODO(#1667) will be null in a search narrow; remove `!`. + (m) => narrow.containsMessage(m)! + ).single; + } + + int msgIdInNarrow(Narrow narrow) => msgInNarrow(narrow).id; + + Future prepareEditMessage(WidgetTester tester, {required Narrow narrow}) async { + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); + await prepareComposeBox(tester, + narrow: narrow, + streams: [channel]); + await store.addMessages([message, dmMessage]); + await tester.pump(); // message list updates + } + + Future takeErrorDialogAndPump(WidgetTester tester) async { + final errorDialog = checkErrorDialog(tester, expectedTitle: 'Message not saved'); + await tester.tap(find.byWidget(errorDialog)); + await tester.pump(); + } + + /// Check that the compose box is in the "Preparing…" state, + /// awaiting the fetch-raw-content request. + Future checkAwaitingRawMessageContent(WidgetTester tester) async { + check(state.controller) + .isA() + ..originalRawContent.isNull() + ..content.value.text.equals(''); + check(tester.widget(contentInputFinder)) + .isA() + .decoration.isNotNull().hintText.equals('Preparing…'); + checkContentInputValue(tester, ''); + + // Controls are disabled + await tester.tap(find.byIcon(ZulipIcons.attach_file), warnIfMissed: false); + await tester.pump(); + check(testBinding.takePickFilesCalls()).isEmpty(); + + // Save button is disabled + final lastRequest = connection.lastRequest; + await tester.tap( + find.widgetWithText(ZulipWebUiKitButton, 'Save'), warnIfMissed: false); + await tester.pump(Duration.zero); + checkNoDialog(tester); + check(connection.lastRequest).equals(lastRequest); + } + + /// Starts an interaction by tapping a failed edit in the message list. + Future startInteractionFromRestoreFailedEdit( + WidgetTester tester, { + required int messageId, + String originalRawContent = 'foo', + String newContent = 'bar', + }) async { + await startEditInteractionFromActionSheet(tester, + messageId: messageId, originalRawContent: originalRawContent); + await tester.pump(Duration(seconds: 1)); // raw-content request + await enterContent(tester, newContent); + + connection.prepare(apiException: eg.apiBadRequest()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration.zero); + await takeErrorDialogAndPump(tester); + await tester.tap(find.text('EDIT NOT SAVED')); + await tester.pump(); + connection.takeRequests(); + } + + void checkRequest(int messageId, { + required String prevContent, + required String content, + }) { + final prevContentSha256 = sha256.convert(utf8.encode(prevContent)).toString(); + check(connection.takeRequests()).single.isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/messages/$messageId') + ..bodyFields.deepEquals({ + 'prev_content_sha256': prevContentSha256, + 'content': content, + }); + } + + /// Check that the compose box is not in editing mode. + void checkNotInEditingMode(WidgetTester tester, { + required Narrow narrow, + String expectedContentText = '', + }) { + switch (narrow) { + case ChannelNarrow(): + check(state.controller) + .isA() + .content.value.text.equals(expectedContentText); + case TopicNarrow(): + case DmNarrow(): + check(state.controller) + .isA() + .content.value.text.equals(expectedContentText); + default: + throw StateError('unexpected narrow type'); + } + checkContentInputValue(tester, expectedContentText); + } + + void testSmoke({required Narrow narrow, required _EditInteractionStart start}) { + testWidgets('smoke: $narrow, ${start.message()}', (tester) async { + await prepareEditMessage(tester, narrow: narrow); + checkNotInEditingMode(tester, narrow: narrow); + + final messageId = msgIdInNarrow(narrow); + switch (start) { + case _EditInteractionStart.actionSheet: + await startEditInteractionFromActionSheet(tester, + messageId: messageId, + originalRawContent: 'foo'); + await checkAwaitingRawMessageContent(tester); + await tester.pump(Duration(seconds: 1)); // fetch-raw-content request + checkContentInputValue(tester, 'foo'); + case _EditInteractionStart.restoreFailedEdit: + await startInteractionFromRestoreFailedEdit(tester, + messageId: messageId, + originalRawContent: 'foo', + newContent: 'bar'); + checkContentInputValue(tester, 'bar'); + } + + // Now that we have the raw content, check the input is interactive + // but no typing notifications are sent… + check(TypingNotifier.debugEnable).isTrue(); + check(state).controller.contentFocusNode.hasFocus.isTrue(); + await enterContent(tester, 'some new content'); + check(connection.takeRequests()).isEmpty(); + + // …and the upload buttons work. + testBinding.pickFilesResult = FilePickerResult([ + PlatformFile(name: 'file.jpg', size: 1000, readStream: Stream.fromIterable(['asdf'.codeUnits]))]); + connection.prepare(json: + UploadFileResult(url: '/path/file.jpg').toJson()); + await tester.tap(find.byIcon(ZulipIcons.attach_file), warnIfMissed: false); + await tester.pump(Duration.zero); + checkNoDialog(tester); + check(testBinding.takePickFilesCalls()).length.equals(1); + connection.takeRequests(); // upload request + + // TODO could also check that quote-and-reply and autocomplete work + // (but as their own test cases, for a single narrow and start) + + // Save; check that the request is made and the compose box resets. + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + checkRequest(messageId, + prevContent: 'foo', content: 'some new content[file.jpg](/path/file.jpg)'); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + }); + } + testSmoke(narrow: channelNarrow, start: _EditInteractionStart.actionSheet); + testSmoke(narrow: topicNarrow, start: _EditInteractionStart.actionSheet); + testSmoke(narrow: dmNarrow, start: _EditInteractionStart.actionSheet); + testSmoke(narrow: channelNarrow, start: _EditInteractionStart.restoreFailedEdit); + testSmoke(narrow: topicNarrow, start: _EditInteractionStart.restoreFailedEdit); + testSmoke(narrow: dmNarrow, start: _EditInteractionStart.restoreFailedEdit); + + Future expectAndHandleDiscardForEditConfirmation(WidgetTester tester, { + required bool shouldContinue, + }) { + return expectAndHandleDiscardConfirmation(tester, + expectedMessage: 'When you edit a message, the content that was previously in the compose box is discarded.', + shouldContinue: shouldContinue); + } + + // Test the "Discard…?" confirmation dialog when you tap "Edit message" in + // the action sheet but there's text in the compose box for a new message. + void testInterruptComposingFromActionSheet({required Narrow narrow}) { + testWidgets('interrupting new-message compose: $narrow', (tester) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final messageId = msgIdInNarrow(narrow); + await prepareEditMessage(tester, narrow: narrow); + checkNotInEditingMode(tester, narrow: narrow); + + await enterContent(tester, 'composing new message'); + + // Expect confirmation dialog; tap Cancel + await startEditInteractionFromActionSheet(tester, messageId: messageId); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: false); + check(connection.takeRequests()).isEmpty(); + // fetch-raw-content request wasn't actually sent; + // take back its prepared response + connection.clearPreparedResponses(); + + // Twiddle the input to make sure it still works + checkNotInEditingMode(tester, + narrow: narrow, expectedContentText: 'composing new message'); + await enterContent(tester, 'composing new message…'); + checkContentInputValue(tester, 'composing new message…'); + + // Try again, but this time tap Discard and expect to enter an edit session + await startEditInteractionFromActionSheet(tester, + messageId: messageId, originalRawContent: 'foo'); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: true); + await tester.pump(); + await checkAwaitingRawMessageContent(tester); + await tester.pump(Duration(seconds: 1)); // fetch-raw-content request + check(connection.takeRequests()).length.equals(1); + checkContentInputValue(tester, 'foo'); + await enterContent(tester, 'bar'); + + // Save; check that the request is made and the compose box resets. + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + checkRequest(messageId, prevContent: 'foo', content: 'bar'); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + }); + } + // Cover multiple narrows, checking that the Discard button resets the state + // correctly for each one. + testInterruptComposingFromActionSheet(narrow: channelNarrow); + testInterruptComposingFromActionSheet(narrow: topicNarrow); + testInterruptComposingFromActionSheet(narrow: dmNarrow); + + // Test the "Discard…?" confirmation dialog when you want to restore + // a failed edit but there's text in the compose box for a new message. + void testInterruptComposingFromFailedEdit({required Narrow narrow}) { + testWidgets('interrupting new-message compose by tapping failed edit to restore: $narrow', (tester) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final messageId = msgIdInNarrow(narrow); + await prepareEditMessage(tester, narrow: narrow); + + await startEditInteractionFromActionSheet(tester, + messageId: messageId, originalRawContent: 'foo'); + await tester.pump(Duration(seconds: 1)); // raw-content request + await enterContent(tester, 'bar'); + + connection.prepare(apiException: eg.apiBadRequest()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + connection.takeRequests(); + await tester.pump(Duration.zero); + await takeErrorDialogAndPump(tester); + checkNotInEditingMode(tester, narrow: narrow); + check(find.text('EDIT NOT SAVED')).findsOne(); + + await enterContent(tester, 'composing new message'); + + // Expect confirmation dialog; tap Cancel + await tester.tap(find.text('EDIT NOT SAVED')); + await tester.pump(); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: false); + checkNotInEditingMode(tester, + narrow: narrow, expectedContentText: 'composing new message'); + + // Twiddle the input to make sure it still works + await enterContent(tester, 'composing new message…'); + + // Try again, but this time tap Discard and expect to enter edit session + await tester.tap(find.text('EDIT NOT SAVED')); + await tester.pump(); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: true); + await tester.pump(); + checkContentInputValue(tester, 'bar'); + await enterContent(tester, 'baz'); + + // Save; check that the request is made and the compose box resets. + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + checkRequest(messageId, prevContent: 'foo', content: 'baz'); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + }); + } + // (So tests run faster, skip some narrows that are already covered above.) + testInterruptComposingFromFailedEdit(narrow: channelNarrow); + // testInterruptComposingFromFailedEdit(narrow: topicNarrow); + // testInterruptComposingFromFailedEdit(narrow: dmNarrow); + + // TODO also test: + // - Restore a failed edit, but when there's compose input for an edit- + // message session. (The failed edit would be for a different message, + // or else started from a different MessageListPage.) + + void testFetchRawContentFails({required Narrow narrow}) { + final description = 'fetch-raw-content fails: $narrow'; + testWidgets(description, (tester) async { + await prepareEditMessage(tester, narrow: narrow); + checkNotInEditingMode(tester, narrow: narrow); + + final messageId = msgIdInNarrow(narrow); + await startEditInteractionFromActionSheet(tester, + messageId: messageId, + originalRawContent: 'foo', + fetchShouldSucceed: false); + await checkAwaitingRawMessageContent(tester); + await tester.pump(Duration(seconds: 1)); // fetch-raw-content request + checkErrorDialog(tester, expectedTitle: 'Could not edit message'); + checkNotInEditingMode(tester, narrow: narrow); + }); + } + // Skip some narrows so the tests run faster; + // the codepaths to be tested are basically the same. + // testFetchRawContentFails(narrow: channelNarrow); + testFetchRawContentFails(narrow: topicNarrow); + // testFetchRawContentFails(narrow: dmNarrow); + + /// Test that an edit session is really cleared by the Cancel button. + /// + /// If `start: _EditInteractionStart.actionSheet` (the default), + /// pass duringFetchRawContentRequest to control whether the Cancel button + /// is tapped during (true) or after (false) the fetch-raw-content request. + /// + /// If `start: _EditInteractionStart.restoreFailedEdit`, + /// don't pass duringFetchRawContentRequest. + void testCancel({ + required Narrow narrow, + _EditInteractionStart start = _EditInteractionStart.actionSheet, + bool? duringFetchRawContentRequest, + }) { + final description = StringBuffer()..write('tap Cancel '); + switch (start) { + case _EditInteractionStart.actionSheet: + assert(duringFetchRawContentRequest != null); + description + ..write(duringFetchRawContentRequest! ? 'during ' : 'after ') + ..write('fetch-raw-content request: '); + case _EditInteractionStart.restoreFailedEdit: + assert(duringFetchRawContentRequest == null); + description.write('when editing from a restored failed edit: '); + } + description.write('$narrow'); + testWidgets(description.toString(), (tester) async { + await prepareEditMessage(tester, narrow: narrow); + checkNotInEditingMode(tester, narrow: narrow); + + final messageId = msgIdInNarrow(narrow); + switch (start) { + case _EditInteractionStart.actionSheet: + await startEditInteractionFromActionSheet(tester, + messageId: messageId, delay: Duration(seconds: 5)); + await checkAwaitingRawMessageContent(tester); + await tester.pump(duringFetchRawContentRequest! + ? Duration(milliseconds: 500) + : Duration(seconds: 5)); + case _EditInteractionStart.restoreFailedEdit: + await startInteractionFromRestoreFailedEdit(tester, + messageId: messageId, + newContent: 'bar'); + checkContentInputValue(tester, 'bar'); + } + + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Cancel')); + await tester.pump(); + checkNotInEditingMode(tester, narrow: narrow); + + // We've canceled the previous edit session, so we should be able to + // do a new edit-message session… + await startEditInteractionFromActionSheet(tester, + messageId: messageId, originalRawContent: 'foo'); + await checkAwaitingRawMessageContent(tester); + await tester.pump(Duration(seconds: 1)); // fetch-raw-content request + checkContentInputValue(tester, 'foo'); + await enterContent(tester, 'qwerty'); + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + checkRequest(messageId, prevContent: 'foo', content: 'qwerty'); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + + // …or send a new message. + connection.prepare(json: {}); // for typing-start request + connection.prepare(json: {}); // for typing-stop request + await enterContent(tester, 'new message to send'); + state.controller.contentFocusNode.unfocus(); + await tester.pump(); + check(connection.takeRequests()).deepEquals(>[ + (it) => it.isA() + ..method.equals('POST')..url.path.equals('/api/v1/typing'), + (it) => it.isA() + ..method.equals('POST')..url.path.equals('/api/v1/typing')]); + if (narrow is ChannelNarrow) { + await enterTopic(tester, narrow: narrow, topic: topic); + } + await tester.pump(); + await tapSendButton(tester); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages'); + checkContentInputValue(tester, ''); + + if (start == _EditInteractionStart.actionSheet && duringFetchRawContentRequest!) { + // Await the fetch-raw-content request from the canceled edit session; + // its completion shouldn't affect anything. + await tester.pump(Duration(seconds: 5)); + } + checkNotInEditingMode(tester, narrow: narrow); + check(connection.takeRequests()).isEmpty(); + }); + } + // Skip some narrows so the tests run faster; + // the codepaths to be tested are basically the same. + testCancel(narrow: channelNarrow, duringFetchRawContentRequest: false); + // testCancel(narrow: topicNarrow, duringFetchRawContentRequest: false); + testCancel(narrow: dmNarrow, duringFetchRawContentRequest: false); + // testCancel(narrow: channelNarrow, duringFetchRawContentRequest: true); + testCancel(narrow: topicNarrow, duringFetchRawContentRequest: true); + // testCancel(narrow: dmNarrow, duringFetchRawContentRequest: true); + testCancel(narrow: channelNarrow, start: _EditInteractionStart.restoreFailedEdit); + // testCancel(narrow: topicNarrow, start: _EditInteractionStart.restoreFailedEdit); + // testCancel(narrow: dmNarrow, start: _EditInteractionStart.restoreFailedEdit); + }); +} + +/// How the edit interaction is started: +/// from the action sheet, or by restoring a failed edit. +enum _EditInteractionStart { + actionSheet, + restoreFailedEdit; + + String message() { + return switch (this) { + _EditInteractionStart.actionSheet => 'from action sheet', + _EditInteractionStart.restoreFailedEdit => 'from restoring a failed edit', + }; + } } diff --git a/test/widgets/content_checks.dart b/test/widgets/content_checks.dart deleted file mode 100644 index 1faf0e2d62..0000000000 --- a/test/widgets/content_checks.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:flutter/widgets.dart'; - -import 'package:zulip/widgets/content.dart'; - -extension RealmContentNetworkImageChecks on Subject { - Subject get src => has((i) => i.src, 'src'); - // TODO others -} - -extension AvatarImageChecks on Subject { - Subject get userId => has((i) => i.userId, 'userId'); -} - -extension AvatarShapeChecks on Subject { - Subject get size => has((i) => i.size, 'size'); - Subject get borderRadius => has((i) => i.borderRadius, 'borderRadius'); - Subject get child => has((i) => i.child, 'child'); -} diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 571034093d..28af496c59 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -7,11 +7,15 @@ import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:zulip/api/core.dart'; +import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/content.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/katex.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; @@ -22,12 +26,10 @@ import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/content_test.dart'; import '../model/test_store.dart'; -import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; +import 'checks.dart'; import 'dialog_checks.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; import 'test_app.dart'; /// Simulate a nested "inner" span's style by merging all ancestor-span @@ -104,6 +106,44 @@ TextStyle? mergedStyleOf(WidgetTester tester, Pattern spanPattern, { /// and reports the target's font size. typedef TargetFontSizeFinder = double Function(InlineSpan rootSpan); +Widget plainContent(String html) { + return Builder(builder: (context) => + DefaultTextStyle( + style: ContentTheme.of(context).textStylePlainParagraph, + child: BlockContentList(nodes: parseContent(html).nodes))); +} + +// TODO(#488) For content that we need to show outside a per-message context +// or a context without a full PerAccountStore, make sure to include tests +// that don't provide such context. +Future prepareContent(WidgetTester tester, Widget child, { + List navObservers = const [], + bool wrapWithPerAccountStoreWidget = false, + InitialSnapshot? initialSnapshot, +}) async { + if (wrapWithPerAccountStoreWidget) { + initialSnapshot ??= eg.initialSnapshot(); + await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); + } else { + assert(initialSnapshot == null); + } + + addTearDown(testBinding.reset); + + prepareBoringImageHttpClient(); + + await tester.pumpWidget(TestZulipApp( + accountId: wrapWithPerAccountStoreWidget ? eg.selfAccount.id : null, + navigatorObservers: navObservers, + child: child)); + await tester.pump(); // global store + if (wrapWithPerAccountStoreWidget) { + await tester.pump(); + } + + debugNetworkImageHttpClientProvider = null; +} + void main() { // For testing a new content feature: // @@ -118,45 +158,11 @@ void main() { TestZulipBinding.ensureInitialized(); - Widget plainContent(String html) { - return Builder(builder: (context) => - DefaultTextStyle( - style: ContentTheme.of(context).textStylePlainParagraph, - child: BlockContentList(nodes: parseContent(html).nodes))); - } - Widget messageContent(String html) { return MessageContent(message: eg.streamMessage(content: html), content: parseContent(html)); } - // TODO(#488) For content that we need to show outside a per-message context - // or a context without a full PerAccountStore, make sure to include tests - // that don't provide such context. - Future prepareContent(WidgetTester tester, Widget child, { - List navObservers = const [], - bool wrapWithPerAccountStoreWidget = false, - }) async { - if (wrapWithPerAccountStoreWidget) { - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - } - - addTearDown(testBinding.reset); - - prepareBoringImageHttpClient(); - - await tester.pumpWidget(TestZulipApp( - accountId: wrapWithPerAccountStoreWidget ? eg.selfAccount.id : null, - navigatorObservers: navObservers, - child: child)); - await tester.pump(); // global store - if (wrapWithPerAccountStoreWidget) { - await tester.pump(); - } - - debugNetworkImageHttpClientProvider = null; - } - /// Test that the given content example renders without throwing an exception. /// /// This requires [ContentExample.expectedText] to be non-null in order to @@ -180,11 +186,11 @@ void main() { /// [styleFinder] must return the [TextStyle] containing the "wght" /// (in [TextStyle.fontVariations]) and the [TextStyle.fontWeight] /// to be checked. - Future testFontWeight(String description, { + void testFontWeight(String description, { required Widget content, required double expectedWght, required TextStyle Function(WidgetTester tester) styleFinder, - }) async { + }) { for (final platformRequestsBold in [false, true]) { testWidgets( description + (platformRequestsBold ? ' (platform requests bold)' : ''), @@ -230,6 +236,45 @@ void main() { }); }); + group('ListNodeWidget', () { + testWidgets('ordered list with custom start', (tester) async { + await prepareContent(tester, plainContent('
          \n
        1. third
        2. \n
        3. fourth
        4. \n
        ')); + expect(find.text('3. '), findsOneWidget); + expect(find.text('4. '), findsOneWidget); + expect(find.text('third'), findsOneWidget); + expect(find.text('fourth'), findsOneWidget); + }); + + testWidgets('list uses correct text baseline alignment', (tester) async { + await prepareContent(tester, plainContent(ContentExample.orderedListLargeStart.html)); + final table = tester.widget
    (find.byType(Table)); + check(table.defaultVerticalAlignment).equals(TableCellVerticalAlignment.baseline); + check(table.textBaseline).equals(localizedTextBaseline(tester.element(find.byType(Table)))); + }); + + testWidgets('ordered list markers have enough space to render completely', (tester) async { + await prepareContent(tester, plainContent(ContentExample.orderedListLargeStart.html)); + final marker = tester.renderObject(find.textContaining('9999.')) as RenderParagraph; + // The marker has the height of just one line of text, not more. + final textHeight = marker.size.height; + final lineHeight = marker.text.style!.height! * marker.text.style!.fontSize!; + check(textHeight).equals(lineHeight); + // The marker's text didn't overflow to more lines + // (and get cut off by a `maxLines: 1`). + check(marker).didExceedMaxLines.isFalse(); + }); + + testWidgets('ordered list markers are end-aligned', (tester) async { + await prepareContent(tester, plainContent(ContentExample.orderedListLargeStart.html)); + final marker9999 = tester.getRect(find.textContaining('9999.')); + final marker10000 = tester.getRect(find.textContaining('10000.')); + // The markers are aligned at their right edge... + check(marker9999).right.equals(marker10000.right); + // ... and not because they somehow happen to have the same width. + check(marker9999).width.isLessThan(marker10000.width); + }); + }); + group('Spoiler', () { testContentSmoke(ContentExample.spoilerDefaultHeader); testContentSmoke(ContentExample.spoilerPlainCustomHeader); @@ -482,7 +527,7 @@ void main() { final expectedLaunchUrl = expectedVideo.hrefUrl; await tester.tap(find.byIcon(Icons.play_arrow_rounded)); check(testBinding.takeLaunchUrlCalls()) - .single.equals((url: Uri.parse(expectedLaunchUrl), mode: LaunchMode.platformDefault)); + .single.equals((url: Uri.parse(expectedLaunchUrl), mode: LaunchMode.inAppBrowserView)); } testWidgets('video preview for youtube embed', (tester) async { @@ -513,7 +558,23 @@ void main() { styleFinder: (tester) => mergedStyleOf(tester, 'A')!); }); - testContentSmoke(ContentExample.mathBlock); + group('MathBlock', () { + // See also katex_test.dart for detailed tests of + // how we render the inside of a math block. + // These tests check how it relates to the enclosing Zulip message. + + testContentSmoke(ContentExample.mathBlock); + + testWidgets('displays KaTeX content', (tester) async { + await prepareContent(tester, plainContent(ContentExample.mathBlock.html)); + tester.widget(find.text('λ', findRichText: true)); + }); + + testWidgets('fallback to displaying KaTeX source if unsupported KaTeX HTML', (tester) async { + await prepareContent(tester, plainContent(ContentExample.mathBlockUnknown.html)); + tester.widget(find.text(r'\lambda', findRichText: true)); + }); + }); /// Make a [TargetFontSizeFinder] to pass to [checkFontSizeRatio], /// from a target [Pattern] (such as a string). @@ -533,10 +594,12 @@ void main() { Future checkFontSizeRatio(WidgetTester tester, { required String targetHtml, required TargetFontSizeFinder targetFontSizeFinder, + bool wrapWithPerAccountStoreWidget = false, }) async { - await prepareContent(tester, plainContent( - '

    header-plain $targetHtml

    \n' - '

    paragraph-plain $targetHtml

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

    header-plain $targetHtml

    \n' + '

    paragraph-plain $targetHtml

    ')); final headerRootSpan = tester.renderObject(find.textContaining('header')).text; final headerPlainStyle = mergedStyleOfSubstring(headerRootSpan, 'header-plain '); @@ -644,8 +707,18 @@ void main() { '
    \n\n\n\n\n' '
    text
    '), styleFinder: findWordBold); + + testWidgets('has strike-through line in strike-through', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1817 + await prepareContent(tester, + plainContent('

    bold

    ')); + final style = mergedStyleOf(tester, 'bold'); + check(style!.decoration).equals(TextDecoration.lineThrough); + }); }); + testContentSmoke(ContentExample.deleted); + testContentSmoke(ContentExample.emphasis); group('inline code', () { @@ -656,6 +729,22 @@ void main() { targetHtml: 'code', targetFontSizeFinder: mkTargetFontSizeFinderFromPattern('code')); }); + + testFontWeight('is bold in bold span', + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1812 + expectedWght: 600, + // **`bold`** + content: plainContent('

    bold

    '), + styleFinder: (tester) => mergedStyleOf(tester, 'bold')!, + ); + + testWidgets('is link-colored in link span', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/806 + await prepareContent(tester, + plainContent('

    code

    ')); + final style = mergedStyleOf(tester, 'code'); + check(style!.color).equals(const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor()); + }); }); group('UserMention', () { @@ -754,11 +843,22 @@ void main() { await tapText(tester, find.text('hello')); final expectedLaunchMode = defaultTargetPlatform == TargetPlatform.iOS ? - LaunchMode.externalApplication : LaunchMode.platformDefault; + LaunchMode.externalApplication : LaunchMode.inAppBrowserView; check(testBinding.takeLaunchUrlCalls()) .single.equals((url: Uri.parse('https://example/'), mode: expectedLaunchMode)); }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + testWidgets('follow browser preference setting to open URL', (tester) async { + await testBinding.globalStore.settings + .setBrowserPreference(BrowserPreference.inApp); + await prepare(tester, + '

    hello

    '); + + await tapText(tester, find.text('hello')); + check(testBinding.takeLaunchUrlCalls()).single.equals(( + url: Uri.parse('https://example/'), mode: LaunchMode.inAppBrowserView)); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + testWidgets('multiple links in paragraph', (tester) async { const fontSize = kBaseFontSize; @@ -772,11 +872,11 @@ void main() { await tester.tapAt(base.translate(1*fontSize, 0)); // "fXo bar baz" check(testBinding.takeLaunchUrlCalls()) - .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.platformDefault)); + .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.inAppBrowserView)); await tester.tapAt(base.translate(9*fontSize, 0)); // "foo bar bXz" check(testBinding.takeLaunchUrlCalls()) - .single.equals((url: Uri.parse('https://b/'), mode: LaunchMode.platformDefault)); + .single.equals((url: Uri.parse('https://b/'), mode: LaunchMode.inAppBrowserView)); }); testWidgets('link nested in other spans', (tester) async { @@ -784,7 +884,7 @@ void main() { '

    word

    '); await tapText(tester, find.text('word')); check(testBinding.takeLaunchUrlCalls()) - .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.platformDefault)); + .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.inAppBrowserView)); }); testWidgets('link containing other spans', (tester) async { @@ -797,11 +897,11 @@ void main() { await tester.tapAt(base.translate(1*fontSize, 0)); // "tXo words" check(testBinding.takeLaunchUrlCalls()) - .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.platformDefault)); + .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.inAppBrowserView)); await tester.tapAt(base.translate(6*fontSize, 0)); // "two woXds" check(testBinding.takeLaunchUrlCalls()) - .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.platformDefault)); + .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.inAppBrowserView)); }); testWidgets('relative links are resolved', (tester) async { @@ -809,7 +909,7 @@ void main() { '

    word

    '); await tapText(tester, find.text('word')); check(testBinding.takeLaunchUrlCalls()) - .single.equals((url: Uri.parse('${eg.realmUrl}a/b?c#d'), mode: LaunchMode.platformDefault)); + .single.equals((url: Uri.parse('${eg.realmUrl}a/b?c#d'), mode: LaunchMode.inAppBrowserView)); }); testWidgets('link inside HeadingNode', (tester) async { @@ -817,10 +917,21 @@ void main() { '
    word
    '); await tapText(tester, find.text('word')); check(testBinding.takeLaunchUrlCalls()) - .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.platformDefault)); + .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.inAppBrowserView)); + }); + + testWidgets('error dialog if invalid URL', (tester) async { + await prepare(tester, + '

    word

    '); + await tapText(tester, find.text('word')); + await tester.pump(); + check(testBinding.takeLaunchUrlCalls()).isEmpty(); + checkErrorDialog(tester, + expectedTitle: 'Unable to open link', + expectedMessage: 'Link could not be opened: ::invalid::'); }); - testWidgets('error dialog if invalid link', (tester) async { + testWidgets('error dialog if platform cannot open link', (tester) async { await prepare(tester, '

    word

    '); testBinding.launchUrlResult = false; @@ -863,6 +974,8 @@ void main() { .page.isA().initNarrow.equals(const ChannelNarrow(1)); }); + // TODO(#1570): test links with /near/ go to the specific message + testWidgets('invalid internal links are opened in browser', (tester) async { // Link is invalid due to `topic` operator missing an operand. final pushedRoutes = await prepare(tester, @@ -871,7 +984,7 @@ void main() { await tapText(tester, find.text('invalid')); final expectedUrl = eg.realmUrl.resolve('/#narrow/stream/1-check/topic'); check(testBinding.takeLaunchUrlCalls()) - .single.equals((url: expectedUrl, mode: LaunchMode.platformDefault)); + .single.equals((url: expectedUrl, mode: LaunchMode.inAppBrowserView)); check(pushedRoutes).isEmpty(); }); }); @@ -893,9 +1006,21 @@ void main() { _ => throw StateError('unexpected platform in test'), }); }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('has strike-through line in strike-through', (tester) async { + // Regression test for https://github.com/zulip/zulip-flutter/issues/1818 + await prepareContent(tester, + plainContent('

    foo:thumbs_up:bar

    ')); + final style = mergedStyleOf(tester, '\u{1f44d}'); + check(style!.decoration).equals(TextDecoration.lineThrough); + }); }); group('inline math', () { + // See also katex_test.dart for detailed tests of + // how we render the inside of a math span. + // These tests check how it relates to the enclosing Zulip message. + testContentSmoke(ContentExample.mathInline); testWidgets('maintains font-size ratio with surrounding text', (tester) async { @@ -905,7 +1030,49 @@ void main() { ''; await checkFontSizeRatio(tester, targetHtml: html, - targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(r'\lambda')); + targetFontSizeFinder: (rootSpan) { + late final double result; + rootSpan.visitChildren((span) { + if (span case WidgetSpan(child: KatexWidget() && var widget)) { + result = mergedStyleOf(tester, + findAncestor: find.byWidget(widget), r'λ')!.fontSize!; + return false; + } + return true; + }); + return result; + }); + }); + + group('fallback to displaying KaTeX source if unsupported KaTeX HTML', () { + testContentSmoke(ContentExample.mathInlineUnknown); + + assert(ContentExample.mathInlineUnknown.html.startsWith('

    ')); + assert(ContentExample.mathInlineUnknown.html.endsWith('

    ')); + final unsupportedKatexHtml = ContentExample.mathInlineUnknown.html + .substring(3, ContentExample.mathInlineUnknown.html.length - 4); + final expectedText = ContentExample.mathInlineUnknown.expectedText!; + + testWidgets('maintains font-size ratio with surrounding text, when falling back to TeX source', (tester) async { + await checkFontSizeRatio(tester, + targetHtml: unsupportedKatexHtml, + targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(expectedText)); + }); + + testFontWeight('is bold in bold span', + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1812 + expectedWght: 600, + content: plainContent('

    $unsupportedKatexHtml

    '), + styleFinder: (tester) => mergedStyleOf(tester, expectedText)!, + ); + + testWidgets('is link-colored in link span', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/806 + await prepareContent(tester, + plainContent('

    $unsupportedKatexHtml

    ')); + final style = mergedStyleOf(tester, expectedText); + check(style!.color).equals(const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor()); + }); }); }); @@ -916,16 +1083,52 @@ void main() { // the timezone of the environment running these tests. Accept here a wide // range of times. See comments in "show dates" test in // `test/widgets/message_list_test.dart`. - final renderedTextRegexp = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$'); + final renderedTextRegexp = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d(?: [AP]M)?$'); + final renderedTextRegexpTwelveHour = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$'); + final renderedTextRegexpTwentyFourHour = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d$'); + + Future prepare( + WidgetTester tester, + [TwentyFourHourTimeMode twentyFourHourTimeMode = TwentyFourHourTimeMode.localeDefault] + ) async { + final initialSnapshot = eg.initialSnapshot() + ..userSettings.twentyFourHourTime = twentyFourHourTimeMode; + await prepareContent(tester, + // We use the self-account's time-format setting. + wrapWithPerAccountStoreWidget: true, + initialSnapshot: initialSnapshot, + plainContent('

    $timeSpanHtml

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

    $timeSpanHtml

    ')); + await prepare(tester); tester.widget(find.textContaining(renderedTextRegexp)); }); + testWidgets('TwentyFourHourTimeMode.twelveHour', (tester) async { + await prepare(tester, TwentyFourHourTimeMode.twelveHour); + check(find.textContaining(renderedTextRegexpTwelveHour)).findsOne(); + }); + + testWidgets('TwentyFourHourTimeMode.twentyFourHour', (tester) async { + await prepare(tester, TwentyFourHourTimeMode.twentyFourHour); + check(find.textContaining(renderedTextRegexpTwentyFourHour)).findsOne(); + }); + + testWidgets('TwentyFourHourTimeMode.localeDefault', (tester) async { + await prepare(tester, TwentyFourHourTimeMode.localeDefault); + // This expectation holds as long as we're always formatting in en_US, + // the default locale, which uses the twelve-hour format. + // TODO(#1727) follow the actual locale; test with different locales + check(find.textContaining(renderedTextRegexpTwelveHour)).findsOne(); + }); + void testIconAndTextSameColor(String description, String html) { testWidgets('clock icon and text are the same color: $description', (tester) async { - await prepareContent(tester, plainContent(html)); + await prepareContent(tester, + // We use the self-account's time-format setting. + wrapWithPerAccountStoreWidget: true, + plainContent(html)); final icon = tester.widget( find.descendant(of: find.byType(GlobalTime), @@ -945,6 +1148,8 @@ void main() { group('maintains font-size ratio with surrounding text', () { Future doCheck(WidgetTester tester, double Function(GlobalTime widget) sizeFromWidget) async { await checkFontSizeRatio(tester, + // We use the self-account's time-format setting. + wrapWithPerAccountStoreWidget: true, targetHtml: '', targetFontSizeFinder: (rootSpan) { late final double result; @@ -977,6 +1182,26 @@ void main() { }); }); + group('InlineAudio', () { + Future prepare(WidgetTester tester, String html) async { + await prepareContent(tester, plainContent(html), + // We try to resolve relative links on the self-account's realm. + wrapWithPerAccountStoreWidget: true); + } + + testWidgets('tapping on audio link opens it in browser', (tester) async { + final url = eg.realmUrl.resolve('/user_uploads/2/f2/a_WnijOXIeRnI6OSxo9F6gZM/crab-rave.mp3'); + await prepare(tester, ContentExample.audioInline.html); + + await tapText(tester, find.text('crab-rave.mp3')); + + final expectedLaunchMode = defaultTargetPlatform == TargetPlatform.iOS ? + LaunchMode.externalApplication : LaunchMode.inAppBrowserView; + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: expectedLaunchMode)); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + }); + group('MessageImageEmoji', () { Future prepare(WidgetTester tester, String html) async { await prepareContent(tester, plainContent(html), @@ -1005,6 +1230,69 @@ void main() { }); }); + group('WebsitePreview', () { + Future prepare(WidgetTester tester, String html) async { + await prepareContent(tester, plainContent(html), + wrapWithPerAccountStoreWidget: true); + } + + testWidgets('smoke', (tester) async { + final url = Uri.parse(ContentExample.websitePreviewSmoke.markdown!); + await prepare(tester, ContentExample.websitePreviewSmoke.html); + + await tester.tap(find.textContaining( + 'Zulip is an organized team chat app for ' + 'distributed teams of all sizes.')); + + await tester.tap(find.text('Zulip — organized team chat')); + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: LaunchMode.inAppBrowserView)); + + await tester.tap(find.byType(RealmContentNetworkImage)); + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: LaunchMode.inAppBrowserView)); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('smoke: without title', (tester) async { + final url = Uri.parse(ContentExample.websitePreviewWithoutTitle.markdown!); + await prepare(tester, ContentExample.websitePreviewWithoutTitle.html); + + await tester.tap(find.textContaining( + 'Zulip is an organized team chat app for ' + 'distributed teams of all sizes.')); + + await tester.tap(find.byType(RealmContentNetworkImage)); + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: LaunchMode.inAppBrowserView)); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('smoke: without description', (tester) async { + final url = Uri.parse(ContentExample.websitePreviewWithoutDescription.markdown!); + await prepare(tester, ContentExample.websitePreviewWithoutDescription.html); + + await tester.tap(find.text('Zulip — organized team chat')); + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: LaunchMode.inAppBrowserView)); + + await tester.tap(find.byType(RealmContentNetworkImage)); + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: LaunchMode.inAppBrowserView)); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('smoke: without title or description', (tester) async { + final url = Uri.parse(ContentExample.websitePreviewWithoutTitleOrDescription.markdown!); + await prepare(tester, ContentExample.websitePreviewWithoutTitleOrDescription.html); + + await tester.tap(find.byType(RealmContentNetworkImage)); + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: LaunchMode.inAppBrowserView)); + debugNetworkImageHttpClientProvider = null; + }); + }); + group('RealmContentNetworkImage', () { final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey); @@ -1045,69 +1333,6 @@ void main() { }); }); - group('AvatarImage', () { - late PerAccountStore store; - - Future actualUrl(WidgetTester tester, String avatarUrl, [double? size]) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - final user = eg.user(avatarUrl: avatarUrl); - await store.addUser(user); - - prepareBoringImageHttpClient(); - await tester.pumpWidget(GlobalStoreWidget( - child: PerAccountStoreWidget(accountId: eg.selfAccount.id, - child: AvatarImage(userId: user.userId, size: size ?? 30)))); - await tester.pump(); - await tester.pump(); - tester.widget(find.byType(AvatarImage)); - final widgets = tester.widgetList( - find.byType(RealmContentNetworkImage)); - return widgets.firstOrNull?.src; - } - - testWidgets('smoke with absolute URL', (tester) async { - const avatarUrl = 'https://example/avatar.png'; - check(await actualUrl(tester, avatarUrl)).isNotNull() - .asString.equals(avatarUrl); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('smoke with relative URL', (tester) async { - const avatarUrl = '/avatar.png'; - check(await actualUrl(tester, avatarUrl)) - .equals(store.tryResolveUrl(avatarUrl)!); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('absolute URL, larger size', (tester) async { - tester.view.devicePixelRatio = 2.5; - addTearDown(tester.view.resetDevicePixelRatio); - - const avatarUrl = 'https://example/avatar.png'; - check(await actualUrl(tester, avatarUrl, 50)).isNotNull() - .asString.equals(avatarUrl.replaceAll('.png', '-medium.png')); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('relative URL, larger size', (tester) async { - tester.view.devicePixelRatio = 2.5; - addTearDown(tester.view.resetDevicePixelRatio); - - const avatarUrl = '/avatar.png'; - check(await actualUrl(tester, avatarUrl, 50)) - .equals(store.tryResolveUrl('/avatar-medium.png')!); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('smoke with invalid URL', (tester) async { - const avatarUrl = '::not a URL::'; - check(await actualUrl(tester, avatarUrl)).isNull(); - debugNetworkImageHttpClientProvider = null; - }); - }); - group('MessageTable', () { testFontWeight('bold column header label', // | a | b | c | d | diff --git a/test/widgets/dialog_checks.dart b/test/widgets/dialog_checks.dart index af0c6e2963..ce7effe2a9 100644 --- a/test/widgets/dialog_checks.dart +++ b/test/widgets/dialog_checks.dart @@ -1,36 +1,97 @@ import 'package:checks/checks.dart'; +import 'package:flutter/cupertino.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/dialog.dart'; -/// In a widget test, check that showErrorDialog was called with the right text. +/// In a widget test, check that [showErrorDialog] was called with the right text. /// /// Checks for an error dialog matching an expected title /// and, optionally, matching an expected message. Fails if none is found. /// /// On success, returns the widget's "OK" button. /// Dismiss the dialog by calling `tester.tap(find.byWidget(okButton))`. +/// +/// See also: +/// - [checkNoDialog] Widget checkErrorDialog(WidgetTester tester, { required String expectedTitle, String? expectedMessage, }) { - final dialog = tester.widget(find.byType(AlertDialog)); - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); - if (expectedMessage != null) { - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); - } + // TODO if a dialog was found but it doesn't match expectations, + // show its details; see checkNoDialog for how to do that + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + final dialog = tester.widget(find.bySubtype()); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); + if (expectedMessage != null) { + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); + } + return tester.widget(find.descendant(of: find.byWidget(dialog), + matching: find.widgetWithText(TextButton, 'OK'))); - return tester.widget( - find.descendant(of: find.byWidget(dialog), - matching: find.widgetWithText(TextButton, 'OK'))); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + final dialog = tester.widget(find.byType(CupertinoAlertDialog)); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); + if (expectedMessage != null) { + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); + } + return tester.widget(find.descendant(of: find.byWidget(dialog), + matching: find.widgetWithText(CupertinoDialogAction, '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 there aren't any alert dialogs. +/// +/// See also: +/// - [checkErrorDialog] +void checkNoDialog(WidgetTester tester) { + final List alertDialogs = [ + ...tester.widgetList(find.bySubtype()), + ...tester.widgetList(find.byType(CupertinoAlertDialog)), + ]; + + if (alertDialogs.isNotEmpty) { + final message = StringBuffer()..write('Found dialog(s) when none were expected:\n'); + for (final alertDialog in alertDialogs) { + final (title, content) = switch (alertDialog) { + AlertDialog() => (alertDialog.title, alertDialog.content), + CupertinoAlertDialog() => (alertDialog.title, alertDialog.content), + _ => throw UnimplementedError(), + }; + + message.write('Dialog:\n' + ' title: ${title is Text ? title.data : title.toString()}\n'); + + if (content != null) { + final contentTexts = tester.widgetList(find.descendant( + matchRoot: true, + of: find.byWidget(content), + matching: find.byType(Text))); + message.write(' content: '); + if (contentTexts.isNotEmpty) { + message.write(contentTexts.map((t) => t.data).join('\n ')); + } else { + // (Could show more detail here as necessary.) + message.write(content.toString()); + } + } + } + throw TestFailure(message.toString()); + } + + check(find.byType(Dialog)).findsNothing(); } /// In a widget test, check that [showSuggestedActionDialog] was called @@ -47,19 +108,35 @@ void checkNoErrorDialog(WidgetTester tester) { required String expectedMessage, String? expectedActionButtonText, }) { - final dialog = tester.widget(find.byType(AlertDialog)); - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + final dialog = tester.widget(find.bySubtype()); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); - final actionButton = tester.widget( - find.descendant(of: find.byWidget(dialog), - matching: find.widgetWithText(TextButton, expectedActionButtonText ?? 'Continue'))); + final actionButton = tester.widget(find.descendant(of: find.byWidget(dialog), + matching: find.widgetWithText(TextButton, expectedActionButtonText ?? 'Continue'))); + final cancelButton = tester.widget(find.descendant(of: find.byWidget(dialog), + matching: find.widgetWithText(TextButton, 'Cancel'))); + return (actionButton, cancelButton); - final cancelButton = tester.widget( - find.descendant(of: find.byWidget(dialog), - matching: find.widgetWithText(TextButton, 'Cancel'))); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + final dialog = tester.widget(find.byType(CupertinoAlertDialog)); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); - return (actionButton, cancelButton); + final actionButton = tester.widget(find.descendant(of: find.byWidget(dialog), + matching: find.widgetWithText(CupertinoDialogAction, expectedActionButtonText ?? 'Continue'))); + final cancelButton = tester.widget(find.descendant(of: find.byWidget(dialog), + matching: find.widgetWithText(CupertinoDialogAction, 'Cancel'))); + return (actionButton, cancelButton); + } } diff --git a/test/widgets/dialog_test.dart b/test/widgets/dialog_test.dart new file mode 100644 index 0000000000..5e273a5dab --- /dev/null +++ b/test/widgets/dialog_test.dart @@ -0,0 +1,169 @@ +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:url_launcher/url_launcher.dart'; +import 'package:zulip/model/settings.dart'; +import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/dialog.dart'; + +import '../model/binding.dart'; +import 'dialog_checks.dart'; +import 'test_app.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + late BuildContext context; + + const title = "Dialog Title"; + const message = "Dialog message."; + + Future prepare(WidgetTester tester) async { + addTearDown(testBinding.reset); + + await tester.pumpWidget(const TestZulipApp( + child: Scaffold(body: Placeholder()))); + await tester.pump(); + context = tester.element(find.byType(Placeholder)); + } + + group('showErrorDialog', () { + testWidgets('show error dialog', (tester) async { + await prepare(tester); + + showErrorDialog(context: context, title: title, message: message); + await tester.pump(); + checkErrorDialog(tester, expectedTitle: title, expectedMessage: message); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('user closes error dialog', (tester) async { + await prepare(tester); + + showErrorDialog(context: context, title: title, message: message); + await tester.pump(); + + final button = checkErrorDialog(tester, expectedTitle: title); + await tester.tap(find.byWidget(button)); + await tester.pump(); + checkNoDialog(tester); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('tap "Learn more" button', (tester) async { + await prepare(tester); + + final learnMoreButtonUrl = Uri.parse('https://foo.example'); + showErrorDialog(context: context, title: title, learnMoreButtonUrl: learnMoreButtonUrl); + await tester.pump(); + checkErrorDialog(tester, expectedTitle: title); + + await tester.tap(find.text('Learn more')); + final expectedMode = switch (defaultTargetPlatform) { + TargetPlatform.android => LaunchMode.inAppBrowserView, + TargetPlatform.iOS => LaunchMode.externalApplication, + _ => throw StateError('attempted to test with $defaultTargetPlatform'), + }; + check(testBinding.takeLaunchUrlCalls()).single + .equals((url: learnMoreButtonUrl, mode: expectedMode)); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('only one SingleChildScrollView created', (tester) async { + await prepare(tester); + + showErrorDialog(context: context, title: title, message: message); + await tester.pump(); + checkErrorDialog(tester, expectedTitle: title, expectedMessage: message); + + check(find.ancestor(of: find.text(message), + matching: find.byType(SingleChildScrollView))).findsOne(); + }, variant: TargetPlatformVariant.all()); + }); + + group('showSuggestedActionDialog', () { + testWidgets('tap action button', (tester) async { + addTearDown(testBinding.reset); + await tester.pumpWidget(TestZulipApp()); + await tester.pump(); + final element = tester.element(find.byType(Placeholder)); + + final dialog = showSuggestedActionDialog(context: element, + title: 'Continue?', + message: 'Do the thing?', + actionButtonText: 'Sure'); + await tester.pump(); + await tester.tap(find.text('Sure')); + await check(dialog.result).completes((it) => it.equals(true)); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('tap cancel', (tester) async { + addTearDown(testBinding.reset); + await tester.pumpWidget(TestZulipApp()); + await tester.pump(); + final element = tester.element(find.byType(Placeholder)); + + final dialog = showSuggestedActionDialog(context: element, + title: 'Continue?', + message: 'Do the thing?', + actionButtonText: 'Sure'); + await tester.pump(); + await tester.tap(find.text('Cancel')); + await check(dialog.result).completes((it) => it.equals(null)); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('tap outside dialog area', (tester) async { + addTearDown(testBinding.reset); + await tester.pumpWidget(TestZulipApp()); + await tester.pump(); + final element = tester.element(find.byType(Placeholder)); + + final dialog = showSuggestedActionDialog(context: element, + title: 'Continue?', + message: 'Do the thing?', + actionButtonText: 'Sure'); + await tester.pump(); + await tester.tapAt(tester.getTopLeft(find.byType(TestZulipApp))); + await check(dialog.result).completes((it) => it.equals(null)); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + }); + + testWidgets('only one SingleChildScrollView created', (tester) async { + addTearDown(testBinding.reset); + await tester.pumpWidget(TestZulipApp()); + await tester.pump(); + final element = tester.element(find.byType(Placeholder)); + + showSuggestedActionDialog(context: element, + title: 'Continue?', + message: 'Do the thing?', + actionButtonText: 'Sure'); + await tester.pump(); + + check(find.ancestor(of: find.text('Do the thing?'), + matching: find.byType(SingleChildScrollView))).findsOne(); + }, variant: TargetPlatformVariant.all()); + + group('UpgradeWelcomeDialog', () { + // TODO(#1594): test LegacyUpgradeState and BoolGlobalSetting.upgradeWelcomeDialogShown + + testWidgets('only one SingleChildScrollView created', (tester) async { + final transitionDurationObserver = TransitionDurationObserver(); + addTearDown(testBinding.reset); + + // Real ZulipApp needed because the show-dialog function calls + // `await ZulipApp.navigator`. + await tester.pumpWidget(ZulipApp(navigatorObservers: [transitionDurationObserver])); + await tester.pump(); + + await testBinding.globalStore.settings + .debugSetLegacyUpgradeState(LegacyUpgradeState.found); + + UpgradeWelcomeDialog.maybeShow(); + await transitionDurationObserver.pumpPastTransition(tester); + + final expectedMessage = 'You’ll find a familiar experience in a faster, sleeker package.'; + check(find.ancestor(of: find.text(expectedMessage), + matching: find.byType(SingleChildScrollView))).findsOne(); + }, variant: TargetPlatformVariant.all()); + }); +} diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index 89333ee1af..d50b7c8d7b 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -1,18 +1,20 @@ import 'dart:io' as io; import 'dart:io'; +import 'dart:ui'; import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.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:legacy_checks/legacy_checks.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/realm.dart'; -import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; @@ -28,6 +30,7 @@ import '../model/emoji_test.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_images.dart'; +import 'checks.dart'; import 'content_test.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; @@ -35,9 +38,11 @@ import 'text_test.dart'; void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; late PerAccountStore store; late FakeApiConnection connection; + late TransitionDurationObserver transitionDurationObserver; Future prepare() async { addTearDown(testBinding.reset); @@ -52,13 +57,36 @@ void main() { await fontLoader.load(); } + // Base JSON for various unicode emoji reactions. Just missing user_id. + final u1 = {'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji'}; + final u2 = {'emoji_name': 'family_man_man_girl_boy', 'emoji_code': '1f468-200d-1f468-200d-1f467-200d-1f466', 'reaction_type': 'unicode_emoji'}; + final u3 = {'emoji_name': 'slight_smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}; + final u4 = {'emoji_name': 'tada', 'emoji_code': '1f389', 'reaction_type': 'unicode_emoji'}; + final u5 = {'emoji_name': 'exploding_head', 'emoji_code': '1f92f', 'reaction_type': 'unicode_emoji'}; + + // Base JSON for various realm-emoji reactions. Just missing user_id. + final i1 = {'emoji_name': 'twocents', 'emoji_code': '181', 'reaction_type': 'realm_emoji'}; + final i2 = {'emoji_name': 'threecents', 'emoji_code': '182', 'reaction_type': 'realm_emoji'}; + + // Base JSON for the one "Zulip extra emoji" reaction. Just missing user_id. + final z1 = {'emoji_name': 'zulip', 'emoji_code': 'zulip', 'reaction_type': 'zulip_extra_emoji'}; + + String nameOf(Map jsonEmoji) => jsonEmoji['emoji_name']!; + Future setupChipsInBox(WidgetTester tester, { required List reactions, double width = 245.0, // (seen in context on an iPhone 13 Pro) }) async { final message = eg.streamMessage(reactions: reactions); + await store.addMessage(message); + + tester.platformDispatcher.accessibilityFeaturesTestValue = + FakeAccessibilityFeatures(accessibleNavigation: true); + addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue); + transitionDurationObserver = TransitionDurationObserver(); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + navigatorObservers: [transitionDurationObserver], child: Center( child: ColoredBox( color: Colors.white, @@ -74,6 +102,44 @@ void main() { check(reactionChipsList).size.isNotNull().width.equals(width); } + final findViewReactionsTabBar = find.semantics.byPredicate((node) => + node.role == SemanticsRole.tabBar + && node.label.contains('Emoji reactions')); + + FinderBase findViewReactionsEmojiItem(String emojiName) => + find.semantics.descendant( + of: findViewReactionsTabBar, + matching: find.semantics.byPredicate( + (node) => node.role == SemanticsRole.tab && node.label.contains(emojiName))); + + /// Checks that a given emoji item is present or absent in [ViewReactions]. + /// + /// If the `expectFoo` fields are null, checks that the item is absent, + /// otherwise checks that it is present with the given details. + void checkViewReactionsEmojiItem(WidgetTester tester, { + required String emojiName, + required int? expectCount, + required bool? expectSelected, + }) { + assert((expectCount == null) == (expectSelected == null)); + check(findViewReactionsTabBar).findsOne(); + + final nodes = findViewReactionsEmojiItem(emojiName).evaluate(); + check(nodes).length.isLessThan(2); + + if (expectCount == null) { + check(nodes).isEmpty(); + } else { + final expectedLabel = switch (expectCount) { + 1 => '$emojiName: 1 vote', + _ => '$emojiName: $expectCount votes', + }; + check(nodes).single.containsSemantics( + label: expectedLabel, + isSelected: expectSelected!); + } + } + group('ReactionChipsList', () { // Smoke tests under various conditions. for (final displayEmojiReactionUsers in [true, false]) { @@ -141,8 +207,8 @@ void main() { await setupChipsInBox(tester, reactions: reactions); final reactionChipsList = tester.element(find.byType(ReactionChipsList)); - check(MediaQuery.of(reactionChipsList)) - .textScaler.equals(TextScaler.linear(textScaleFactor)); + check(MediaQuery.of(reactionChipsList).textScaler).legacyMatcher( + isSystemTextScaler(withScaleFactor: textScaleFactor)); check(Directionality.of(reactionChipsList)).equals(textDirection); // TODO(upstream) Do these in an addTearDown, once we can: @@ -157,20 +223,6 @@ void main() { skip: io.Platform.isMacOS); } - // Base JSON for various unicode emoji reactions. Just missing user_id. - final u1 = {'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji'}; - final u2 = {'emoji_name': 'family_man_man_girl_boy', 'emoji_code': '1f468-200d-1f468-200d-1f467-200d-1f466', 'reaction_type': 'unicode_emoji'}; - final u3 = {'emoji_name': 'smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}; - final u4 = {'emoji_name': 'tada', 'emoji_code': '1f389', 'reaction_type': 'unicode_emoji'}; - final u5 = {'emoji_name': 'exploding_head', 'emoji_code': '1f92f', 'reaction_type': 'unicode_emoji'}; - - // Base JSON for various realm-emoji reactions. Just missing user_id. - final i1 = {'emoji_name': 'twocents', 'emoji_code': '181', 'reaction_type': 'realm_emoji'}; - final i2 = {'emoji_name': 'threecents', 'emoji_code': '182', 'reaction_type': 'realm_emoji'}; - - // Base JSON for the one "Zulip extra emoji" reaction. Just missing user_id. - final z1 = {'emoji_name': 'zulip', 'emoji_code': 'zulip', 'reaction_type': 'zulip_extra_emoji'}; - final user1 = eg.user(fullName: 'abc'); final user2 = eg.user(fullName: 'Long Name With Many Words In It'); final user3 = eg.user(fullName: 'longnamelongnamelongnamelongname'); @@ -226,6 +278,45 @@ void main() { } } } + + testWidgets('show "Muted user" label for muted reactors', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + + await prepare(); + await store.addUsers([user1, user2]); + await store.setMutedUsers([user1.userId]); + await setupChipsInBox(tester, + reactions: [ + Reaction.fromJson({'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': user1.userId}), + Reaction.fromJson({'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': user2.userId}), + ]); + + final reactionChipFinder = find.byType(ReactionChip); + check(reactionChipFinder).findsOne(); + check(find.descendant( + of: reactionChipFinder, + matching: find.text('Muted user, User 2') + )).findsOne(); + }); + + testWidgets('show view-reactions sheet on long-press', (tester) async { + await prepare(); + await store.addUser(eg.otherUser); + + await setupChipsInBox(tester, + reactions: [ + Reaction.fromJson({'user_id': eg.selfUser.userId, ...u1}), + Reaction.fromJson({'user_id': eg.otherUser.userId, ...u2}), + ]); + + await tester.longPress(find.byType(ReactionChip).last); + await tester.pump(); + await transitionDurationObserver.pumpPastTransition(tester); + + checkViewReactionsEmojiItem(tester, + emojiName: nameOf(u2), expectCount: 1, expectSelected: true); + }); }); testWidgets('Smoke test for light/dark/lerped', (tester) async { @@ -238,7 +329,7 @@ void main() { await setupChipsInBox(tester, reactions: [ Reaction.fromJson({ 'user_id': eg.selfUser.userId, - 'emoji_name': 'smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}), + 'emoji_name': 'slight_smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}), Reaction.fromJson({ 'user_id': eg.otherUser.userId, 'emoji_name': 'tada', 'emoji_code': '1f389', 'reaction_type': 'unicode_emoji'}), @@ -246,11 +337,12 @@ void main() { Color? backgroundColor(String emojiName) { final material = tester.widget(find.descendant( - of: find.byTooltip(emojiName), matching: find.byType(Material))); + of: find.bySemanticsLabel(RegExp(r'^' + RegExp.escape(emojiName) + r':\ ')), + matching: find.byType(Material))); return material.color; } - check(backgroundColor('smile')).isNotNull() + check(backgroundColor('slight_smile')).isNotNull() .isSameColorAs(EmojiReactionTheme.light.bgSelected); check(backgroundColor('tada')).isNotNull() .isSameColorAs(EmojiReactionTheme.light.bgUnselected); @@ -260,13 +352,13 @@ void main() { await tester.pump(kThemeAnimationDuration * 0.4); final expectedLerped = EmojiReactionTheme.light.lerp(EmojiReactionTheme.dark, 0.4); - check(backgroundColor('smile')).isNotNull() + check(backgroundColor('slight_smile')).isNotNull() .isSameColorAs(expectedLerped.bgSelected); check(backgroundColor('tada')).isNotNull() .isSameColorAs(expectedLerped.bgUnselected); await tester.pump(kThemeAnimationDuration * 0.6); - check(backgroundColor('smile')).isNotNull() + check(backgroundColor('slight_smile')).isNotNull() .isSameColorAs(EmojiReactionTheme.dark.bgSelected); check(backgroundColor('tada')).isNotNull() .isSameColorAs(EmojiReactionTheme.dark.bgUnselected); @@ -298,14 +390,17 @@ void main() { // - Non-animated image emoji is selected when intended group('EmojiPicker', () { - final popularCandidates = EmojiStore.popularEmojiCandidates; + final popularCandidates = + (eg.store()..setServerEmojiData(eg.serverEmojiDataPopular)) + .popularEmojiCandidates(); Future setupEmojiPicker(WidgetTester tester, { required StreamMessage message, required Narrow narrow, }) async { addTearDown(testBinding.reset); - assert(narrow.containsMessage(message)); + // TODO(#1667) will be null in a search narrow; remove `!`. + assert(narrow.containsMessage(message)!); final httpClient = FakeImageHttpClient(); debugNetworkImageHttpClientProvider = () => httpClient; @@ -329,6 +424,11 @@ void main() { await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage(initNarrow: narrow))); + store.setServerEmojiData(eg.serverEmojiDataPopularPlus( + ServerEmojiData(codeToNames: { + '1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data) + }))); + // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); // request the message action sheet @@ -336,9 +436,6 @@ void main() { // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); - store.setServerEmojiData(ServerEmojiData(codeToNames: { - '1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data) - })); await store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: { '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'buzzing'), })); @@ -351,6 +448,9 @@ void main() { final searchFieldFinder = find.widgetWithText(TextField, 'Search emoji'); + Finder findInPicker(Finder finder) => + find.descendant(of: find.byType(EmojiPicker), matching: finder); + Condition conditionEmojiListEntry({ required ReactionType emojiType, required String emojiCode, @@ -429,9 +529,7 @@ void main() { await setupEmojiPicker(tester, message: message, narrow: TopicNarrow.ofMessage(message)); connection.prepare(json: {}); - await tester.tap(find.descendant( - of: find.byType(BottomSheet), - matching: find.text('\u{1f4a4}'))); // 'zzz' emoji + await tester.tap(findInPicker(find.text('\u{1f4a4}'))); // 'zzz' emoji await tester.pump(Duration.zero); check(connection.lastRequest).isA() @@ -452,15 +550,9 @@ void main() { connection.prepare( delay: const Duration(seconds: 2), - httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }); - - await tester.tap(find.descendant( - of: find.byType(BottomSheet), - matching: find.text('\u{1f4a4}'))); // 'zzz' emoji + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); + + await tester.tap(findInPicker(find.text('\u{1f4a4}'))); // 'zzz' emoji await tester.pump(); // register tap await tester.pump(const Duration(seconds: 1)); // emoji picker animates away await tester.pump(const Duration(seconds: 1)); // error arrives; error dialog shows @@ -471,9 +563,205 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + + group('handle view paddings', () { + const screenHeight = 400.0; + + late Rect scrollViewRect; + final scrollViewFinder = findInPicker(find.bySubtype()); + + Rect getListEntriesRect(WidgetTester tester) => + tester.getRect(find.byType(EmojiPickerListEntry).first) + .expandToInclude(tester.getRect(find.byType(EmojiPickerListEntry).last)); + + Future prepare(WidgetTester tester, { + required FakeViewPadding viewPadding, + }) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = Size(640, screenHeight); + // This makes it easier to convert between device pixels used for + // [FakeViewPadding] and logical pixels used in tests. + // If needed, there is a clearer way to implement this generally. + // See comment: https://github.com/zulip/zulip-flutter/pull/1315/files#r1962703436 + tester.view.devicePixelRatio = 1.0; + + tester.view.viewPadding = viewPadding; + tester.view.padding = viewPadding; + + final message = eg.streamMessage(); + await setupEmojiPicker(tester, + message: message, narrow: TopicNarrow.ofMessage(message)); + + scrollViewRect = tester.getRect(scrollViewFinder); + // The scroll view should expand all the way to the bottom of the + // screen, even if there is device bottom padding. + check(scrollViewRect) + ..bottom.equals(screenHeight) + // There should always be enough entries to overflow the scroll view. + ..height.isLessThan(getListEntriesRect(tester).height); + } + + testWidgets('no view padding', (tester) async { + await prepare(tester, viewPadding: FakeViewPadding.zero); + + // The top edge of the list entries is padded by 8px from the top edge + // of the scroll view; the bottom edge is out of view. + Rect listEntriesRect = getListEntriesRect(tester); + check(scrollViewRect) + ..top.equals(listEntriesRect.top - 8) + ..bottom.isLessThan(listEntriesRect.bottom); + + // Scroll to the very bottom of the list with a large offset. + await tester.drag(scrollViewFinder, Offset(0, -500)); + await tester.pumpAndSettle(); // let overscroll finish + // The top edge of the list entries is out of view; + // the bottom is padded by 8px, the minimum padding, from the bottom + // edge of the scroll view. + listEntriesRect = getListEntriesRect(tester); + check(scrollViewRect) + ..top.isGreaterThan(listEntriesRect.top) + ..bottom.equals(listEntriesRect.bottom + 8); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('with bottom view padding', (tester) async { + await prepare(tester, viewPadding: FakeViewPadding(bottom: 10)); + + // The top edge of the list entries is padded by 8px from the top edge + // of the scroll view; the bottom edge is out of view. + Rect listEntriesRect = getListEntriesRect(tester); + check(scrollViewRect) + ..top.equals(listEntriesRect.top - 8) + ..bottom.isLessThan(listEntriesRect.bottom); + + // Scroll to the very bottom of the list with a large offset. + await tester.drag(scrollViewFinder, Offset(0, -500)); + await tester.pumpAndSettle(); // let overscroll finish + // The top edge of the list entries is out of view; + // the bottom edge is padded by 10px from the bottom edge of the scroll + // view, because the view bottom padding is larger than the minimum 8px. + listEntriesRect = getListEntriesRect(tester); + check(scrollViewRect) + ..top.isGreaterThan(listEntriesRect.top) + ..bottom.equals(listEntriesRect.bottom + 10); + + debugNetworkImageHttpClientProvider = null; + }); + }); }); -} -extension EmojiPickerListItemChecks on Subject { - Subject get emoji => has((x) => x.emoji, 'emoji'); + group('showViewReactionsSheet', () { + Future setupViewReactionsSheet(WidgetTester tester, { + required StreamMessage message, + List usersExcludingSelf = const [], + }) async { + assert(message.reactions != null && message.reactions!.total > 0); + addTearDown(testBinding.reset); + + final httpClient = FakeImageHttpClient(); + debugNetworkImageHttpClientProvider = () => httpClient; + httpClient.request.response + ..statusCode = HttpStatus.ok + ..content = kSolidBlueAvatar; + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUsers([ + eg.selfUser, + ...usersExcludingSelf, + ]); + final stream = eg.stream(streamId: message.streamId); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + + transitionDurationObserver = TransitionDurationObserver(); + + connection = store.connection as FakeApiConnection; + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + navigatorObservers: [transitionDurationObserver], + child: MessageListPage(initNarrow: CombinedFeedNarrow()))); + + store.setServerEmojiData(eg.serverEmojiDataPopularPlus( + ServerEmojiData(codeToNames: { + '1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data) + }))); + + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + await tester.longPress(find.byType(MessageContent)); + await transitionDurationObserver.pumpPastTransition(tester); + + await store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: { + '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'buzzing'), + })); + + await tester.tap(find.byIcon(ZulipIcons.see_who_reacted)); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byType(ViewReactions)); + } + + void checkUserList(WidgetTester tester, String emojiName, List expectUsers) { + final findPanel = find.semantics.byPredicate((node) => + node.role == SemanticsRole.tabPanel + && node.label.contains('Votes for $emojiName')); + + final panel = findPanel.evaluate().single; + check(panel).containsSemantics(label: 'Votes for $emojiName (${expectUsers.length})'); + + for (final user in expectUsers) { + check(find.semantics.descendant( + of: findPanel, + matching: find.semantics.byLabel(user.fullName)), + because: 'expect ${user.fullName}').findsOne(); + } + } + + testWidgets('smoke', (tester) async { + final reactions = [ + Reaction.fromJson({'user_id': eg.selfUser.userId, ...i1}), + Reaction.fromJson({'user_id': eg.selfUser.userId, ...z1}), + Reaction.fromJson({'user_id': eg.selfUser.userId, ...u1}), + Reaction.fromJson({'user_id': eg.selfUser.userId, ...u2}), + + Reaction.fromJson({'user_id': eg.otherUser.userId, ...i1}), + Reaction.fromJson({'user_id': eg.otherUser.userId, ...z1}), + Reaction.fromJson({'user_id': eg.otherUser.userId, ...u2}), + Reaction.fromJson({'user_id': eg.otherUser.userId, ...u3}), + ]; + + final message = eg.streamMessage(reactions: reactions); + await setupViewReactionsSheet(tester, message: message, usersExcludingSelf: [eg.otherUser]); + + checkViewReactionsEmojiItem(tester, emojiName: nameOf(i1), expectCount: 2, expectSelected: true); + checkViewReactionsEmojiItem(tester, emojiName: nameOf(z1), expectCount: 2, expectSelected: false); + checkViewReactionsEmojiItem(tester, emojiName: nameOf(u1), expectCount: 1, expectSelected: false); + checkViewReactionsEmojiItem(tester, emojiName: nameOf(u2), expectCount: 2, expectSelected: false); + checkViewReactionsEmojiItem(tester, emojiName: nameOf(u3), expectCount: 1, expectSelected: false); + + checkUserList(tester, nameOf(i1), [eg.selfUser, eg.otherUser]); + tester.semantics.tap(findViewReactionsEmojiItem(nameOf(z1))); + await tester.pump(); + checkUserList(tester, nameOf(z1), [eg.selfUser, eg.otherUser]); + tester.semantics.tap(findViewReactionsEmojiItem(nameOf(u1))); + await tester.pump(); + checkUserList(tester, nameOf(u1), [eg.selfUser]); + tester.semantics.tap(findViewReactionsEmojiItem(nameOf(u3))); + await tester.pump(); + checkUserList(tester, nameOf(u3), [eg.otherUser]); + + // TODO(upstream) Do this in an addTearDown once we can: + // https://github.com/flutter/flutter/issues/123189 + debugNetworkImageHttpClientProvider = null; + }); + + // TODO test last-vote-removed on selected emoji + // TODO test message deleted + // TODO test that tapping a user opens their profile + // TODO test emoji list's scroll-into-view logic + // TODO test expired event queue/refresh + }); } diff --git a/test/widgets/finders.dart b/test/widgets/finders.dart new file mode 100644 index 0000000000..fe4111cdbe --- /dev/null +++ b/test/widgets/finders.dart @@ -0,0 +1,102 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Like `find.text` from flutter_test upstream, but with +/// the `includePlaceholders` option. +/// +/// When `includePlaceholders` is true, any [PlaceholderSpan] (for example, +/// any [WidgetSpan]) in the tree will be represented as +/// an "object replacement character", U+FFFC. +/// When `includePlaceholders` is false, such spans will be omitted. +/// +/// TODO(upstream): get `find.text` to accept includePlaceholders +Finder findText(String text, { + bool findRichText = false, + bool includePlaceholders = true, + bool skipOffstage = true, +}) { + return _TextWidgetFinder(text, + findRichText: findRichText, + includePlaceholders: includePlaceholders, + skipOffstage: skipOffstage); +} + +// (Compare the implementation in `package:flutter_test/src/finders.dart`.) +abstract class _MatchTextFinder extends MatchFinder { + _MatchTextFinder({this.findRichText = false, this.includePlaceholders = true, + super.skipOffstage}); + + /// Whether standalone [RichText] widgets should be found or not. + /// + /// Defaults to `false`. + /// + /// If disabled, only [Text] widgets will be matched. [RichText] widgets + /// *without* a [Text] ancestor will be ignored. + /// If enabled, only [RichText] widgets will be matched. This *implicitly* + /// matches [Text] widgets as well since they always insert a [RichText] + /// child. + /// + /// In either case, [EditableText] widgets will also be matched. + final bool findRichText; + + final bool includePlaceholders; + + bool matchesText(String textToMatch); + + @override + bool matches(Element candidate) { + final Widget widget = candidate.widget; + if (widget is EditableText) { + return _matchesEditableText(widget); + } + + if (!findRichText) { + return _matchesNonRichText(widget); + } + // It would be sufficient to always use _matchesRichText if we wanted to + // match both standalone RichText widgets as well as Text widgets. However, + // the find.text() finder used to always ignore standalone RichText widgets, + // which is why we need the _matchesNonRichText method in order to not be + // backwards-compatible and not break existing tests. + return _matchesRichText(widget); + } + + bool _matchesRichText(Widget widget) { + if (widget is RichText) { + return matchesText(widget.text.toPlainText( + includePlaceholders: includePlaceholders)); + } + return false; + } + + bool _matchesNonRichText(Widget widget) { + if (widget is Text) { + if (widget.data != null) { + return matchesText(widget.data!); + } + assert(widget.textSpan != null); + return matchesText(widget.textSpan!.toPlainText( + includePlaceholders: includePlaceholders)); + } + return false; + } + + bool _matchesEditableText(EditableText widget) { + return matchesText(widget.controller.text); + } +} + +class _TextWidgetFinder extends _MatchTextFinder { + _TextWidgetFinder(this.text, {super.findRichText, super.includePlaceholders, + super.skipOffstage}); + + final String text; + + @override + String get description => 'text "$text"'; + + @override + bool matchesText(String textToMatch) { + return textToMatch == text; + } +} diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 5bb789727f..1ee0a0ae8e 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -2,11 +2,10 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:zulip/api/model/events.dart'; +import 'package:zulip/model/actions.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'; @@ -25,8 +24,7 @@ import '../model/binding.dart'; import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; +import 'checks.dart'; import 'test_app.dart'; void main () { @@ -35,10 +33,25 @@ void main () { late PerAccountStore store; late FakeApiConnection connection; - Future prepare(WidgetTester tester, { - NavigatorObserver? navigatorObserver, - }) async { + late Route? topRoute; + late Route? previousTopRoute; + late List> pushedRoutes; + late Route? lastPoppedRoute; + + final testNavObserver = TestNavigatorObserver() + ..onChangedTop = ((current, previous) { + topRoute = current; + previousTopRoute = previous; + }) + ..onPushed = ((route, prevRoute) => pushedRoutes.add(route)) + ..onPopped = ((route, prevRoute) => lastPoppedRoute = route); + + Future prepare(WidgetTester tester) async { addTearDown(testBinding.reset); + topRoute = null; + previousTopRoute = null; + pushedRoutes = []; + lastPoppedRoute = null; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); connection = store.connection as FakeApiConnection; @@ -46,7 +59,7 @@ void main () { await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, - navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [], + navigatorObservers: [testNavObserver], child: const HomePage())); await tester.pump(); } @@ -72,8 +85,8 @@ void main () { testWidgets('preserve states when switching between views', (tester) async { await prepare(tester); await store.addUser(eg.otherUser); - await store.handleEvent(MessageEvent( - id: 0, message: eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]))); + await store.addMessage( + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); await tester.pump(); check(find.byIcon(ZulipIcons.arrow_down)).findsExactly(2); @@ -111,7 +124,7 @@ void main () { of: find.byType(ZulipAppBar), matching: find.text('Channels'))).findsOne(); - await tester.tap(find.byIcon(ZulipIcons.user)); + await tester.tap(find.byIcon(ZulipIcons.two_person)); await tester.pump(); check(find.descendant( of: find.byType(ZulipAppBar), @@ -119,67 +132,110 @@ void main () { }); testWidgets('combined feed', (tester) async { - final pushedRoutes = >[]; - final testNavObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); - await prepare(tester, navigatorObserver: testNavObserver); + await prepare(tester); pushedRoutes.clear(); connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: []).toJson()); await tester.tap(find.byIcon(ZulipIcons.message_feed)); await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); check(pushedRoutes).single.isA().page .isA() .initNarrow.equals(const CombinedFeedNarrow()); + await tester.pump(Duration.zero); // message-list fetch }); }); group('menu', () { final designVariables = DesignVariables.light; - final inboxMenuIconFinder = find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(ZulipIcons.inbox)); - final channelsMenuIconFinder = find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(ZulipIcons.hash_italic)); - final combinedFeedMenuIconFinder = find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(ZulipIcons.message_feed)); - - Future tapOpenMenu(WidgetTester tester) async { + final inboxMenuIconFinder = find.byIcon(ZulipIcons.inbox); + final channelsMenuIconFinder = find.byIcon(ZulipIcons.hash_italic); + final combinedFeedMenuIconFinder = find.byIcon(ZulipIcons.message_feed); + + Future tapOpenMenuAndAwait(WidgetTester tester) async { + final topRouteBeforePress = topRoute; await tester.tap(find.byIcon(ZulipIcons.menu)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tester.pump(); + final topRouteAfterPress = topRoute; + check(topRouteAfterPress).isA>(); + await tester.pump((topRouteAfterPress as ModalBottomSheetRoute).transitionDuration); + + // This was the only change during the interaction. + check(topRouteBeforePress).identicalTo(previousTopRoute); + + // We got to the sheet by pushing, not popping or something else. + check(pushedRoutes.last).identicalTo(topRouteAfterPress); + check(find.byType(BottomSheet)).findsOne(); } + /// Taps the [buttonFinder] button and awaits the bottom sheet's exit. + /// + /// Includes a check that the bottom sheet is gone. + /// Also awaits the transition to a new pushed route, if one is pushed. + /// + /// [buttonFinder] will be run only in the bottom sheet's subtree; + /// it doesn't need its own `find.descendant` logic. + Future tapButtonAndAwaitTransition(WidgetTester tester, Finder buttonFinder) async { + final topRouteBeforePress = topRoute; + check(topRouteBeforePress).isA>(); + final numPushedRoutesBeforePress = pushedRoutes.length; + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: buttonFinder)); + await tester.pump(Duration.zero); + + final newPushedRoute = pushedRoutes.skip(numPushedRoutesBeforePress) + .singleOrNull; + + final sheetPopDuration = (topRouteBeforePress as ModalBottomSheetRoute) + .reverseTransitionDuration; + // TODO not sure why a 1ms fudge is needed; investigate. + await tester.pump(sheetPopDuration + Duration(milliseconds: 1)); + check(find.byType(BottomSheet)).findsNothing(); + + if (newPushedRoute != null) { + final pushDuration = (newPushedRoute as TransitionRoute).transitionDuration; + if (pushDuration > sheetPopDuration) { + await tester.pump(pushDuration - sheetPopDuration); + } + } + + // We dismissed the sheet by popping, not pushing or replacing. + check(topRouteBeforePress as Route?) + ..not((it) => it.identicalTo(topRoute)) + ..identicalTo(lastPoppedRoute); + } + void checkIconSelected(WidgetTester tester, Finder finder) { - check(tester.widget(finder)).isA().color.isNotNull() + final widget = tester.widget(find.descendant( + of: find.byType(BottomSheet), + matching: finder)); + check(widget).isA().color.isNotNull() .isSameColorAs(designVariables.iconSelected); } void checkIconNotSelected(WidgetTester tester, Finder finder) { - check(tester.widget(finder)).isA().color.isNotNull() + final widget = tester.widget(find.descendant( + of: find.byType(BottomSheet), + matching: finder)); + check(widget).isA().color.isNotNull() .isSameColorAs(designVariables.icon); } testWidgets('navigation states reflect on navigation bar menu buttons', (tester) async { await prepare(tester); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); checkIconSelected(tester, inboxMenuIconFinder); checkIconNotSelected(tester, channelsMenuIconFinder); - await tester.tap(find.text('Cancel')); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tapButtonAndAwaitTransition(tester, find.text('Close')); await tester.tap(find.byIcon(ZulipIcons.hash_italic)); await tester.pump(); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); checkIconNotSelected(tester, inboxMenuIconFinder); checkIconSelected(tester, channelsMenuIconFinder); }); @@ -187,85 +243,74 @@ void main () { testWidgets('navigation bar menu buttons control navigation states', (tester) async { await prepare(tester); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); checkIconSelected(tester, inboxMenuIconFinder); checkIconNotSelected(tester, channelsMenuIconFinder); check(find.byType(InboxPageBody)).findsOne(); check(find.byType(SubscriptionListPageBody)).findsNothing(); - await tester.tap(channelsMenuIconFinder); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation - check(find.byType(BottomSheet)).findsNothing(); + await tapButtonAndAwaitTransition(tester, channelsMenuIconFinder); check(find.byType(InboxPageBody)).findsNothing(); check(find.byType(SubscriptionListPageBody)).findsOne(); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); checkIconNotSelected(tester, inboxMenuIconFinder); checkIconSelected(tester, channelsMenuIconFinder); }); testWidgets('navigation bar menu buttons dismiss the menu', (tester) async { await prepare(tester); - await tapOpenMenu(tester); - - await tester.tap(channelsMenuIconFinder); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation - check(find.byType(BottomSheet)).findsNothing(); + await tapOpenMenuAndAwait(tester); + await tapButtonAndAwaitTransition(tester, channelsMenuIconFinder); }); - testWidgets('cancel button dismisses the menu', (tester) async { + testWidgets('close button dismisses the menu', (tester) async { await prepare(tester); - await tapOpenMenu(tester); - - await tester.tap(find.text('Cancel')); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation - check(find.byType(BottomSheet)).findsNothing(); + await tapOpenMenuAndAwait(tester); + await tapButtonAndAwaitTransition(tester, find.text('Close')); }); testWidgets('menu buttons dismiss the menu', (tester) async { addTearDown(testBinding.reset); + topRoute = null; + previousTopRoute = null; + pushedRoutes = []; + lastPoppedRoute = null; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await tester.pumpWidget(const ZulipApp()); + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); final connection = store.connection as FakeApiConnection; await tester.pump(); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [eg.streamMessage()]).toJson()); - await tester.tap(combinedFeedMenuIconFinder); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tapButtonAndAwaitTransition(tester, combinedFeedMenuIconFinder); // When we go back to the home page, the menu sheet should be gone. + final topBeforePop = topRoute; + check(topBeforePop).isNotNull().isA() + .page.isA().initNarrow.equals(CombinedFeedNarrow()); (await ZulipApp.navigator).pop(); - await tester.pump(const Duration(milliseconds: 350)); // wait for pop animation + await tester.pump((topBeforePop as TransitionRoute).reverseTransitionDuration); + check(find.byType(BottomSheet)).findsNothing(); }); testWidgets('_MyProfileButton', (tester) async { await prepare(tester); - await tapOpenMenu(tester); - - await tester.tap(find.text('My profile')); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tapOpenMenuAndAwait(tester); + await tapButtonAndAwaitTransition(tester, find.text('My profile')); check(find.byType(ProfilePage)).findsOne(); check(find.text(eg.selfUser.fullName)).findsAny(); }); testWidgets('_AboutZulipButton', (tester) async { await prepare(tester); - await tapOpenMenu(tester); - - await tester.tap(find.byIcon(ZulipIcons.info)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tapOpenMenuAndAwait(tester); + await tapButtonAndAwaitTransition(tester, find.byIcon(ZulipIcons.info)); check(find.byType(AboutZulipPage)).findsOne(); }); }); @@ -283,24 +328,40 @@ void main () { Future prepare(WidgetTester tester) async { addTearDown(testBinding.reset); + topRoute = null; + previousTopRoute = null; + pushedRoutes = []; + lastPoppedRoute = null; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await testBinding.globalStore.add(eg.otherAccount, eg.initialSnapshot()); - await tester.pumpWidget(const ZulipApp()); + await testBinding.globalStore.add( + eg.otherAccount, eg.initialSnapshot(realmUsers: [eg.otherUser]), + markLastVisited: false); + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); await tester.pump(Duration.zero); // wait for the loading page checkOnLoadingPage(); } - Future tapChooseAccount(WidgetTester tester) async { + Future tapTryAnotherAccount(WidgetTester tester) async { + final numPushedRoutesBefore = pushedRoutes.length; await tester.tap(find.text('Try another account')); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tester.pump(); + final pushedRoute = pushedRoutes.skip(numPushedRoutesBefore).single; + check(pushedRoute).isA().page.isA(); + await tester.pump((pushedRoute as TransitionRoute).transitionDuration); checkOnChooseAccountPage(); } Future chooseAccountWithEmail(WidgetTester tester, String email) async { + lastPoppedRoute = null; await tester.tap(find.text(email)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 350)); // wait for push & pop animations + await tester.pump(); + check(topRoute).isA().page.isA(); + check(lastPoppedRoute).isA().page.isA(); + final popDuration = (lastPoppedRoute as TransitionRoute).reverseTransitionDuration; + final pushDuration = (topRoute as TransitionRoute).transitionDuration; + final animationDuration = popDuration > pushDuration ? popDuration : pushDuration; + // TODO not sure why a 1ms fudge is needed; investigate. + await tester.pump(animationDuration + Duration(milliseconds: 1)); checkOnLoadingPage(); } @@ -331,11 +392,16 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); + lastPoppedRoute = null; await tester.tap(find.byType(BackButton)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 350)); // wait for pop animation + await tester.pump(); + check(lastPoppedRoute).isA().page.isA(); + await tester.pump( + (lastPoppedRoute as TransitionRoute).reverseTransitionDuration + // TODO not sure why a 1ms fudge is needed; investigate. + + Duration(milliseconds: 1)); checkOnLoadingPage(); await tester.pump(loadPerAccountDuration); @@ -346,7 +412,7 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration * 2; await chooseAccountWithEmail(tester, eg.otherAccount.email); @@ -364,7 +430,7 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); // While still loading, choose a different account. await chooseAccountWithEmail(tester, eg.otherAccount.email); @@ -381,12 +447,13 @@ void main () { testWidgets('while loading, go to nested levels of ChooseAccountPage', (tester) async { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; final thirdAccount = eg.account(user: eg.thirdUser); - await testBinding.globalStore.add(thirdAccount, eg.initialSnapshot()); + await testBinding.globalStore.add(thirdAccount, eg.initialSnapshot( + realmUsers: [eg.thirdUser])); await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); // While still loading the first account, choose a different account. - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); await chooseAccountWithEmail(tester, eg.otherAccount.email); // User cannot go back because the navigator stack // was cleared after choosing an account. @@ -397,7 +464,7 @@ void main () { await tester.pump(kTryAnotherAccountWaitPeriod); // While still loading the second account, choose a different account. - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); await chooseAccountWithEmail(tester, thirdAccount.email); // User cannot go back because the navigator stack // was cleared after choosing an account. @@ -414,15 +481,20 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); // Stall while on ChoooseAccountPage so that the account finished loading. await tester.pump(loadPerAccountDuration); checkOnChooseAccountPage(); + lastPoppedRoute = null; await tester.tap(find.byType(BackButton)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 350)); // wait for pop animation + await tester.pump(); + check(lastPoppedRoute).isA().page.isA(); + await tester.pump( + (lastPoppedRoute as TransitionRoute).reverseTransitionDuration + // TODO not sure why a 1ms fudge is needed; investigate. + + Duration(milliseconds: 1)); checkOnHomePage(tester, expectedAccount: eg.selfAccount); }); @@ -430,16 +502,21 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); // Stall while on ChoooseAccountPage so that the account finished loading. await tester.pump(loadPerAccountDuration); checkOnChooseAccountPage(); // Choosing the already loaded account should result in no loading page. + lastPoppedRoute = null; await tester.tap(find.text(eg.selfAccount.email)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 350)); // wait for push & pop animations + await tester.pump(); + check(lastPoppedRoute).isA().page.isA(); + await tester.pump( + (lastPoppedRoute as TransitionRoute).reverseTransitionDuration + // TODO not sure why a 1ms fudge is needed; investigate. + + Duration(milliseconds: 1)); // No additional wait for loadPerAccount. checkOnHomePage(tester, expectedAccount: eg.selfAccount); }); @@ -453,8 +530,7 @@ void main () { await tester.pump(); // wait for the loading page checkOnLoadingPage(); - final element = tester.element(find.byType(MaterialApp)); - final future = logOutAccount(element, eg.selfAccount.id); + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); await tester.pump(TestGlobalStore.removeAccountDuration); await future; // No error expected from briefly not having @@ -471,12 +547,15 @@ void main () { 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); + final future = logOutAccount(testBinding.globalStore, 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(); }); + + // TODO end-to-end widget test that checks the error dialog when connecting + // to an ancient server: + // https://github.com/zulip/zulip-flutter/pull/1410#discussion_r1999991512 } diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 3fa3713d5d..fe96b427ab 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -9,6 +9,7 @@ import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/channel_colors.dart'; +import 'package:zulip/widgets/unread_count_badge.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; @@ -58,6 +59,7 @@ void main() { List? subscriptions, List? users, required List unreadMessages, + List? otherMessages, NavigatorObserver? navigatorObserver, }) async { addTearDown(testBinding.reset); @@ -70,7 +72,7 @@ void main() { for (final message in unreadMessages) { assert(!message.flags.contains(MessageFlag.read)); - await store.handleEvent(MessageEvent(id: 1, message: message)); + await store.addMessage(message); } await tester.pumpWidget(TestZulipApp( @@ -196,6 +198,7 @@ void main() { group('InboxPage', () { testWidgets('page builds; empty', (tester) async { await setupPage(tester, unreadMessages: []); + check(find.textContaining('There are no unread messages in your inbox.')).findsOne(); }); // TODO more checks: ordering, etc. @@ -226,7 +229,7 @@ void main() { streams: [stream], subscriptions: [subscription], unreadMessages: [eg.streamMessage(stream: stream, topic: 'lunch')]); - await store.addUserTopic(stream, 'lunch', UserTopicVisibilityPolicy.muted); + await store.setUserTopic(stream, 'lunch', UserTopicVisibilityPolicy.muted); await tester.pump(); check(tester.widgetList(find.text('lunch'))).length.equals(0); }); @@ -248,7 +251,7 @@ void main() { streams: [stream], subscriptions: [subscription], unreadMessages: [eg.streamMessage(stream: stream, topic: 'lunch')]); - await store.addUserTopic(stream, 'lunch', UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(stream, 'lunch', UserTopicVisibilityPolicy.unmuted); await tester.pump(); check(tester.widgetList(find.text('lunch'))).length.equals(1); }); @@ -307,6 +310,15 @@ void main() { }); }); + testWidgets('empty topic', (tester) async { + final channel = eg.stream(); + await setupPage(tester, + streams: [channel], + subscriptions: [(eg.subscription(channel))], + unreadMessages: [eg.streamMessage(stream: channel, topic: '')]); + check(find.text(eg.defaultRealmEmptyTopicDisplayName)).findsOne(); + }); + group('topic visibility', () { final channel = eg.stream(); const topic = 'topic'; @@ -317,7 +329,7 @@ void main() { streams: [channel], subscriptions: [eg.subscription(channel)], unreadMessages: [message]); - await store.addUserTopic(channel, topic, UserTopicVisibilityPolicy.followed); + await store.setUserTopic(channel, topic, UserTopicVisibilityPolicy.followed); await tester.pump(); check(hasIcon(tester, parent: findRowByLabel(tester, topic), @@ -330,7 +342,7 @@ void main() { subscriptions: [eg.subscription(channel)], unreadMessages: [eg.streamMessage(stream: channel, topic: topic, flags: [MessageFlag.mentioned])]); - await store.addUserTopic(channel, topic, UserTopicVisibilityPolicy.followed); + await store.setUserTopic(channel, topic, UserTopicVisibilityPolicy.followed); await tester.pump(); check(hasIcon(tester, parent: findRowByLabel(tester, topic), @@ -346,12 +358,45 @@ void main() { streams: [channel], subscriptions: [eg.subscription(channel, isMuted: true)], unreadMessages: [message]); - await store.addUserTopic(channel, topic, UserTopicVisibilityPolicy.unmuted); + await store.setUserTopic(channel, topic, UserTopicVisibilityPolicy.unmuted); await tester.pump(); check(hasIcon(tester, parent: findRowByLabel(tester, topic), icon: ZulipIcons.unmute)).isTrue(); }); + + testWidgets('unmuted (topics treated case-insensitively)', (tester) async { + // Case-insensitivity of both topic-visibility and unreads data + // TODO(#1065) this belongs in test/model/ once the inbox page has + // its own view-model + + final message1 = eg.streamMessage(stream: channel, topic: 'aaa'); + final message2 = eg.streamMessage(stream: channel, topic: 'AaA', flags: [MessageFlag.read]); + final message3 = eg.streamMessage(stream: channel, topic: 'aAa', flags: [MessageFlag.read]); + await setupPage(tester, + users: [eg.selfUser, eg.otherUser], + streams: [channel], + subscriptions: [eg.subscription(channel, isMuted: true)], + unreadMessages: [message1]); + await store.setUserTopic(channel, 'aaa', UserTopicVisibilityPolicy.unmuted); + await tester.pump(); + + check(find.descendant( + of: find.byWidget(findRowByLabel(tester, 'aaa')!), + matching: find.widgetWithText(UnreadCountBadge, '1'))).findsOne(); + + await store.handleEvent(eg.updateMessageFlagsRemoveEvent(MessageFlag.read, [message2])); + await tester.pump(); + check(find.descendant( + of: find.byWidget(findRowByLabel(tester, 'aaa')!), + matching: find.widgetWithText(UnreadCountBadge, '2'))).findsOne(); + + await store.handleEvent(eg.updateMessageFlagsRemoveEvent(MessageFlag.read, [message3])); + await tester.pump(); + check(find.descendant( + of: find.byWidget(findRowByLabel(tester, 'aaa')!), + matching: find.widgetWithText(UnreadCountBadge, '3'))).findsOne(); + }); }); group('collapsing', () { diff --git a/test/widgets/inset_shadow_test.dart b/test/widgets/inset_shadow_test.dart index a8e3d5f498..6051be47d2 100644 --- a/test/widgets/inset_shadow_test.dart +++ b/test/widgets/inset_shadow_test.dart @@ -16,7 +16,7 @@ void main() { // to ease the check on [Rect] later. alignment: Alignment.topLeft, child: SizedBox(width: 20, height: 20, - child: InsetShadowBox(top: 7, bottom: 3, + child: InsetShadowBox(top: 7, bottom: 3, start: 5, end: 6, color: Colors.red, child: SizedBox.shrink()))))); @@ -29,20 +29,20 @@ void main() { check(childRect).equals(parentRect); }); - testWidgets('render shadow correctly', (tester) async { - PaintPatternPredicate paintGradient({required Rect rect}) { - // This is inspired by - // https://github.com/flutter/flutter/blob/7b5462cc34af903e2f2de4be7540ff858685cdfc/packages/flutter/test/cupertino/route_test.dart#L1449-L1475 - return (Symbol methodName, List arguments) { - check(methodName).equals(#drawRect); - check(arguments[0]).isA().equals(rect); - // We can't further check [ui.Gradient] because it is opaque: - // https://github.com/flutter/engine/blob/07d01ad1199522fa5889a10c1688c4e1812b6625/lib/ui/painting.dart#L4487 - check(arguments[1]).isA().shader.isA(); - return true; - }; - } + PaintPatternPredicate paintGradient({required Rect rect}) { + // This is inspired by + // https://github.com/flutter/flutter/blob/7b5462cc34af903e2f2de4be7540ff858685cdfc/packages/flutter/test/cupertino/route_test.dart#L1449-L1475 + return (Symbol methodName, List arguments) { + check(methodName).equals(#drawRect); + check(arguments[0]).isA().equals(rect); + // We can't further check [ui.Gradient] because it is opaque: + // https://github.com/flutter/engine/blob/07d01ad1199522fa5889a10c1688c4e1812b6625/lib/ui/painting.dart#L4487 + check(arguments[1]).isA().shader.isA(); + return true; + }; + } + testWidgets('render shadow correctly: top/bottom', (tester) async { await tester.pumpWidget(const Directionality( textDirection: TextDirection.ltr, child: Center( @@ -61,4 +61,33 @@ void main() { ..something(paintGradient(rect: const Rect.fromLTRB(0, 100-7, 100, 100))) ) as Matcher); }); + + final textDirectionVariant = + ValueVariant({TextDirection.ltr, TextDirection.rtl}); + + testWidgets('render shadow correctly: start/end', (tester) async { + final textDirection = textDirectionVariant.currentValue!; + await tester.pumpWidget(Directionality( + textDirection: textDirection, + child: Center( + // This would be forced to fill up the screen + // if not wrapped in a widget like [Center]. + child: SizedBox(width: 100, height: 100, + child: InsetShadowBox(start: 3, end: 7, + color: Colors.red, + child: SizedBox(width: 30, height: 30)))))); + + final box = tester.renderObject(find.byType(InsetShadowBox)); + check(box).legacyMatcher( + // The coordinate system of these [Rect]'s is relative to the parent + // of the [Gradient] from [InsetShadowBox], not the entire [FlutterView]. + switch (textDirection) { + TextDirection.ltr => paints + ..something(paintGradient(rect: Rect.fromLTRB(0, 0, 0+3, 100))) + ..something(paintGradient(rect: Rect.fromLTRB(100-7, 0, 100, 100))), + TextDirection.rtl => paints + ..something(paintGradient(rect: Rect.fromLTRB(100-3, 0, 100, 100))) + ..something(paintGradient(rect: Rect.fromLTRB(0, 0, 0+7, 100))), + } as Matcher); + }, variant: textDirectionVariant); } diff --git a/test/widgets/katex_test.dart b/test/widgets/katex_test.dart new file mode 100644 index 0000000000..0d6a93ca52 --- /dev/null +++ b/test/widgets/katex_test.dart @@ -0,0 +1,186 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/widgets/katex.dart'; + +import '../model/binding.dart'; +import '../model/katex_test.dart'; +import 'content_test.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + group('snapshot per-character rects', () { + final testCases = <(KatexExample, List<(String, Offset, Size)>, {bool? skip})>[ + (KatexExample.sizing, skip: false, [ + ('1', Offset(0.00, 2.24), Size(25.59, 61.00)), + ('2', Offset(25.59, 10.04), Size(21.33, 51.00)), + ('3', Offset(46.91, 16.55), Size(17.77, 43.00)), + ('4', Offset(64.68, 21.98), Size(14.80, 36.00)), + ('5', Offset(79.48, 26.50), Size(12.34, 30.00)), + ('6', Offset(91.82, 30.26), Size(10.28, 25.00)), + ('7', Offset(102.10, 32.15), Size(9.25, 22.00)), + ('8', Offset(111.35, 34.03), Size(8.23, 20.00)), + ('9', Offset(119.58, 35.91), Size(7.20, 17.00)), + ('0', Offset(126.77, 39.68), Size(5.14, 12.00)), + ]), + (KatexExample.nestedSizing, skip: false, [ + ('1', Offset(0.00, 40.24), Size(5.14, 12.00)), + ('2', Offset(5.14, 2.80), Size(25.59, 61.00)), + ]), + (KatexExample.delimsizing, skip: false, [ + ('(', Offset(8.00, 20.14), Size(9.42, 25.00)), + ('[', Offset(17.42, 20.14), Size(9.71, 25.00)), + ('⌈', Offset(27.12, 20.14), Size(11.99, 25.00)), + ('⌊', Offset(39.11, 20.14), Size(13.14, 25.00)), + ]), + (KatexExample.spacing, skip: false, [ + ('1', Offset(0.00, 2.24), Size(10.28, 25.00)), + (':', Offset(16.00, 2.24), Size(5.72, 25.00)), + ('2', Offset(27.43, 2.24), Size(10.28, 25.00)), + ]), + (KatexExample.vlistSuperscript, skip: false, [ + ('a', Offset(0.00, 5.28), Size(10.88, 25.00)), + ('′', Offset(10.88, 1.13), Size(3.96, 17.00)), + ]), + (KatexExample.vlistSubscript, skip: false, [ + ('x', Offset(0.00, 5.28), Size(11.76, 25.00)), + ('n', Offset(11.76, 13.65), Size(8.63, 17.00)), + ]), + (KatexExample.vlistSubAndSuperscript, skip: false, [ + ('u', Offset(0.00, 15.65), Size(8.23, 17.00)), + ('o', Offset(0.00, 2.07), Size(6.98, 17.00)), + ]), + (KatexExample.vlistRaisebox, skip: false, [ + ('a', Offset(0.00, 4.16), Size(10.88, 25.00)), + ('b', Offset(10.88, -0.66), Size(8.82, 25.00)), + ('c', Offset(19.70, 4.16), Size(8.90, 25.00)), + ]), + (KatexExample.negativeMargin, skip: false, [ + ('1', Offset(0.00, 3.12), Size(10.28, 25.00)), + ('2', Offset(6.85, 3.36), Size(10.28, 25.00)), + ]), + (KatexExample.katexLogo, skip: false, [ + ('K', Offset(0.0, 8.64), Size(16.0, 25.0)), + ('A', Offset(12.50, 10.85), Size(10.79, 17.0)), + ('T', Offset(20.21, 9.36), Size(14.85, 25.0)), + ('E', Offset(31.63, 14.52), Size(14.0, 25.0)), + ('X', Offset(43.06, 9.85), Size(15.42, 25.0)), + ]), + (KatexExample.vlistNegativeMargin, skip: false, [ + ('X', Offset(0.00, 7.04), Size(17.03, 25.00)), + ('n', Offset(17.03, 15.90), Size(8.63, 17.00)), + ]), + (KatexExample.colonEquals, skip: false, [ + (':', Offset(0.00, 3.45), Size(5.72, 25.00)), + ('=', Offset(5.72, 3.92), Size(16.00, 25.00)), + ]), + (KatexExample.nulldelimiter, skip: false, [ + ('a', Offset(2.47, 3.36), Size(10.88, 25.00)), + ('b', Offset(15.81, 3.36), Size(8.82, 25.00)), + ]), + ]; + + for (final testCase in testCases) { + testWidgets(testCase.$1.description, (tester) async { + await _loadKatexFonts(); + + await prepareContent(tester, plainContent(testCase.$1.html)); + + final baseRect = tester.getRect(find.byType(KatexWidget)); + + for (final characterData in testCase.$2) { + final character = characterData.$1; + final expectedTopLeftOffset = characterData.$2; + final expectedSize = characterData.$3; + + final rect = tester.getRect(find.text(character)); + final topLeftOffset = rect.topLeft - baseRect.topLeft; + final size = rect.size; + + check(topLeftOffset) + .within(distance: 0.05, from: expectedTopLeftOffset); + check(size) + .within(distance: 0.05, from: expectedSize); + } + }, skip: testCase.skip); + } + }); + + group('characters are rendered in specific color', () { + final testCases = <(KatexExample, List<(String, Color)>)>[ + (KatexExample.color, [ + ('0', Color.fromARGB(255, 255, 0, 0)) + ]), + (KatexExample.textColor, [ + ('1', Color.fromARGB(255, 255, 0, 0)) + ]), + (KatexExample.customColorMacro, [ + ('2', Color.fromARGB(255, 223, 0, 48)) + ]), + (KatexExample.phantom, [ + ('∗', Color.fromARGB(0, 0, 0, 0)) + ]) + ]; + + for (final testCase in testCases) { + testWidgets(testCase.$1.description, (tester) async { + await prepareContent(tester, plainContent(testCase.$1.html)); + + for (final characterData in testCase.$2) { + final character = characterData.$1; + final expectedColor = characterData.$2; + + final renderParagraph = + tester.renderObject(find.text(character)); + final color = renderParagraph.text.style?.color; + check(color).equals(expectedColor); + } + }); + } + }); +} + +Future _loadKatexFonts() async { + const fonts = { + 'KaTeX_AMS': ['KaTeX_AMS-Regular.ttf'], + 'KaTeX_Caligraphic': [ + 'KaTeX_Caligraphic-Regular.ttf', + 'KaTeX_Caligraphic-Bold.ttf', + ], + 'KaTeX_Fraktur': [ + 'KaTeX_Fraktur-Regular.ttf', + 'KaTeX_Fraktur-Bold.ttf', + ], + 'KaTeX_Main': [ + 'KaTeX_Main-Regular.ttf', + 'KaTeX_Main-Bold.ttf', + 'KaTeX_Main-Italic.ttf', + 'KaTeX_Main-BoldItalic.ttf', + ], + 'KaTeX_Math': [ + 'KaTeX_Math-Italic.ttf', + 'KaTeX_Math-BoldItalic.ttf', + ], + 'KaTeX_SansSerif': [ + 'KaTeX_SansSerif-Regular.ttf', + 'KaTeX_SansSerif-Bold.ttf', + 'KaTeX_SansSerif-Italic.ttf', + ], + 'KaTeX_Script': ['KaTeX_Script-Regular.ttf'], + 'KaTeX_Size1': ['KaTeX_Size1-Regular.ttf'], + 'KaTeX_Size2': ['KaTeX_Size2-Regular.ttf'], + 'KaTeX_Size3': ['KaTeX_Size3-Regular.ttf'], + 'KaTeX_Size4': ['KaTeX_Size4-Regular.ttf'], + 'KaTeX_Typewriter': ['KaTeX_Typewriter-Regular.ttf'], + }; + for (final MapEntry(key: fontFamily, value: fontFiles) in fonts.entries) { + final fontLoader = FontLoader(fontFamily); + for (final fontFile in fontFiles) { + fontLoader.addFont(rootBundle.load('assets/KaTeX/$fontFile')); + } + await fontLoader.load(); + } +} diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index 31a4132a7c..0df0cde574 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -1,21 +1,29 @@ import 'dart:async'; +import 'dart:math'; import 'package:checks/checks.dart'; import 'package:clock/clock.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import 'package:video_player_platform_interface/video_player_platform_interface.dart'; import 'package:video_player/video_player.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/lightbox.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/user.dart'; +import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; +import '../model/content_test.dart'; +import '../model/test_store.dart'; import '../test_images.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; @@ -24,16 +32,14 @@ const kTestVideoUrl = "https://a/video.mp4"; const kTestUnsupportedVideoUrl = "https://a/unsupported.mp4"; const kTestVideoDuration = Duration(seconds: 10); -class FakeVideoPlayerPlatform extends Fake - with MockPlatformInterfaceMixin - implements VideoPlayerPlatform { +class FakeVideoPlayerPlatform extends VideoPlayerPlatform { static final FakeVideoPlayerPlatform instance = FakeVideoPlayerPlatform(); static void registerWith() { VideoPlayerPlatform.instance = instance; } - static const int _kTextureId = 0xffffffff; + static const int _kPlayerId = 0xffffffff; StreamController _streamController = StreamController(); bool _hasError = false; @@ -96,21 +102,21 @@ class FakeVideoPlayerPlatform extends Fake Future init() async {} @override - Future dispose(int textureId) async { + Future dispose(int playerId) async { if (_hasError) { assert(!initialized); - assert(textureId == VideoPlayerController.kUninitializedTextureId); + assert(playerId == VideoPlayerController.kUninitializedPlayerId); return; } assert(initialized); - assert(textureId == _kTextureId); + assert(playerId == _kPlayerId); } @override - Future create(DataSource dataSource) async { + Future createWithOptions(VideoCreationOptions options) async { assert(!initialized); - if (dataSource.uri == kTestUnsupportedVideoUrl) { + if (options.dataSource.uri == kTestUnsupportedVideoUrl) { _hasError = true; _streamController.addError( PlatformException( @@ -127,24 +133,24 @@ class FakeVideoPlayerPlatform extends Fake size: const Size(100, 100), rotationCorrection: 0, )); - return _kTextureId; + return _kPlayerId; } @override - Stream videoEventsFor(int textureId) { - assert(textureId == _kTextureId); + Stream videoEventsFor(int playerId) { + assert(playerId == _kPlayerId); return _streamController.stream; } @override - Future setLooping(int textureId, bool looping) async { - assert(textureId == _kTextureId); + Future setLooping(int playerId, bool looping) async { + assert(playerId == _kPlayerId); assert(!looping); } @override - Future play(int textureId) async { - assert(textureId == _kTextureId); + Future play(int playerId) async { + assert(playerId == _kPlayerId); _stopwatch?.start(); _streamController.add(VideoEvent( eventType: VideoEventType.isPlayingStateUpdate, @@ -153,8 +159,8 @@ class FakeVideoPlayerPlatform extends Fake } @override - Future pause(int textureId) async { - assert(textureId == _kTextureId); + Future pause(int playerId) async { + assert(playerId == _kPlayerId); _stopwatch?.stop(); _streamController.add(VideoEvent( eventType: VideoEventType.isPlayingStateUpdate, @@ -163,49 +169,172 @@ class FakeVideoPlayerPlatform extends Fake } @override - Future setVolume(int textureId, double volume) async { - assert(textureId == _kTextureId); + Future setVolume(int playerId, double volume) async { + assert(playerId == _kPlayerId); } @override - Future seekTo(int textureId, Duration pos) async { + Future seekTo(int playerId, Duration pos) async { _callLog.add('seekTo'); - assert(textureId == _kTextureId); + assert(playerId == _kPlayerId); _lastSetPosition = pos >= kTestVideoDuration ? kTestVideoDuration : pos; _stopwatch?.reset(); } @override - Future setPlaybackSpeed(int textureId, double speed) async { - assert(textureId == _kTextureId); + Future setPlaybackSpeed(int playerId, double speed) async { + assert(playerId == _kPlayerId); } @override - Future getPosition(int textureId) async { - assert(textureId == _kTextureId); + Future getPosition(int playerId) async { + assert(playerId == _kPlayerId); return position; } @override - Widget buildView(int textureId) { - assert(textureId == _kTextureId); + Widget buildViewWithOptions(VideoViewOptions options) { + assert(options.playerId == _kPlayerId); return const SizedBox(width: 100, height: 100); } } void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; + + late PerAccountStore store; + + group('LightboxHero', () { + late PerAccountStore store; + late FakeApiConnection connection; + + final channel = eg.stream(); + final message = eg.streamMessage(stream: channel, + topic: 'test topic', contentMarkdown: ContentExample.imageSingle.html); + + // From ContentExample.imageSingle. + final imageSrcUrlStr = 'https://chat.example/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp'; + final imageSrcUrl = Uri.parse(imageSrcUrlStr); + final imageFinder = find.byWidgetPredicate( + (widget) => widget is RealmContentNetworkImage && widget.src == imageSrcUrl); + + Future setupMessageListPage(WidgetTester tester) async { + addTearDown(testBinding.reset); + final subscription = eg.subscription(channel); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + streams: [channel], subscriptions: [subscription])); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + await store.addUser(eg.selfUser); + + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: MessageListPage(initNarrow: const CombinedFeedNarrow()))); + await tester.pumpAndSettle(); + } + + testWidgets('Hero animation occurs smoothly when opening lightbox from message list', (tester) async { + double dist(Rect a, Rect b) => + sqrt(pow(a.top - b.top, 2) + pow(a.left - b.left, 2)); + + prepareBoringImageHttpClient(); + + await setupMessageListPage(tester); + + final initialImagePosition = tester.getRect(imageFinder); + await tester.tap(imageFinder); + await tester.pump(); + // pump to start hero animation + await tester.pump(); + + const heroAnimationDuration = Duration(milliseconds: 300); + const steps = 150; + final stepDuration = heroAnimationDuration ~/ steps; + final animatedPositions = []; + for (int i = 1; i <= steps; i++) { + await tester.pump(stepDuration); + animatedPositions.add(tester.getRect(imageFinder)); + } + + final totalDistance = dist(initialImagePosition, animatedPositions.last); + Rect previousPosition = initialImagePosition; + double maxStepDistance = 0.0; + for (final position in animatedPositions) { + final stepDistance = dist(previousPosition, position); + maxStepDistance = max(maxStepDistance, stepDistance); + check(position).not((pos) => pos.equals(previousPosition)); + + previousPosition = position; + } + check(maxStepDistance).isLessThan(0.03 * totalDistance); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('no hero animation occurs between different message list pages for same image', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/930 + Rect getElementRect(Element element) => + tester.getRect(find.byElementPredicate((e) => e == element)); + + prepareBoringImageHttpClient(); + + await setupMessageListPage(tester); + + final firstElement = tester.element(imageFinder); + final firstImagePosition = getElementRect(firstElement); + + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); + await tester.tap(find.descendant( + of: find.byType(StreamMessageRecipientHeader), + matching: find.text('test topic'))); + await tester.pumpAndSettle(); + + final secondElement = tester.element(imageFinder); + final secondImagePosition = getElementRect(secondElement); + + await tester.tap(find.byType(BackButton)); + await tester.pump(); + + const heroAnimationDuration = Duration(milliseconds: 300); + const steps = 150; + final stepDuration = heroAnimationDuration ~/ steps; + for (int i = 0; i < steps; i++) { + await tester.pump(stepDuration); + check(tester.elementList(imageFinder)) + .unorderedEquals([firstElement, secondElement]); + check(getElementRect(firstElement)).equals(firstImagePosition); + check(getElementRect(secondElement)).equals(secondImagePosition); + } + + debugNetworkImageHttpClientProvider = null; + }, skip: true, // TODO get this no-hero test to work again with new page transitions; + // see https://github.com/flutter/flutter/pull/165832#issuecomment-3111641360 . + // Perhaps specify the old default, of ZoomPageTransitionsBuilder? + // Or make getElementRect work relative to the enclosing page, + // rather than the whole screen, so that the test becomes robust to + // the whole pages moving around. + ); + }); group('_ImageLightboxPage', () { final src = Uri.parse('https://chat.example/lightbox-image.png'); Future setupPage(WidgetTester tester, { Message? message, + List? users, required Uri? thumbnailUrl, }) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + if (users != null) { + await store.addUsers(users); + } // ZulipApp instead of TestZulipApp because we need the navigator to push // the lightbox route. The lightbox page works together with the route; @@ -216,6 +345,7 @@ void main() { unawaited(navigator.push(getImageLightboxRoute( accountId: eg.selfAccount.id, message: message ?? eg.streamMessage(), + messageImageContext: navigator.context, src: src, thumbnailUrl: thumbnailUrl, originalHeight: null, @@ -236,20 +366,51 @@ void main() { debugNetworkImageHttpClientProvider = null; }); - testWidgets('app bar shows sender name and date', (tester) async { + testWidgets('image can zoom up to 10x', (tester) async { prepareBoringImageHttpClient(); - final timestamp = DateTime.parse("2024-07-23 23:12:24").millisecondsSinceEpoch ~/ 1000; - final message = eg.streamMessage(sender: eg.otherUser, timestamp: timestamp); - await setupPage(tester, message: message, thumbnailUrl: null); + await setupPage(tester, thumbnailUrl: null); + + check(tester.widget(find.byType(InteractiveViewer)).maxScale) + .equals(10); - // We're looking for a RichText, in the app bar, with both the - // sender's name and the timestamp. + debugNetworkImageHttpClientProvider = null; + }); + + void checkAppBarNameAndDate(WidgetTester tester, String expectedName, String expectedDate) { final labelTextWidget = tester.widget( find.descendant(of: find.byType(AppBar).last, - matching: find.textContaining(findRichText: true, - eg.otherUser.fullName))); + matching: find.textContaining(findRichText: true, expectedName))); check(labelTextWidget.text.toPlainText()) - .contains('Jul 23, 2024 23:12:24'); + .contains(expectedDate); + } + + testWidgets('app bar shows sender name and date; updates when name changes', (tester) async { + prepareBoringImageHttpClient(); + final timestamp = DateTime.parse("2024-07-23 23:12:24").millisecondsSinceEpoch ~/ 1000; + final sender = eg.user(fullName: 'Old name'); + final message = eg.streamMessage(sender: sender, timestamp: timestamp); + await setupPage(tester, message: message, thumbnailUrl: null, users: [sender]); + check(store.getUser(sender.userId)).isNotNull(); + + checkAppBarNameAndDate(tester, 'Old name', 'Jul 23, 2024 11:12:24 PM'); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: sender.userId, fullName: 'New name')); + await tester.pump(); + checkAppBarNameAndDate(tester, 'New name', 'Jul 23, 2024 11:12:24 PM'); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('app bar shows sender name and date; unknown sender', (tester) async { + prepareBoringImageHttpClient(); + final timestamp = DateTime.parse("2024-07-23 23:12:24").millisecondsSinceEpoch ~/ 1000; + final sender = eg.user(fullName: 'Sender name'); + final message = eg.streamMessage(sender: sender, timestamp: timestamp); + await setupPage(tester, message: message, thumbnailUrl: null, users: []); + check(store.getUser(sender.userId)).isNull(); + + checkAppBarNameAndDate(tester, 'Sender name', 'Jul 23, 2024 11:12:24 PM'); debugNetworkImageHttpClientProvider = null; }); diff --git a/test/widgets/login_test.dart b/test/widgets/login_test.dart index a5109ba5db..9bc4159f72 100644 --- a/test/widgets/login_test.dart +++ b/test/widgets/login_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; +import 'package:zulip/api/core.dart'; import 'package:zulip/api/model/web_auth.dart'; import 'package:zulip/api/route/account.dart'; import 'package:zulip/api/route/realm.dart'; @@ -17,13 +18,14 @@ import 'package:zulip/widgets/login.dart'; import 'package:zulip/widgets/page.dart'; import '../api/fake_api.dart'; +import '../api/route/route_checks.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; import 'dialog_checks.dart'; -import 'page_checks.dart'; +import 'checks.dart'; void main() { TestZulipBinding.ensureInitialized(); @@ -65,7 +67,114 @@ void main() { expectErrorFromText('email@example.com', ServerUrlValidationError.noUseEmail); }); - // TODO test AddAccountPage + group('AddAccountPage', () { + late FakeApiConnection connection; + List> pushedRoutes = []; + List> poppedRoutes = []; + + List> takePushedRoutes() { + final routes = pushedRoutes.toList(); + pushedRoutes.clear(); + return routes; + } + + Future prepare(WidgetTester tester) async { + addTearDown(testBinding.reset); + + pushedRoutes = []; + poppedRoutes = []; + final testNavObserver = TestNavigatorObserver(); + testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route); + testNavObserver.onPopped = (route, prevRoute) => poppedRoutes.add(route); + testNavObserver.onReplaced = (route, prevRoute) { + poppedRoutes.add(prevRoute!); + pushedRoutes.add(route!); + }; + + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + await tester.pump(); + check(takePushedRoutes()).single.isA().page.isA(); + await tester.tap(find.text('Add an account')); + check(takePushedRoutes()).single.isA().page.isA(); + await testNavObserver.pumpPastTransition(tester); + } + + Future attempt(WidgetTester tester, + Uri realmUrl, Map responseJson) async { + await tester.enterText(find.byType(TextField), realmUrl.toString()); + testBinding.globalStore.useCachedApiConnections = true; + connection = testBinding.globalStore.apiConnection( + realmUrl: realmUrl, + zulipFeatureLevel: null); + connection.prepare(json: responseJson); + await tester.tap(find.text('Continue')); + await tester.pump(Duration.zero); + } + + testWidgets('happy path', (tester) async { + await prepare(tester); + + final serverSettings = eg.serverSettings(); + + await attempt(tester, serverSettings.realmUrl, serverSettings.toJson()); + checkNoDialog(tester); + check(takePushedRoutes()).single.isA().page.isA() + .serverSettings.realmUrl.equals(serverSettings.realmUrl); + }); + + testWidgets('Server too old, well-formed response', (tester) async { + await prepare(tester); + + final serverSettings = eg.serverSettings( + zulipFeatureLevel: 1, zulipVersion: '3.0'); + + await attempt(tester, serverSettings.realmUrl, serverSettings.toJson()); + checkErrorDialog(tester, + expectedTitle: 'Could not connect', + expectedMessage: '${serverSettings.realmUrl} is running Zulip Server 3.0, which is unsupported. The minimum supported version is Zulip Server $kMinSupportedZulipVersion.'); + // i.e., not the login route + check(takePushedRoutes()).single.isA>(); + }); + + testWidgets('Server too old, malformed response', (tester) async { + await prepare(tester); + + final serverSettings = eg.serverSettings( + zulipFeatureLevel: 1, zulipVersion: '3.0'); + final serverSettingsMalformedJson = + serverSettings.toJson()..['push_notifications_enabled'] = 'abcd'; + check(() => GetServerSettingsResult.fromJson(serverSettingsMalformedJson)) + .throws(); + + await attempt(tester, serverSettings.realmUrl, serverSettingsMalformedJson); + checkErrorDialog(tester, + expectedTitle: 'Could not connect', + expectedMessage: '${serverSettings.realmUrl} is running Zulip Server 3.0, which is unsupported. The minimum supported version is Zulip Server $kMinSupportedZulipVersion.'); + // i.e., not the login route + check(takePushedRoutes()).single.isA>(); + }); + + testWidgets('Malformed response, server not too old', (tester) async { + await prepare(tester); + + final serverSettings = eg.serverSettings( + zulipVersion: eg.recentZulipVersion, + zulipFeatureLevel: eg.recentZulipFeatureLevel); + final serverSettingsMalformedJson = + serverSettings.toJson()..['push_notifications_enabled'] = 'abcd'; + check(() => GetServerSettingsResult.fromJson(serverSettingsMalformedJson)) + .throws(); + + await attempt(tester, serverSettings.realmUrl, serverSettingsMalformedJson); + checkErrorDialog(tester, + expectedTitle: 'Could not connect', + expectedMessage: 'Failed to connect to server:\n${serverSettings.realmUrl}'); + // i.e., not the login route + check(takePushedRoutes()).single.isA>(); + }); + + // TODO other errors + }); group('LoginPage', () { late FakeApiConnection connection; diff --git a/test/widgets/message_list_checks.dart b/test/widgets/message_list_checks.dart deleted file mode 100644 index 6ce43a2d43..0000000000 --- a/test/widgets/message_list_checks.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:zulip/model/narrow.dart'; -import 'package:zulip/widgets/message_list.dart'; - -extension MessageListPageChecks on Subject { - Subject get initNarrow => has((x) => x.initNarrow, 'initNarrow'); -} diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index b3cc208463..acdde3fd2c 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1,29 +1,43 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; 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/exception.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; +import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/basic.dart'; +import 'package:zulip/model/actions.dart'; import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/message.dart'; +import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; +import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/color.dart'; +import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/channel_colors.dart'; +import 'package:zulip/widgets/theme.dart'; +import 'package:zulip/widgets/topic_list.dart'; +import 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -34,14 +48,13 @@ import '../flutter_checks.dart'; import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; -import 'content_checks.dart'; +import 'checks.dart'; import 'dialog_checks.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; late PerAccountStore store; late FakeApiConnection connection; @@ -51,44 +64,76 @@ void main() { bool foundOldest = true, int? messageCount, List? messages, + GetMessagesResult? fetchResult, List? streams, List? users, + List? mutedUserIds, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, + int? zulipFeatureLevel, List navObservers = const [], + bool skipAssertAccountExists = false, + bool skipPumpAndSettle = false, }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); addTearDown(testBinding.reset); streams ??= subscriptions ??= [eg.subscription(eg.stream(streamId: eg.defaultStreamMessageStreamId))]; - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + zulipFeatureLevel ??= eg.recentZulipFeatureLevel; + final selfAccount = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( + zulipFeatureLevel: zulipFeatureLevel, streams: streams, subscriptions: subscriptions, unreadMsgs: unreadMsgs)); - store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(selfAccount.id); connection = store.connection as FakeApiConnection; // prepare message list data await store.addUser(eg.selfUser); await store.addUsers(users ?? []); - assert((messageCount == null) != (messages == null)); - messages ??= List.generate(messageCount!, (index) { - return eg.streamMessage(sender: eg.selfUser); - }); - connection.prepare(json: - eg.newestGetMessagesResult(foundOldest: foundOldest, messages: messages).toJson()); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } + if (fetchResult != null) { + assert(foundOldest && messageCount == null && messages == null); + } else { + assert((messageCount == null) != (messages == null)); + messages ??= List.generate(messageCount!, (index) { + return eg.streamMessage(sender: eg.selfUser); + }); + fetchResult = eg.newestGetMessagesResult( + foundOldest: foundOldest, messages: messages); + } + connection.prepare(json: fetchResult.toJson()); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, + skipAssertAccountExists: skipAssertAccountExists, navigatorObservers: navObservers, child: MessageListPage(initNarrow: narrow))); + if (skipPumpAndSettle) return; // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); } + void checkAppBarChannelTopic(String channelName, String topic) { + final appBarFinder = find.byType(MessageListAppBarTitle); + check(appBarFinder).findsOne(); + check(find.descendant(of: appBarFinder, matching: find.text(channelName))) + .findsOne(); + check(find.descendant(of: appBarFinder, matching: find.text(topic))) + .findsOne(); + } + + ScrollView findScrollView(WidgetTester tester) => + tester.widget(find.bySubtype()); + ScrollController? findMessageListScrollController(WidgetTester tester) { - final scrollView = tester.widget(find.byType(CustomScrollView)); - return scrollView.controller; + return findScrollView(tester).controller; } + final contentInputFinder = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller is ComposeContentController); + group('MessageListPage', () { testWidgets('ancestorOf finds page state from message', (tester) async { await setupMessageListPage(tester, @@ -115,26 +160,120 @@ void main() { check(state.narrow).equals(ChannelNarrow(stream.streamId)); }); - testWidgets('composeBoxController finds compose box', (tester) async { + testWidgets('narrow gets normalized from "general chat"', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1717 + final stream = eg.stream(); + // Open the page on a topic with the literal name "general chat". + final topic = eg.defaultRealmEmptyTopicDisplayName; + final topicNarrow = eg.topicNarrow(stream.streamId, topic); + await setupMessageListPage(tester, narrow: topicNarrow, + streams: [stream], + messages: [eg.streamMessage(stream: stream, topic: topic, content: "

    a message

    ")]); + final state = MessageListPage.ancestorOf(tester.element(find.text("a message"))); + // The page's narrow has been updated; the topic is "", not "general chat". + check(state.narrow).equals(eg.topicNarrow(stream.streamId, '')); + }); + + testWidgets('composeBoxState finds compose box', (tester) async { final stream = eg.stream(); await setupMessageListPage(tester, narrow: ChannelNarrow(stream.streamId), streams: [stream], messages: [eg.streamMessage(stream: stream, content: "

    a message

    ")]); final state = MessageListPage.ancestorOf(tester.element(find.text("a message"))); - check(state.composeBoxController).isNotNull(); + check(state.composeBoxState).isNotNull(); }); - testWidgets('composeBoxController null when no compose box', (tester) async { + testWidgets('composeBoxState null when no compose box', (tester) async { await setupMessageListPage(tester, narrow: const CombinedFeedNarrow(), messages: [eg.streamMessage(content: "

    a message

    ")]); final state = MessageListPage.ancestorOf(tester.element(find.text("a message"))); - check(state.composeBoxController).isNull(); + check(state.composeBoxState).isNull(); + }); + + testWidgets('dispose MessageListView when event queue expired', (tester) async { + final message = eg.streamMessage(); + await setupMessageListPage(tester, messages: [message]); + final oldViewModel = store.debugMessageListViews.single; + final updateMachine = store.updateMachine!; + updateMachine.debugPauseLoop(); + updateMachine.poll(); + + updateMachine.debugPrepareLoopError( + eg.apiExceptionBadEventQueueId(queueId: store.queueId)); + updateMachine.debugAdvanceLoop(); + await tester.pump(); + // Event queue has been replaced; but the [MessageList] hasn't been + // rebuilt yet. + final newStore = testBinding.globalStore.perAccountSync(eg.selfAccount.id)!; + check(connection.isOpen).isFalse(); // indicates that the old store has been disposed + check(store.debugMessageListViews).single.equals(oldViewModel); + check(newStore.debugMessageListViews).isEmpty(); + + (newStore.connection as FakeApiConnection).prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await tester.pump(); + await tester.pump(Duration.zero); + // As [MessageList] rebuilds, the old view model gets disposed and + // replaced with a fresh one. + check(store.debugMessageListViews).isEmpty(); + check(newStore.debugMessageListViews).single.not((it) => it.equals(oldViewModel)); + }); + + testWidgets('dispose MessageListView when logged out', (tester) async { + await setupMessageListPage(tester, + messages: [eg.streamMessage()], skipAssertAccountExists: true); + check(store.debugMessageListViews).single; + + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); + await tester.pump(TestGlobalStore.removeAccountDuration); + await future; + check(store.debugMessageListViews).isEmpty(); }); }); group('app bar', () { // Tests for the topic action sheet are in test/widgets/action_sheet_test.dart. + testWidgets('handle empty topics', (tester) async { + final channel = eg.stream(); + await setupMessageListPage(tester, + narrow: eg.topicNarrow(channel.streamId, ''), + streams: [channel], + messageCount: 1); + checkAppBarChannelTopic( + channel.name, eg.defaultRealmEmptyTopicDisplayName); + }); + + void testChannelIconInChannelRow(IconData expectedIcon, { + required bool isWebPublic, + required bool inviteOnly, + }) { + final description = 'channel icon in channel row; ' + 'web-public: $isWebPublic, invite-only: $inviteOnly'; + testWidgets(description, (tester) async { + final color = 0xff95a5fd; + + final channel = eg.stream(isWebPublic: isWebPublic, inviteOnly: inviteOnly); + final subscription = eg.subscription(channel, color: color); + + await setupMessageListPage(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + subscriptions: [subscription], + messages: [eg.streamMessage(stream: channel)]); + + final iconElement = tester.element(find.descendant( + of: find.byType(ZulipAppBar), + matching: find.byIcon(expectedIcon))); + + check(Theme.brightnessOf(iconElement)).equals(Brightness.light); + check(iconElement.widget as Icon).color.equals(Color(0xff5972fc)); + }); + } + testChannelIconInChannelRow(ZulipIcons.globe, isWebPublic: true, inviteOnly: false); + testChannelIconInChannelRow(ZulipIcons.lock, isWebPublic: false, inviteOnly: true); + testChannelIconInChannelRow(ZulipIcons.hash_sign, isWebPublic: false, inviteOnly: false); + testWidgets('has channel-feed action for topic narrows', (tester) async { final pushedRoutes = >[]; final navObserver = TestNavigatorObserver() @@ -155,6 +294,25 @@ void main() { .equals(ChannelNarrow(channel.streamId)); }); + testWidgets('has topic-list action for topic narrows', (tester) async { + final channel = eg.stream(name: 'channel foo'); + await setupMessageListPage(tester, + narrow: eg.topicNarrow(channel.streamId, 'topic foo'), + streams: [channel], + messages: [eg.streamMessage(stream: channel, topic: 'topic foo')]); + + connection.prepare(json: GetStreamTopicsResult(topics: [ + eg.getStreamTopicsEntry(name: 'topic foo'), + ]).toJson()); + await tester.tap(find.byIcon(ZulipIcons.topics)); + await tester.pump(); // tap the button + await tester.pump(Duration.zero); // wait for request + check(find.descendant( + of: find.byType(TopicListPage), + matching: find.text('channel foo')), + ).findsOne(); + }); + testWidgets('show topic visibility policy for topic narrows', (tester) async { final channel = eg.stream(); const topic = 'topic'; @@ -170,10 +328,108 @@ void main() { of: find.byType(MessageListAppBarTitle), matching: find.byIcon(ZulipIcons.mute))).findsOne(); }); + + testWidgets('has topic-list action for channel narrows', (tester) async { + final channel = eg.stream(name: 'channel foo'); + await setupMessageListPage(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + messages: [eg.streamMessage(stream: channel, topic: 'topic foo')]); + + connection.prepare(json: GetStreamTopicsResult(topics: [ + eg.getStreamTopicsEntry(name: 'topic foo'), + ]).toJson()); + await tester.tap(find.byIcon(ZulipIcons.topics)); + await tester.pump(); // tap the button + await tester.pump(Duration.zero); // wait for request + check(find.descendant( + of: find.byType(TopicListPage), + matching: find.text('channel foo')), + ).findsOne(); + }); + + testWidgets('shows "Muted user" label for muted users in DM narrow', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + final user3 = eg.user(userId: 3, fullName: 'User 3'); + final mutedUsers = [1, 3]; + + await setupMessageListPage(tester, + narrow: DmNarrow.withOtherUsers([1, 2, 3], selfUserId: 10), + users: [user1, user2, user3], + mutedUserIds: mutedUsers, + messageCount: 1, + ); + + check(find.text('DMs with Muted user, User 2, Muted user')).findsOne(); + }); + }); + + group('no-messages placeholder', () { + final findPlaceholder = find.byType(PageBodyEmptyContentPlaceholder); + + Finder findTextInPlaceholder(String text) => + find.descendant(of: findPlaceholder, matching: find.textContaining(text)); + + testWidgets('Combined feed', (tester) async { + await setupMessageListPage(tester, narrow: CombinedFeedNarrow(), messages: []); + check(findTextInPlaceholder('There are no messages here.')).findsOne(); + }); + + testWidgets('Search, empty keyword', (tester) async { + await setupMessageListPage(tester, narrow: KeywordSearchNarrow(''), messages: []); + check(findTextInPlaceholder('No search results.')).findsOne(); + }); + + testWidgets('Search, non-empty keyword', (tester) async { + await setupMessageListPage(tester, narrow: KeywordSearchNarrow('hello'), messages: []); + check(findTextInPlaceholder('No search results.')).findsOne(); + }); + + testWidgets('when `messages` empty but `outboxMessages` not empty, show outboxes, not placeholder', (tester) async { + final channel = eg.stream(); + await setupMessageListPage(tester, + narrow: TopicNarrow(channel.streamId, eg.t('topic')), + streams: [channel], + messages: []); + check(findPlaceholder).findsOne(); + + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await tester.enterText(contentInputFinder, 'asdfjkl;'); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(kLocalEchoDebounceDuration); + + check(findPlaceholder).findsNothing(); + check(find.text('asdfjkl;')).findsOne(); + }); }); group('presents message content appropriately', () { - testWidgets('content not asked to consume insets (including bottom), even without compose box', (tester) async { + testWidgets('content not asked to consume insets (including bottom), even without compose box, in top sliver', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1523 + const fakePadding = FakeViewPadding(left: 10, top: 10, right: 10, bottom: 10); + tester.view.viewInsets = fakePadding; + tester.view.padding = fakePadding; + + await setupMessageListPage(tester, narrow: const CombinedFeedNarrow(), + messages: [ + eg.streamMessage(content: ContentExample.codeBlockPlain.html), + eg.streamMessage(), + ]); + + // Verify this message list lacks a compose box. + // (The original bug wouldn't reproduce with a compose box present.) + final state = MessageListPage.ancestorOf(tester.element(find.text("verb\natim"))); + check(state.composeBoxState).isNull(); + // Also verify that the first message is in the top sliver. + check(state.model!.middleMessage).equals(1); + + final element = tester.element(find.byType(CodeBlock)); + final padding = MediaQuery.of(element).padding; + check(padding).equals(EdgeInsets.zero); + }); + + testWidgets('content not asked to consume insets (including bottom), even without compose box, in bottom sliver', (tester) async { // Regression test for: https://github.com/zulip/zulip-flutter/issues/736 const fakePadding = FakeViewPadding(left: 10, top: 10, right: 10, bottom: 10); tester.view.viewInsets = fakePadding; @@ -185,7 +441,9 @@ void main() { // Verify this message list lacks a compose box. // (The original bug wouldn't reproduce with a compose box present.) final state = MessageListPage.ancestorOf(tester.element(find.text("verb\natim"))); - check(state.composeBoxController).isNull(); + check(state.composeBoxState).isNull(); + // Also verify that the message is in the bottom sliver. + check(state.model!.middleMessage).equals(0); final element = tester.element(find.byType(CodeBlock)); final padding = MediaQuery.of(element).padding; @@ -209,27 +467,113 @@ void main() { return widget.color; } - check(backgroundColor()).isSameColorAs(MessageListTheme.light.streamMessageBgDefault); + check(backgroundColor()).isSameColorAs(DesignVariables.light.bgMessageRegular); tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; await tester.pump(); await tester.pump(kThemeAnimationDuration * 0.4); - final expectedLerped = MessageListTheme.light.lerp(MessageListTheme.dark, 0.4); - check(backgroundColor()).isSameColorAs(expectedLerped.streamMessageBgDefault); + final expectedLerped = DesignVariables.light.lerp(DesignVariables.dark, 0.4); + check(backgroundColor()).isSameColorAs(expectedLerped.bgMessageRegular); await tester.pump(kThemeAnimationDuration * 0.6); - check(backgroundColor()).isSameColorAs(MessageListTheme.dark.streamMessageBgDefault); + check(backgroundColor()).isSameColorAs(DesignVariables.dark.bgMessageRegular); + }); + + group('fetch initial batch of messages', () { + // TODO(#1571): test effect of visitFirstUnread setting + // TODO(#1569): test effect of initAnchorMessageId + // TODO(#1569): test that after jumpToEnd, then new store causing new fetch, + // new post-jump anchor prevails over initAnchorMessageId + + group('topic permalink', () { + final someStream = eg.stream(); + const someTopic = 'some topic'; + + final otherStream = eg.stream(); + const otherTopic = 'other topic'; + + testWidgets('with message move', (tester) async { + final narrow = TopicNarrow(someStream.streamId, eg.t(someTopic), with_: 1); + await setupMessageListPage(tester, + narrow: narrow, + // server sends the /with/ message in its current, different location + messages: [eg.streamMessage(id: 1, stream: otherStream, topic: otherTopic)], + streams: [someStream, otherStream], + skipPumpAndSettle: true); + await tester.pump(); // global store loaded + await tester.pump(); // per-account store loaded + + // Until we learn the conversation was moved, + // we put the link's stream/topic in the app bar. + checkAppBarChannelTopic(someStream.name, someTopic); + + await tester.pumpAndSettle(); // initial message fetch plus anything else + + // When we learn the conversation was moved, + // we put the new stream/topic in the app bar. + checkAppBarChannelTopic(otherStream.name, otherTopic); + + // We followed the move in just one fetch. + check(connection.takeRequests()).single.isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages') + ..url.queryParameters.deepEquals({ + 'narrow': jsonEncode(resolveApiNarrowForServer(narrow.apiEncode(), connection.zulipFeatureLevel!)), + 'anchor': AnchorCode.firstUnread.toJson(), + 'num_before': kMessageListFetchBatchSize.toString(), + 'num_after': kMessageListFetchBatchSize.toString(), + 'allow_empty_topic_name': 'true', + }); + }); + + testWidgets('without message move', (tester) async { + final narrow = TopicNarrow(someStream.streamId, eg.t(someTopic), with_: 1); + await setupMessageListPage(tester, + narrow: narrow, + // server sends the /with/ message in its current, different location + messages: [eg.streamMessage(id: 1, stream: someStream, topic: someTopic)], + streams: [someStream], + skipPumpAndSettle: true); + await tester.pump(); // global store loaded + await tester.pump(); // per-account store loaded + + // Until we learn if the conversation was moved, + // we put the link's stream/topic in the app bar. + checkAppBarChannelTopic(someStream.name, someTopic); + + await tester.pumpAndSettle(); // initial message fetch plus anything else + + // There was no move, so we're still showing the same stream/topic. + checkAppBarChannelTopic(someStream.name, someTopic); + + // We only made one fetch. + check(connection.takeRequests()).single.isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages') + ..url.queryParameters.deepEquals({ + 'narrow': jsonEncode(resolveApiNarrowForServer(narrow.apiEncode(), connection.zulipFeatureLevel!)), + 'anchor': AnchorCode.firstUnread.toJson(), + 'num_before': kMessageListFetchBatchSize.toString(), + 'num_after': kMessageListFetchBatchSize.toString(), + 'allow_empty_topic_name': 'true', + }); + }); + }); }); group('fetch older messages on scroll', () { + // TODO(#1569): test fetch newer messages on scroll, too; + // in particular test it happens even when near top as well as bottom + // (because may have haveOldest true but haveNewest false) + int? itemCount(WidgetTester tester) => - tester.widget(find.byType(CustomScrollView)).semanticChildCount; + findScrollView(tester).semanticChildCount; testWidgets('basic', (tester) async { await setupMessageListPage(tester, foundOldest: false, messages: List.generate(300, (i) => eg.streamMessage(id: 950 + i, sender: eg.selfUser))); - check(itemCount(tester)).equals(303); + check(itemCount(tester)).equals(301); // Fling-scroll upward... await tester.fling(find.byType(MessageListPage), const Offset(0, 300), 8000); @@ -242,7 +586,7 @@ void main() { await tester.pump(Duration.zero); // Allow a frame for the response to arrive. // Now we have more messages. - check(itemCount(tester)).equals(403); + check(itemCount(tester)).equals(401); }); testWidgets('observe double-fetch glitch', (tester) async { @@ -283,7 +627,7 @@ void main() { ...List.generate(100, (i) => eg.streamMessage(id: 1302 + i)), ]); final lastRequest = connection.lastRequest; - check(itemCount(tester)).equals(404); + check(itemCount(tester)).equals(402); // Fling-scroll upward... await tester.fling(find.byType(MessageListPage), const Offset(0, 300), 8000); @@ -307,6 +651,69 @@ void main() { }); }); + group('scroll position', () { + // The scrolling behavior is tested in more detail in the tests of + // [MessageListScrollView], in scrolling_test.dart . + + testWidgets('sticks to end upon new message', (tester) async { + await setupMessageListPage(tester, messages: List.generate(10, + (i) => eg.streamMessage(content: '

    message $i

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

    a

    b

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

    message $i

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

    a

    b

    ')); + await tester.pump(); + check(tester.getRect(findMiddleMessage)).equals(messageRect); + // … because the scroll position value remained the same… + check(controller.position) + ..pixels.equals(scrollPixels) + // … even though there's now more content off screen below. + // (This last check relies on the fact that the old extentAfter is small, + // less than cacheExtent, so that the new content is only barely offscreen, + // it gets built, and the new extentAfter reflects it.) + ..extentAfter.isGreaterThan(100); + }); + }); + group('ScrollToBottomButton interactions', () { bool isButtonVisible(WidgetTester tester) { return tester.any(find.descendant( @@ -316,60 +723,154 @@ void main() { testWidgets('scrolling changes visibility', (tester) async { await setupMessageListPage(tester, messageCount: 10); - - final scrollController = findMessageListScrollController(tester)!; - - // Initial state should be not visible, as the message list renders with latest message in view + // Scroll position starts at the end, so button hidden. + final controller = findMessageListScrollController(tester)!; + check(controller.position).extentAfter.equals(0); check(isButtonVisible(tester)).equals(false); - scrollController.jumpTo(-600); + // Scrolling up, button becomes visible. + controller.jumpTo(-600); await tester.pump(); + check(controller.position).extentAfter.isGreaterThan(0); check(isButtonVisible(tester)).equals(true); - scrollController.jumpTo(0); + // Scrolling back down to end, button becomes hidden again. + controller.jumpTo(controller.position.maxScrollExtent); await tester.pump(); + check(controller.position).extentAfter.equals(0); check(isButtonVisible(tester)).equals(false); }); testWidgets('dimension updates changes visibility', (tester) async { await setupMessageListPage(tester, messageCount: 100); - final scrollController = findMessageListScrollController(tester)!; - - // Initial state should be not visible, as the message list renders with latest message in view - check(isButtonVisible(tester)).equals(false); - - scrollController.jumpTo(-600); + // Scroll up, to hide the button. + final controller = findMessageListScrollController(tester)!; + controller.jumpTo(-600); await tester.pump(); check(isButtonVisible(tester)).equals(true); + // Make the view taller, so that the bottom of the list is back in view. addTearDown(tester.view.resetPhysicalSize); tester.view.physicalSize = const Size(2000, 40000); await tester.pump(); - // Dimension changes use NotificationListener + // which has a one-frame lag. If that ever gets resolved, + // this extra pump would ideally be removed.) await tester.pump(); + // Check the button duly disappears again. check(isButtonVisible(tester)).equals(false); }); - testWidgets('button functionality', (tester) async { + testWidgets('button works', (tester) async { await setupMessageListPage(tester, messageCount: 10); + final controller = findMessageListScrollController(tester)!; + controller.jumpTo(-600); + await tester.pump(); + check(controller.position).extentAfter.isGreaterOrEqual(600); - final scrollController = findMessageListScrollController(tester)!; - - // Initial state should be not visible, as the message list renders with latest message in view + // Tap button. + await tester.tap(find.byType(ScrollToBottomButton)); + // The list scrolls to the end… + await tester.pumpAndSettle(); + check(controller.position).extentAfter.equals(0); + // … and for good measure confirm the button disappeared. check(isButtonVisible(tester)).equals(false); + }); + + // TODO(#1569): test choice of jumpToEnd vs. scrollToEnd - scrollController.jumpTo(-600); + testWidgets('scrolls at reasonable, constant speed', (tester) async { + const maxSpeed = 8000.0; + const distance = 40000.0; + await setupMessageListPage(tester, messageCount: 1000); + final controller = findMessageListScrollController(tester)!; + + // Scroll a long distance up, many screenfuls. + controller.jumpTo(-distance); await tester.pump(); - check(isButtonVisible(tester)).equals(true); + check(controller.position).pixels.equals(-distance); + // Tap button. await tester.tap(find.byType(ScrollToBottomButton)); + await tester.pump(); + + // Measure speed. + final log = []; + double pos = controller.position.pixels; + while (pos < 0) { + check(log.length).isLessThan(30); + await tester.pump(const Duration(seconds: 1)); + final lastPos = pos; + pos = controller.position.pixels; + log.add(pos - lastPos); + } + // Check the main question: the speed was as expected throughout. + check(log.slice(0, log.length-1)).every((it) => it.equals(maxSpeed)); + check(log).last..isGreaterThan(0)..isLessOrEqual(maxSpeed); + + // Also check the test's assumptions: the scroll reached the end… + check(pos).equals(0); + // … and scrolled far enough to effectively test the max speed. + check(log.sum).isGreaterThan(2 * maxSpeed); + }); + }); + + // TODO test markers at start of list (`_buildStartCap`) + + group('markers at end of list', () { + final findLoadingIndicator = find.byType(CircularProgressIndicator); + + testWidgets('spacer when have newest', (tester) async { + final messages = List.generate(10, + (i) => eg.streamMessage(content: '

    message $i

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

    message $i

    ')))); + await tester.pump(); + + // The message list will immediately start fetching newer messages. + connection.prepare(json: eg.newerGetMessagesResult( + anchor: 109, foundNewest: true, messages: List.generate(100, + (i) => eg.streamMessage(id: 110 + i))).toJson()); + await tester.pump(Duration(milliseconds: 10)); + await tester.pump(); + + // There's a loading indicator. + check(findLoadingIndicator).findsOne(); + // It's at the bottom. + check(findMessageListScrollController(tester)!.position) + .extentAfter.equals(0); + final loadingIndicatorRect = tester.getRect(findLoadingIndicator); + check(loadingIndicatorRect).bottom.isGreaterThan(575); + // The last message is shortly above it; no spacer or anything else. + check(tester.getRect(find.text('message 9'))) + .bottom.isGreaterThan(loadingIndicatorRect.top - 36); // TODO(#1569) where's this space going? await tester.pumpAndSettle(); - check(isButtonVisible(tester)).equals(false); - check(scrollController.position.pixels).equals(0); }); + + // TODO(#1569) test no typing status or mark-read button when not haveNewest + // (even without loading indicator) }); group('TypingStatusWidget', () { @@ -392,7 +893,7 @@ void main() { final streamMessage = eg.streamMessage(); final topicNarrow = TopicNarrow.ofMessage(streamMessage); - for (final (description, message, narrow) in [ + for (final (description, message, narrow) in <(String, Message, SendableNarrow)>[ ('typing in dm', dmMessage, dmNarrow), ('typing in topic', streamMessage, topicNarrow), ]) { @@ -434,6 +935,30 @@ void main() { // Wait for the pending timers to end. await tester.pump(const Duration(seconds: 15)); }); + + testWidgets('muted user typing', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, users: users, messages: [streamMessage]); + + await checkTyping(tester, + eg.typingEvent(topicNarrow, TypingOp.start, eg.otherUser.userId), + expected: 'Other User is typing…'); + + await checkTyping(tester, + eg.typingEvent(topicNarrow, TypingOp.start, eg.thirdUser.userId), + expected: 'Other User and Third User are typing…'); + + await store.setMutedUsers([eg.otherUser.userId]); + await tester.pump(); + + await checkTyping(tester, + eg.typingEvent(topicNarrow, TypingOp.start, eg.thirdUser.userId), + expected: 'Third User is typing…', // no "Other User" + ); + + // Wait for the pending timers to end. + await tester.pump(const Duration(seconds: 15)); + }); }); group('MarkAsReadWidget', () { @@ -550,15 +1075,10 @@ void main() { await setupMessageListPage(tester, narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); check(isMarkAsReadButtonVisible(tester)).isTrue(); - - connection.prepare(httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }); - checkAppearsLoading(tester, false); + connection.prepare( + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); await tester.tap(find.byType(MarkAsReadWidget)); await tester.pump(); checkAppearsLoading(tester, true); @@ -588,7 +1108,7 @@ void main() { 'include_anchor': 'false', 'num_before': '0', 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), + 'narrow': jsonEncode(resolveApiNarrowForServer(apiNarrow, connection.zulipFeatureLevel!)), 'op': 'add', 'flag': 'read', }); @@ -649,7 +1169,7 @@ void main() { narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); check(isMarkAsReadButtonVisible(tester)).isTrue(); - connection.prepare(exception: http.ClientException('Oops')); + connection.prepare(httpException: http.ClientException('Oops')); await tester.tap(find.byType(MarkAsReadWidget)); await tester.pumpAndSettle(); checkErrorDialog(tester, @@ -670,8 +1190,8 @@ void main() { foundOldest: false, messages: messages).toJson()); } - void handleMessageMoveEvent(List messages, String newTopic, {int? newChannelId}) { - store.handleEvent(eg.updateMessageEventMoveFrom( + Future handleMessageMoveEvent(List messages, String newTopic, {int? newChannelId}) async { + await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: messages, newTopicStr: newTopic, newStreamId: newChannelId, @@ -692,7 +1212,7 @@ void main() { ..controller.isNotNull().text.equals('Some text'); prepareGetMessageResponse([message]); - handleMessageMoveEvent([message], 'new topic', newChannelId: otherChannel.streamId); + await handleMessageMoveEvent([message], 'new topic', newChannelId: otherChannel.streamId); await tester.pump(const Duration(seconds: 1)); check(tester.widget(channelContentInputFinder)) ..decoration.isNotNull().hintText.equals('Message #${otherChannel.name} > new topic') @@ -700,7 +1220,8 @@ void main() { connection.prepare(json: SendMessageResult(id: 1).toJson()); await tester.tap(find.byIcon(ZulipIcons.send)); - await tester.pump(); + await tester.pump(Duration.zero); + final localMessageId = store.outboxMessages.keys.single; check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/messages') @@ -709,8 +1230,12 @@ void main() { 'to': '${otherChannel.streamId}', 'topic': 'new topic', 'content': 'Some text', - 'read_by_sender': 'true'}); - await tester.pumpAndSettle(); + 'read_by_sender': 'true', + 'queue_id': store.queueId, + 'local_id': localMessageId.toString()}); + // Remove the outbox message and its timers created when sending message. + await store.handleEvent( + eg.messageEvent(message, localMessageId: localMessageId)); }); testWidgets('Move to narrow with existing messages', (tester) async { @@ -722,7 +1247,7 @@ void main() { final existingMessage = eg.streamMessage( stream: eg.stream(), topic: 'new topic', content: 'Existing message'); prepareGetMessageResponse([existingMessage, message]); - handleMessageMoveEvent([message], 'new topic'); + await handleMessageMoveEvent([message], 'new topic'); await tester.pump(const Duration(seconds: 1)); check(find.textContaining('Existing message').evaluate()).length.equals(1); @@ -734,17 +1259,14 @@ void main() { await setupMessageListPage(tester, narrow: narrow, messages: [message], streams: [channel]); prepareGetMessageResponse([message]); - handleMessageMoveEvent([message], 'new topic'); + await handleMessageMoveEvent([message], 'new topic'); await tester.pump(const Duration(seconds: 1)); check(find.descendant( of: find.byType(RecipientHeader), matching: find.text('new topic')).evaluate() ).length.equals(1); - check(find.descendant( - of: find.byType(MessageListAppBarTitle), - matching: find.text('new topic')).evaluate() - ).length.equals(1); + checkAppBarChannelTopic(channel.name, 'new topic'); }); }); @@ -808,6 +1330,26 @@ void main() { check(findInMessageList('topic name')).length.equals(1); }); + final messageEmptyTopic = eg.streamMessage(stream: stream, topic: ''); + + testWidgets('show general chat for empty topics with channel name', (tester) async { + await setupMessageListPage(tester, + narrow: const CombinedFeedNarrow(), + messages: [messageEmptyTopic], subscriptions: [eg.subscription(stream)]); + await tester.pump(); + check(findInMessageList('stream name')).single; + check(findInMessageList(eg.defaultRealmEmptyTopicDisplayName)).single; + }); + + testWidgets('show general chat for empty topics without channel name', (tester) async { + await setupMessageListPage(tester, + narrow: TopicNarrow.ofMessage(messageEmptyTopic), + messages: [messageEmptyTopic]); + await tester.pump(); + check(findInMessageList('stream name')).isEmpty(); + check(findInMessageList(eg.defaultRealmEmptyTopicDisplayName)).single; + }); + testWidgets('show topic visibility icon when followed', (tester) async { await setupMessageListPage(tester, narrow: const CombinedFeedNarrow(), @@ -928,6 +1470,33 @@ void main() { tester.widget(find.text('new stream name')); }); + testWidgets('navigates to ChannelNarrow on tapping channel in CombinedFeedNarrow', (tester) async { + final pushedRoutes = >[]; + final navObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final channel = eg.stream(); + final subscription = eg.subscription(channel); + final message = eg.streamMessage(stream: channel, topic: 'topic name'); + await setupMessageListPage(tester, + narrow: CombinedFeedNarrow(), + subscriptions: [subscription], + messages: [message], + navObservers: [navObserver]); + + assert(pushedRoutes.length == 1); + pushedRoutes.clear(); + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await tester.tap(find.descendant( + of: find.byType(StreamMessageRecipientHeader), + matching: find.text(channel.name))); + await tester.pump(); + check(pushedRoutes).single.isA().page.isA() + .initNarrow.equals(ChannelNarrow(channel.streamId)); + await tester.pumpAndSettle(); + }); + testWidgets('navigates to TopicNarrow on tapping topic in ChannelNarrow', (tester) async { final pushedRoutes = >[]; final navObserver = TestNavigatorObserver() @@ -1009,6 +1578,21 @@ void main() { "${zulipLocalizations.unknownUserName}, ${eg.thirdUser.fullName}"))); }); + testWidgets('show "Muted user" label for muted users', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + final user3 = eg.user(userId: 3, fullName: 'User 3'); + final mutedUsers = [1, 3]; + + await setupMessageListPage(tester, + users: [user1, user2, user3], + mutedUserIds: mutedUsers, + messages: [eg.dmMessage(from: eg.selfUser, to: [user1, user2, user3])] + ); + + check(find.text('You and Muted user, Muted user, User 2')).findsOne(); + }); + testWidgets('icon color matches text color', (tester) async { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await setupMessageListPage(tester, messages: [ @@ -1018,7 +1602,7 @@ void main() { final textSpan = tester.renderObject(find.text( zulipLocalizations.messageListGroupYouAndOthers( zulipLocalizations.unknownUserName))).text; - final icon = tester.widget(find.byIcon(ZulipIcons.user)); + final icon = tester.widget(find.byIcon(ZulipIcons.two_person)); check(textSpan).style.isNotNull().color.isNotNull().isSameColorAs(icon.color!); }); }); @@ -1062,7 +1646,7 @@ void main() { .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() @@ -1082,30 +1666,108 @@ void main() { }); }); - group('formatHeaderDate', () { - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - final now = DateTime.parse("2023-01-10 12:00"); - final testCases = [ - ("2023-01-10 12:00", zulipLocalizations.today), - ("2023-01-10 00:00", zulipLocalizations.today), - ("2023-01-10 23:59", zulipLocalizations.today), - ("2023-01-09 23:59", zulipLocalizations.yesterday), - ("2023-01-09 00:00", zulipLocalizations.yesterday), - ("2023-01-08 00:00", "Jan 8"), - ("2022-12-31 00:00", "Dec 31, 2022"), - // Future times - ("2023-01-10 19:00", zulipLocalizations.today), - ("2023-01-11 00:00", "Jan 11, 2023"), - ]; - for (final (dateTime, expected) in testCases) { - test('$dateTime returns $expected', () { - check(formatHeaderDate(zulipLocalizations, DateTime.parse(dateTime), now: now)) - .equals(expected); - }); + group('MessageTimestampStyle', () { + void doTests( + MessageTimestampStyle style, + List<( + String timestampStr, + String? expectedTwelveHour, + String? expectedTwentyFourHour, + )> cases, { + DateTime? now, + }) { + now ??= DateTime.parse("2023-01-10 12:00"); + for (final (timestampStr, expectedTwelveHour, expectedTwentyFourHour) in cases) { + for (final mode in TwentyFourHourTimeMode.values) { + final expected = switch (mode) { + TwentyFourHourTimeMode.twelveHour => expectedTwelveHour, + TwentyFourHourTimeMode.twentyFourHour => expectedTwentyFourHour, + // This expectation will hold as long as we're always using the + // default locale, en_US, which uses the twelve-hour format. + // TODO(#1727) test with other locales + TwentyFourHourTimeMode.localeDefault => expectedTwelveHour, + }; + + test('${style.name} in ${mode.name}: $timestampStr returns $expected', () { + addTearDown(testBinding.reset); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + withClock(Clock.fixed(now!), () { + final timestamp = DateTime.parse(timestampStr) + .millisecondsSinceEpoch ~/ 1000; + final result = style.format( + timestamp, + now: testBinding.utcNow().toLocal(), + twentyFourHourTimeMode: mode, + zulipLocalizations: zulipLocalizations); + check(result).equals(expected); + }); + }); + } + } + } + + for (final style in MessageTimestampStyle.values) { + switch (style) { + case MessageTimestampStyle.none: + doTests(style, [('2023-01-10 12:00', null, null)]); + case MessageTimestampStyle.dateOnlyRelative: + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + doTests(style, + now: DateTime.parse("2023-01-10 12:00"), + [ + ("2023-01-10 12:00", zulipLocalizations.today, zulipLocalizations.today), + ("2023-01-10 00:00", zulipLocalizations.today, zulipLocalizations.today), + ("2023-01-10 23:59", zulipLocalizations.today, zulipLocalizations.today), + ("2023-01-09 23:59", zulipLocalizations.yesterday, zulipLocalizations.yesterday), + ("2023-01-09 00:00", zulipLocalizations.yesterday, zulipLocalizations.yesterday), + ("2023-01-08 00:00", "Jan 8", "Jan 8"), + ("2022-12-31 00:00", "Dec 31, 2022", "Dec 31, 2022"), + // Future times + ("2023-01-10 19:00", zulipLocalizations.today, zulipLocalizations.today), + ("2023-01-11 00:00", "Jan 11, 2023", "Jan 11, 2023"), + ]); + case MessageTimestampStyle.timeOnly: + doTests(style, [('2023-01-10 12:00', '12:00 PM', '12:00')]); + case MessageTimestampStyle.lightbox: + doTests(style, + [('2023-01-10 12:00', + 'Jan 10, 2023 12:00:00 PM', + 'Jan 10, 2023 12:00:00')]); + case MessageTimestampStyle.full: + doTests(style, + [('2023-01-10 12:00', + 'Jan 10, 2023 12:00 PM', + 'Jan 10, 2023 12:00')]); + } } }); group('MessageWithPossibleSender', () { + testWidgets('known user', (tester) async { + final user = eg.user(fullName: 'Old Name'); + await setupMessageListPage(tester, + messages: [eg.streamMessage(sender: user)], + users: [user]); + + check(find.widgetWithText(MessageWithPossibleSender, 'Old Name')).findsOne(); + + // If the user's name changes, the sender row should update. + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: user.userId, fullName: 'New Name')); + await tester.pump(); + check(find.widgetWithText(MessageWithPossibleSender, 'New Name')).findsOne(); + }); + + testWidgets('unknown user', (tester) async { + final user = eg.user(fullName: 'Some User'); + await setupMessageListPage(tester, messages: [eg.streamMessage(sender: user)]); + check(store.getUser(user.userId)).isNull(); + + // The sender row should fall back to the name in the message. + check(find.widgetWithText(MessageWithPossibleSender, 'Some User')).findsOne(); + }); + testWidgets('Updates avatar on RealmUserUpdateEvent', (tester) async { addTearDown(testBinding.reset); @@ -1127,15 +1789,18 @@ void main() { } } + final user = eg.user(); + Future handleNewAvatarEventAndPump(WidgetTester tester, String avatarUrl) async { - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, avatarUrl: avatarUrl)); + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: user.userId, avatarUrl: avatarUrl)); await tester.pump(); } prepareBoringImageHttpClient(); - await setupMessageListPage(tester, messageCount: 10); - checkResultForSender(eg.selfUser.avatarUrl); + await setupMessageListPage(tester, users: [user], + messages: [eg.streamMessage(sender: user)]); + checkResultForSender(user.avatarUrl); await handleNewAvatarEventAndPump(tester, '/foo.png'); checkResultForSender('/foo.png'); @@ -1188,6 +1853,373 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + + group('User status', () { + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(SenderRow))).findsOne(); + } + + testWidgets('emoji (unicode) & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji (image) & text are set -> emoji is displayed, text is not', (tester) async { + prepareBoringImageHttpClient(); + + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Coding'), + emoji: OptionSome(StatusEmoji(emojiName: 'zulip', + emojiCode: 'zulip', reactionType: ReactionType.zulipExtraEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.byType(Image)); + check(find.textContaining('Coding')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('longer user name -> emoji stays visible', (tester) async { + final user = eg.user(fullName: 'User with a very very very long name to check if emoji is still visible'); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionNone(), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + + group('Muted sender', () { + void checkMessage(Message message, {required bool expectIsMuted}) { + final mutedLabel = 'Muted user'; + final mutedLabelFinder = find.widgetWithText(MessageWithPossibleSender, + mutedLabel); + + final avatarFinder = find.byWidgetPredicate( + (widget) => widget is Avatar && widget.userId == message.senderId); + final mutedAvatarFinder = find.descendant( + of: avatarFinder, + matching: find.byIcon(ZulipIcons.person)); + final nonmutedAvatarFinder = find.descendant( + of: avatarFinder, + matching: find.byType(RealmContentNetworkImage)); + + final senderName = store.senderDisplayName(message, replaceIfMuted: false); + assert(senderName != mutedLabel); + final senderNameFinder = find.widgetWithText(MessageWithPossibleSender, + senderName); + + final contentFinder = find.descendant( + of: find.byType(MessageContent), + matching: find.text('A message', findRichText: true)); + + check(mutedLabelFinder.evaluate().length).equals(expectIsMuted ? 1 : 0); + check(senderNameFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); + check(mutedAvatarFinder.evaluate().length).equals(expectIsMuted ? 1 : 0); + check(nonmutedAvatarFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); + check(contentFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); + } + + final user = eg.user(userId: 1, fullName: 'User', avatarUrl: '/foo.png'); + final message = eg.streamMessage(sender: user, + content: '

    A message

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

    link

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

    plain content

    ')); + await tester.pump(); + await tapText(tester, find.text('plain content')); + if (expected) { + final expectedNarrow = SendableNarrow.ofMessage(message, selfUserId: store.selfUserId); + + check(lastPushedRoute).isNotNull().isA() + .page.isA() + ..initNarrow.equals(expectedNarrow) + ..initAnchorMessageId.equals(message.id); + } else { + check(lastPushedRoute).isNull(); + } + + // TODO test tapping whitespace in message + }); + } + + doTest(expected: false, CombinedFeedNarrow(), + mkMessage: () => eg.streamMessage()); + doTest(expected: false, ChannelNarrow(subscription.streamId), + mkMessage: () => eg.streamMessage(stream: subscription)); + doTest(expected: false, TopicNarrow(subscription.streamId, eg.t(topic)), + mkMessage: () => eg.streamMessage(stream: subscription)); + doTest(expected: false, DmNarrow.withUsers([], selfUserId: eg.selfUser.userId), + mkMessage: () => eg.streamMessage(stream: subscription, topic: topic)); + doTest(expected: true, StarredMessagesNarrow(), + mkMessage: () => eg.streamMessage(flags: [MessageFlag.starred])); + doTest(expected: true, MentionsNarrow(), + mkMessage: () => eg.streamMessage(flags: [MessageFlag.mentioned])); + }); + }); + + group('OutboxMessageWithPossibleSender', () { + final stream = eg.stream(); + final topic = 'topic'; + final topicNarrow = eg.topicNarrow(stream.streamId, topic); + const content = 'outbox message content'; + + Finder outboxMessageFinder = find.widgetWithText( + OutboxMessageWithPossibleSender, content, skipOffstage: true); + + Finder messageNotSentFinder = find.descendant( + of: find.byType(OutboxMessageWithPossibleSender), + matching: find.text('MESSAGE NOT SENT')).hitTestable(); + Finder loadingIndicatorFinder = find.descendant( + of: find.byType(OutboxMessageWithPossibleSender), + matching: find.byType(LinearProgressIndicator)).hitTestable(); + + Future sendMessageAndSucceed(WidgetTester tester, { + Duration delay = Duration.zero, + }) async { + connection.prepare(json: SendMessageResult(id: 1).toJson(), delay: delay); + await tester.enterText(contentInputFinder, content); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + } + + Future sendMessageAndFail(WidgetTester tester, { + Duration delay = Duration.zero, + }) async { + connection.prepare(httpException: SocketException('error'), delay: delay); + await tester.enterText(contentInputFinder, content); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + } + + Future dismissErrorDialog(WidgetTester tester) async { + await tester.tap(find.byWidget( + checkErrorDialog(tester, expectedTitle: 'Message not sent'))); + await tester.pump(Duration(milliseconds: 250)); + } + + Future checkTapRestoreMessage(WidgetTester tester) async { + final state = tester.state(find.byType(ComposeBox)); + check(store.outboxMessages).values.single; + check(outboxMessageFinder).findsOne(); + check(messageNotSentFinder).findsOne(); + check(state).controller.content.text.isNotNull().isEmpty(); + + // Tap the message. This should put its content back into the compose box + // and remove it. + await tester.tap(outboxMessageFinder); + await tester.pump(); + check(store.outboxMessages).isEmpty(); + check(outboxMessageFinder).findsNothing(); + check(state).controller.content.text.equals(content); + } + + Future checkTapNotRestoreMessage(WidgetTester tester) async { + check(store.outboxMessages).values.single; + check(outboxMessageFinder).findsOne(); + + // the message should ignore the pointer event + await tester.tap(outboxMessageFinder, warnIfMissed: false); + await tester.pump(); + check(store.outboxMessages).values.single; + check(outboxMessageFinder).findsOne(); + } + + // State transitions are tested more thoroughly in + // test/model/message_test.dart . + + testWidgets('hidden -> waiting', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, streams: [stream], + messages: []); + + await sendMessageAndSucceed(tester); + check(outboxMessageFinder).findsNothing(); + + await tester.pump(kLocalEchoDebounceDuration); + check(outboxMessageFinder).findsOne(); + check(loadingIndicatorFinder).findsOne(); + // The outbox message is still in waiting state; + // tapping does not restore it. + await checkTapNotRestoreMessage(tester); + }); + + testWidgets('hidden -> failed, tap to restore message', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, streams: [stream], + messages: []); + // Send a message and fail. Dismiss the error dialog as it pops up. + await sendMessageAndFail(tester); + await dismissErrorDialog(tester); + check(messageNotSentFinder).findsOne(); + + await checkTapRestoreMessage(tester); + }); + + testWidgets('hidden -> failed, tapping does nothing if compose box is not offered', (tester) async { + final transitionDurationObserver = TransitionDurationObserver(); + + final messages = [eg.streamMessage( + stream: stream, topic: topic, content: content)]; + await setupMessageListPage(tester, + narrow: const CombinedFeedNarrow(), + streams: [stream], subscriptions: [eg.subscription(stream)], + navObservers: [transitionDurationObserver], + messages: messages); + + // Navigate to a message list page in a topic narrow, + // which has a compose box. + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: messages).toJson()); + await tester.tap(find.widgetWithText(RecipientHeader, topic)); + await tester.pump(); // handle tap + await transitionDurationObserver.pumpPastTransition(tester); + check(contentInputFinder).findsOne(); + + await sendMessageAndFail(tester); + await dismissErrorDialog(tester); + // Navigate back to the message list page without a compose box, + // where the failed to send message should be visible. + + await tester.pageBack(); + await tester.pump(); // handle tap + await transitionDurationObserver.pumpPastTransition(tester); + check(contentInputFinder).findsNothing(); + check(messageNotSentFinder).findsOne(); + + // Tap the failed to send message. + // This should not remove it from the message list. + await checkTapNotRestoreMessage(tester); + }); + + testWidgets('waiting -> waitPeriodExpired, tap to restore message', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, streams: [stream], + messages: []); + await sendMessageAndFail(tester, + delay: kSendMessageOfferRestoreWaitPeriod + const Duration(seconds: 1)); + await tester.pump(kSendMessageOfferRestoreWaitPeriod); + final localMessageId = store.outboxMessages.keys.single; + check(messageNotSentFinder).findsOne(); + + await checkTapRestoreMessage(tester); + + // While `localMessageId` is no longer in store, there should be no error + // when a message event refers to it. + await store.handleEvent(eg.messageEvent( + eg.streamMessage(stream: stream, topic: 'topic'), + localMessageId: localMessageId)); + + // The [sendMessage] request fails; there is no outbox message affected. + await tester.pump(Duration(seconds: 1)); + check(messageNotSentFinder).findsNothing(); + }); }); group('Starred messages', () { @@ -1204,7 +2236,7 @@ void main() { }); }); - group('edit state label', () { + group('EDITED/MOVED label and edit-message error status', () { void checkMarkersCount({required int edited, required int moved}) { check(find.text('EDITED').evaluate()).length.equals(edited); check(find.text('MOVED').evaluate()).length.equals(moved); @@ -1235,6 +2267,83 @@ void main() { await tester.pump(); checkMarkersCount(edited: 2, moved: 0); }); + + void checkEditInProgress(WidgetTester tester) { + check(find.text('SAVING EDIT…')).findsOne(); + check(find.byType(LinearProgressIndicator)).findsOne(); + final opacityWidget = tester.widget(find.ancestor( + of: find.byType(MessageContent), + matching: find.byType(Opacity))); + check(opacityWidget.opacity).equals(0.6); + checkMarkersCount(edited: 0, moved: 0); + } + + void checkEditNotInProgress(WidgetTester tester) { + check(find.text('SAVING EDIT…')).findsNothing(); + check(find.byType(LinearProgressIndicator)).findsNothing(); + check(find.ancestor( + of: find.byType(MessageContent), + matching: find.byType(Opacity))).findsNothing(); + } + + void checkEditFailed(WidgetTester tester) { + check(find.text('EDIT NOT SAVED')).findsOne(); + final opacityWidget = tester.widget(find.ancestor( + of: find.byType(MessageContent), + matching: find.byType(Opacity))); + check(opacityWidget.opacity).equals(0.6); + checkMarkersCount(edited: 0, moved: 0); + } + + testWidgets('successful edit', (tester) async { + final message = eg.streamMessage(); + await setupMessageListPage(tester, + narrow: TopicNarrow.ofMessage(message), + messages: [message]); + + connection.prepare(json: UpdateMessageResult().toJson()); + unawaited(store.editMessage(messageId: message.id, + originalRawContent: 'foo', + newContent: 'bar')); + await tester.pump(Duration.zero); + checkEditInProgress(tester); + await store.handleEvent(eg.updateMessageEditEvent(message)); + await tester.pump(); + checkEditNotInProgress(tester); + }); + + testWidgets('failed edit', (tester) async { + final message = eg.streamMessage(); + await setupMessageListPage(tester, + narrow: TopicNarrow.ofMessage(message), + messages: [message]); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + unawaited(check(store.editMessage(messageId: message.id, + originalRawContent: 'foo', + newContent: 'bar')).throws()); + await tester.pump(Duration.zero); + checkEditInProgress(tester); + await tester.pump(Duration(seconds: 1)); + // (the error dialog is tested elsewhere; + // it's triggered in the "Save" tap handler, not store.editMessage) + checkEditFailed(tester); + + connection.prepare(json: GetMessageResult( + message: eg.streamMessage(content: 'foo')).toJson(), delay: Duration(milliseconds: 500)); + await tester.tap(find.byType(MessageContent)); + // We don't clear out the failed attempt, with the intended new content… + checkEditFailed(tester); + await tester.pump(Duration(milliseconds: 500)); + // …until we have the current content, from a successful message fetch, + // for prevContentSha256. + checkEditNotInProgress(tester); + + final state = MessageListPage.ancestorOf(tester.element(find.byType(MessageContent))); + check(state.composeBoxState).isNotNull().controller + .isA() + .content.value.text.equals('bar'); + }); }); group('_UnreadMarker animations', () { @@ -1323,9 +2432,9 @@ void main() { // introduce new message final newMessage = eg.streamMessage(flags:[MessageFlag.read]); - await store.handleEvent(MessageEvent(id: 0, message: newMessage)); + await store.addMessage(newMessage); await tester.pump(); // process handleEvent - check(find.byType(MessageItem).evaluate()).length.equals(2); + check(find.byType(MessageItem)).findsExactly(2); check(getAnimation(tester, message.id)) ..value.isGreaterThan(0.0) ..value.isLessThan(1.0) diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart new file mode 100644 index 0000000000..aff2e8ba6e --- /dev/null +++ b/test/widgets/new_dm_sheet_test.dart @@ -0,0 +1,454 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/home.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/new_dm_sheet.dart'; +import 'package:zulip/widgets/store.dart'; +import 'package:zulip/widgets/user.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../flutter_checks.dart'; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../test_navigation.dart'; +import 'finders.dart'; +import 'test_app.dart'; + +late PerAccountStore store; + +Future setupSheet(WidgetTester tester, { + User? selfUser, + required List users, + List? mutedUserIds, +}) async { + addTearDown(testBinding.reset); + + Route? lastPushedRoute; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, _) => lastPushedRoute = route; + + selfUser ??= eg.selfUser; + final account = eg.account(user: selfUser); + await testBinding.globalStore.add(account, eg.initialSnapshot( + realmUsers: [selfUser, ...users])); + store = await testBinding.globalStore.perAccount(account.id); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } + + await tester.pumpWidget(TestZulipApp( + navigatorObservers: [testNavObserver], + accountId: account.id, + child: const HomePage())); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(ZulipIcons.two_person)); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(GestureDetector, 'New DM')); + await tester.pump(); + check(lastPushedRoute).isNotNull().isA>(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); +} + +void main() { + TestZulipBinding.ensureInitialized(); + + final findComposeButton = find.widgetWithText(GestureDetector, 'Compose'); + void checkComposeButtonEnabled(WidgetTester tester, bool expected) { + final button = tester.widget(findComposeButton); + if (expected) { + check(button.onTap).isNotNull(); + } else { + check(button.onTap).isNull(); + } + } + + Finder findUserTile(User user) => + find.ancestor(of: findText(user.fullName, includePlaceholders: false), + matching: find.byType(InkWell)).first; + + Finder findUserChip(User user) { + final findAvatar = find.byWidgetPredicate((widget) => + widget is Avatar + && widget.userId == user.userId + && widget.size == 22); + + return find.ancestor(of: findAvatar, matching: find.byType(GestureDetector)); + } + + testWidgets('shows header with correct buttons', (tester) async { + await setupSheet(tester, users: []); + + check(find.descendant( + of: find.byType(NewDmPicker), + matching: find.text('New DM'))).findsOne(); + check(find.text('Cancel')).findsOne(); + check(findComposeButton).findsOne(); + + checkComposeButtonEnabled(tester, false); + }); + + testWidgets('search field has focus when sheet opens', (tester) async { + await setupSheet(tester, users: []); + + void checkHasFocus() { + // Some element is focused… + final focusedElement = tester.binding.focusManager.primaryFocus?.context; + check(focusedElement).isNotNull(); + + // …it's a TextField. Specifically, the search input. + final focusedTextFieldWidget = focusedElement! + .findAncestorWidgetOfExactType(); + check(focusedTextFieldWidget).isNotNull() + .decoration.isNotNull() + .hintText.equals('Add one or more users'); + } + + checkHasFocus(); // It's focused initially. + await tester.pump(Duration(seconds: 1)); + checkHasFocus(); // Something else doesn't come along and steal the focus. + }); + + group('user filtering', () { + final testUsers = [ + eg.user(fullName: 'Alice Anderson'), + eg.user(fullName: 'Bob Brown'), + eg.user(fullName: 'Charlie Carter'), + eg.user(fullName: 'Édith Piaf'), + ]; + + testWidgets('shows full list initially', (tester) async { + await setupSheet(tester, selfUser: testUsers[0], users: testUsers); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(testUsers.length); + check(find.byIcon(ZulipIcons.check_circle_checked)).findsNothing(); + }); + + testWidgets('shows filtered users based on search', (tester) async { + await setupSheet(tester, users: testUsers); + await tester.enterText(find.byType(TextField), 'Alice'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + }); + + testWidgets('deactivated users excluded', (tester) async { + // Omit a deactivated user both before there's a query… + final deactivatedUser = eg.user(fullName: 'Impostor Charlie', isActive: false); + await setupSheet(tester, selfUser: testUsers[0], + users: [...testUsers, deactivatedUser]); + check(findText(includePlaceholders: false, 'Impostor Charlie')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(testUsers.length); + + // … and after a query that would match their name. + await tester.enterText(find.byType(TextField), 'Charlie'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Impostor Charlie')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(1); + }); + + testWidgets('muted users excluded', (tester) async { + // Omit muted users both before there's a query… + final mutedUser = eg.user(fullName: 'Someone Muted'); + await setupSheet(tester, selfUser: testUsers[0], + users: [...testUsers, mutedUser], mutedUserIds: [mutedUser.userId]); + check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing(); + check(findText(includePlaceholders: false, 'Muted user')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(testUsers.length); + + // … and after a query. One which matches both the user's actual name and + // the replacement text "Muted user", for good measure. + await tester.enterText(find.byType(TextField), 'e'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing(); + check(findText(includePlaceholders: false, 'Muted user')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); + check(findText(includePlaceholders: false, 'Édith Piaf')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); + }); + + // TODO test sorting by recent-DMs + // TODO test that scroll position resets on query change + + testWidgets('search is case- and diacritics-insensitive', (tester) async { + await setupSheet(tester, users: testUsers); + await tester.enterText(find.byType(TextField), 'alice'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + + await tester.enterText(find.byType(TextField), 'ALICE'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + + await tester.enterText(find.byType(TextField), 'alicé'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + + await tester.enterText(find.byType(TextField), 'edith'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Édith Piaf')).findsOne(); + }); + + testWidgets('partial name and last name search handling', (tester) async { + await setupSheet(tester, users: testUsers); + + await tester.enterText(find.byType(TextField), 'Ali'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + + await tester.enterText(find.byType(TextField), 'Anderson'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + + await tester.enterText(find.byType(TextField), 'son'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + }); + + testWidgets('shows empty state when no users match', (tester) async { + await setupSheet(tester, users: testUsers); + await tester.enterText(find.byType(TextField), 'Zebra'); + await tester.pump(); + check(findText(includePlaceholders: false, 'No users found')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + }); + + testWidgets('search text clears when user is selected', (tester) async { + final user = eg.user(fullName: 'Test User'); + await setupSheet(tester, users: [user]); + + await tester.enterText(find.byType(TextField), 'Test'); + await tester.pump(); + final textField = tester.widget(find.byType(TextField)); + check(textField.controller!.text).equals('Test'); + + await tester.tap(findUserTile(user)); + await tester.pump(); + check(textField.controller!.text).isEmpty(); + }); + }); + + group('user selection', () { + void checkUserSelected(WidgetTester tester, User user, bool expected) { + final icon = tester.widget(find.descendant( + of: findUserTile(user), + matching: find.byType(Icon))); + + if (expected) { + check(findUserChip(user)).findsOne(); + check(icon).icon.equals(ZulipIcons.check_circle_checked); + } else { + check(findUserChip(user)).findsNothing(); + check(icon).icon.equals(ZulipIcons.check_circle_unchecked); + } + } + + testWidgets('tapping user chip deselects the user', (tester) async { + await setupSheet(tester, users: [eg.otherUser, eg.thirdUser]); + + await tester.tap(findUserTile(eg.otherUser)); + await tester.pump(); + checkUserSelected(tester, eg.otherUser, true); + await tester.tap(findUserChip(eg.otherUser)); + await tester.pump(); + checkUserSelected(tester, eg.otherUser, false); + }); + + testWidgets('selecting and deselecting a user', (tester) async { + final user = eg.user(fullName: 'Test User'); + await setupSheet(tester, users: [user]); + + checkUserSelected(tester, user, false); + checkUserSelected(tester, eg.selfUser, false); + checkComposeButtonEnabled(tester, false); + + await tester.tap(findUserTile(user)); + await tester.pump(); + checkUserSelected(tester, user, true); + checkComposeButtonEnabled(tester, true); + + await tester.tap(findUserTile(user)); + await tester.pump(); + checkUserSelected(tester, user, false); + checkComposeButtonEnabled(tester, false); + }); + + testWidgets('other user selection deselects self user', (tester) async { + final otherUser = eg.user(fullName: 'Other User'); + await setupSheet(tester, users: [otherUser]); + + await tester.tap(findUserTile(eg.selfUser)); + await tester.pump(); + checkUserSelected(tester, eg.selfUser, true); + check(findText(includePlaceholders: false, eg.selfUser.fullName)).findsExactly(2); + + await tester.tap(findUserTile(otherUser)); + await tester.pump(); + checkUserSelected(tester, otherUser, true); + check(find.text(eg.selfUser.fullName)).findsNothing(); + }); + + testWidgets('other user selection hides self user', (tester) async { + final otherUser = eg.user(fullName: 'Other User'); + await setupSheet(tester, users: [otherUser]); + + check(findText(includePlaceholders: false, eg.selfUser.fullName)).findsOne(); + + await tester.tap(findUserTile(otherUser)); + await tester.pump(); + check(find.text(eg.selfUser.fullName)).findsNothing(); + }); + + testWidgets('can select multiple users', (tester) async { + final user1 = eg.user(fullName: 'Test User 1'); + final user2 = eg.user(fullName: 'Test User 2'); + await setupSheet(tester, users: [user1, user2]); + + await tester.tap(findUserTile(user1)); + await tester.pump(); + await tester.tap(findUserTile(user2)); + await tester.pump(); + checkUserSelected(tester, user1, true); + checkUserSelected(tester, user2, true); + }); + }); + + group('User status', () { + void checkFindsTileStatusEmoji(WidgetTester tester, User user, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + final tileStatusEmojiFinder = find.descendant(of: findUserTile(user), + matching: statusEmojiFinder); + check(tester.widget(tileStatusEmojiFinder) + .neverAnimate).isTrue(); + check(tileStatusEmojiFinder).findsOne(); + } + + void checkFindsChipStatusEmoji(WidgetTester tester, User user, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + final chipStatusEmojiFinder = find.descendant(of: findUserChip(user), + matching: statusEmojiFinder); + check(tester.widget(chipStatusEmojiFinder) + .neverAnimate).isTrue(); + check(chipStatusEmojiFinder).findsOne(); + } + + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + await setupSheet(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsTileStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(findUserChip(user)).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + + await tester.tap(findUserTile(user)); + await tester.pump(); + + checkFindsTileStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(findUserChip(user)).findsOne(); + checkFindsChipStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + await setupSheet(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(findUserTile(user)).findsOne(); + check(findUserChip(user)).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + + await tester.tap(findUserTile(user)); + await tester.pump(); + + check(findUserTile(user)).findsOne(); + check(findUserChip(user)).findsOne(); + check(find.textContaining('Busy')).findsNothing(); + }); + }); + + group('navigation to DM Narrow', () { + Future runAndCheck(WidgetTester tester, { + required List users, + required String expectedAppBarTitle, + }) async { + await setupSheet(tester, users: users); + + final context = tester.element(find.byType(NewDmPicker)); + final store = PerAccountStoreWidget.of(context); + final connection = store.connection as FakeApiConnection; + + connection.prepare( + json: eg.newestGetMessagesResult(foundOldest: true, messages: []).toJson()); + for (final user in users) { + await tester.tap(findUserTile(user)); + await tester.pump(); + } + await tester.tap(findComposeButton); + await tester.pumpAndSettle(); + check(find.widgetWithText(ZulipAppBar, expectedAppBarTitle)).findsOne(); + + check(find.byType(ComposeBox)).findsOne(); + } + + testWidgets('navigates to self DM', (tester) async { + await runAndCheck( + tester, + users: [eg.selfUser], + expectedAppBarTitle: 'DMs with yourself'); + }); + + testWidgets('navigates to 1:1 DM', (tester) async { + final user = eg.user(fullName: 'Test User'); + await runAndCheck( + tester, + users: [user], + expectedAppBarTitle: 'DMs with Test User'); + }); + + testWidgets('navigates to group DM', (tester) async { + final users = [ + eg.user(fullName: 'User 1'), + eg.user(fullName: 'User 2'), + eg.user(fullName: 'User 3'), + ]; + await runAndCheck( + tester, + users: users, + expectedAppBarTitle: 'DMs with User 1, User 2, User 3'); + }); + }); +} diff --git a/test/widgets/page_checks.dart b/test/widgets/page_checks.dart deleted file mode 100644 index 412a59fc49..0000000000 --- a/test/widgets/page_checks.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:flutter/widgets.dart'; -import 'package:zulip/widgets/page.dart'; - -extension WidgetRouteChecks on Subject> { - Subject get page => has((x) => x.page, 'page'); -} - -extension AccountPageRouteMixinChecks on Subject> { - Subject get accountId => has((x) => x.accountId, 'accountId'); -} diff --git a/test/widgets/poll_test.dart b/test/widgets/poll_test.dart index bd6ae3f92f..8e3d66c3bb 100644 --- a/test/widgets/poll_test.dart +++ b/test/widgets/poll_test.dart @@ -5,7 +5,6 @@ import 'package:http/http.dart' as http; import 'package:flutter/widgets.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/submessage.dart'; import 'package:zulip/model/store.dart'; @@ -29,18 +28,22 @@ void main() { WidgetTester tester, SubmessageData? submessageContent, { Iterable? users, + List? mutedUserIds, Iterable<(User, int)> voterIdxPairs = const [], }) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers(users ?? [eg.selfUser, eg.otherUser]); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } connection = store.connection as FakeApiConnection; message = eg.streamMessage( sender: eg.selfUser, submessages: [eg.submessage(content: submessageContent)]); - await store.handleEvent(MessageEvent(id: 0, message: message)); + await store.addMessage(message); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: PollWidget(messageId: message.id, poll: message.poll!))); await tester.pump(); @@ -97,6 +100,18 @@ void main() { check(findTextAtRow('100', index: 0)).findsOne(); }); + testWidgets('muted voters', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await preparePollWidget(tester, pollWidgetData, + users: [user1, user2], + mutedUserIds: [user2.userId], + voterIdxPairs: [(user1, 0), (user2, 0), (user2, 1)]); + + check(findTextAtRow('(User 1, Muted user)', index: 0)).findsOne(); + check(findTextAtRow('(Muted user)', index: 1)).findsOne(); + }); + testWidgets('show unknown voter', (tester) async { await preparePollWidget(tester, pollWidgetData, users: [eg.selfUser], voterIdxPairs: [(eg.thirdUser, 1)]); diff --git a/test/widgets/profile_page_checks.dart b/test/widgets/profile_page_checks.dart deleted file mode 100644 index bc08b43ec1..0000000000 --- a/test/widgets/profile_page_checks.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:zulip/widgets/profile.dart'; - -extension ProfilePageChecks on Subject { - Subject get userId => has((x) => x.userId, 'userId'); -} diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 38f6c223e3..c50a02563e 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -1,43 +1,68 @@ +import 'dart:io'; + import 'package:checks/checks.dart'; +import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; +import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/button.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; +import 'package:zulip/widgets/remote_settings.dart'; import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/user.dart'; +import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; import '../model/test_store.dart'; +import '../stdlib_checks.dart'; +import '../test_images.dart'; import '../test_navigation.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; -import 'profile_page_checks.dart'; +import 'checks.dart'; +import 'finders.dart'; import 'test_app.dart'; +late PerAccountStore store; +late FakeApiConnection connection; + Future setupPage(WidgetTester tester, { required int pageUserId, List? users, + List? mutedUserIds, List? customProfileFields, Map? realmDefaultExternalAccounts, + bool realmPresenceDisabled = false, NavigatorObserver? navigatorObserver, }) async { addTearDown(testBinding.reset); final initialSnapshot = eg.initialSnapshot( customProfileFields: customProfileFields, - realmDefaultExternalAccounts: realmDefaultExternalAccounts); + realmDefaultExternalAccounts: realmDefaultExternalAccounts, + realmPresenceDisabled: realmPresenceDisabled); await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; await store.addUser(eg.selfUser); if (users != null) { await store.addUsers(users); } + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, @@ -48,39 +73,135 @@ Future setupPage(WidgetTester tester, { await tester.pumpAndSettle(); } -CustomProfileField mkCustomProfileField( - int id, - CustomProfileFieldType type, { - int? order, - bool? displayInProfileSummary, - String? fieldData, -}) { - return CustomProfileField( - id: id, - type: type, - order: order ?? id, - name: 'field$id', - hint: 'hint$id', - fieldData: fieldData ?? '', - displayInProfileSummary: displayInProfileSummary ?? true, - ); -} - void main() { TestZulipBinding.ensureInitialized(); - group('ProfilePage', () { - testWidgets('page builds; profile page renders', (tester) async { - final user = eg.user(userId: 1, fullName: 'test user', - deliveryEmail: 'testuser@example.com'); + testWidgets('page builds; profile page renders', (tester) async { + final user = eg.user(userId: 1, fullName: 'test user', + deliveryEmail: 'testuser@example.com'); + + await setupPage(tester, users: [user], pageUserId: user.userId); + + check(because: 'find user avatar', find.byType(Avatar).evaluate()).length.equals(1); + check(because: 'find user name', find.text('test user').evaluate()).isNotEmpty(); + // Tests for user status are in their own test group. + check(because: 'find user delivery email', find.text('testuser@example.com').evaluate()).isNotEmpty(); + }); + + testWidgets('page builds; error page shows up if data is missing', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId + 1989); + check(because: 'find no user avatar', find.byType(Avatar).evaluate()).isEmpty(); + check(because: 'find error icon', find.byIcon(Icons.error).evaluate()).isNotEmpty(); + }); + + testWidgets('page builds; dm links to correct narrow', (tester) async { + final pushedRoutes = >[]; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + + await setupPage(tester, + users: [eg.user(userId: 1)], + pageUserId: 1, + navigatorObserver: testNavObserver, + ); + + final targetWidget = find.byIcon(Icons.email); + await tester.ensureVisible(targetWidget); + await tester.tap(targetWidget); + check(pushedRoutes).last.isA().page + .isA() + .initNarrow.equals(DmNarrow.withUser(1, selfUserId: eg.selfUser.userId)); + }); + + testWidgets('page builds; ensure long name does not overflow', (tester) async { + final longString = 'X' * 400; + final user = eg.user(userId: 1, fullName: longString); + await setupPage(tester, users: [user], pageUserId: user.userId); + check(find.text(longString).evaluate()).isNotEmpty(); + }); + group('_LastActiveTime', () { + Future update(WidgetTester tester, User user, { + required int activeSeconds, + int? idleSeconds, + }) async { + idleSeconds ??= activeSeconds + 3600; + final now = clock.now().millisecondsSinceEpoch ~/ 1000; + final presence = PerUserPresence( + activeTimestamp: now - activeSeconds, + idleTimestamp: now - idleSeconds, + ); + store.presence.debugHandlePresenceResponse({user.userId: presence}); + await tester.pump(); + } + + testWidgets('active, idle, never', (tester) async { + final user = eg.user(); await setupPage(tester, users: [user], pageUserId: user.userId); + check(find.text('Not active in the last year')).findsOne(); + + await update(tester, user, activeSeconds: 3600, idleSeconds: 30); + check(find.text('Idle')).findsOne(); - check(because: 'find user avatar', find.byType(Avatar).evaluate()).length.equals(1); - check(because: 'find user name', find.text('test user').evaluate()).isNotEmpty(); - check(because: 'find user delivery email', find.text('testuser@example.com').evaluate()).isNotEmpty(); + await update(tester, user, activeSeconds: 30); + check(find.text('Active now')).findsOne(); }); + testWidgets('various ages', (tester) async { + final user = eg.user(); + await setupPage(tester, users: [user], pageUserId: user.userId); + + // These tests could be more detailed in making sure the behavior is + // exactly the way it currently is. But save that for a future where + // we've made a pass over the logic to ensure we're happy with that spec. + + await update(tester, user, activeSeconds: 10 * 60); + check(find.text('Active 10 minutes ago')).findsOne(); + + await update(tester, user, activeSeconds: 61 * 60); + check(find.text('Active 1 hour ago')).findsOne(); + + await update(tester, user, activeSeconds: 20 * 60 * 60); + check(find.text('Active 20 hours ago')).findsOne(); + + await update(tester, user, activeSeconds: 24 * 60 * 60); + check(find.text('Active yesterday')).findsOne(); + + await update(tester, user, activeSeconds: 2 * 24 * 60 * 60); + check(find.text('Active 2 days ago')).findsOne(); + + await update(tester, user, activeSeconds: 80 * 24 * 60 * 60); + check(find.text('Active 80 days ago')).findsOne(); + }); + + testWidgets('dates', (tester) async { + final user = eg.user(); + await setupPage(tester, users: [user], pageUserId: user.userId); + + final now = DateTime.parse('2025-08-01 12:00'); + await withClock(Clock.fixed(now), () async { + await update(tester, user, activeSeconds: now.difference( + DateTime.parse('2025-04-01 12:00')).inSeconds); + check(find.text('Active Apr 1')).findsOne(); + + await update(tester, user, activeSeconds: now.difference( + DateTime.parse('2024-04-01 12:00')).inSeconds); + check(find.text('Active Apr 1, 2024')).findsOne(); + }); + }); + + testWidgets('omit for bots', (tester) async { + final user = eg.user(isBot: true); + await setupPage(tester, users: [user], pageUserId: user.userId); + await update(tester, user, activeSeconds: 30); + check(find.text('Active now')).findsNothing(); + check(find.textContaining('Active')).findsNothing(); + check(find.textContaining('Idle')).findsNothing(); + check(find.textContaining('Not active')).findsNothing(); + }); + }); + + group('custom profile fields', () { testWidgets('page builds; profile page renders with profileData', (tester) async { await setupPage(tester, users: [ @@ -98,16 +219,16 @@ void main() { ], pageUserId: 1, customProfileFields: [ - mkCustomProfileField(0, CustomProfileFieldType.shortText), - mkCustomProfileField(1, CustomProfileFieldType.longText), - mkCustomProfileField(2, CustomProfileFieldType.choice, + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(1, CustomProfileFieldType.longText), + eg.customProfileField(2, CustomProfileFieldType.choice, fieldData: '{"x": {"text": "choiceValue", "order": "1"}}'), - mkCustomProfileField(3, CustomProfileFieldType.date), - mkCustomProfileField(4, CustomProfileFieldType.link), - mkCustomProfileField(5, CustomProfileFieldType.user), - mkCustomProfileField(6, CustomProfileFieldType.externalAccount, + eg.customProfileField(3, CustomProfileFieldType.date), + eg.customProfileField(4, CustomProfileFieldType.link), + eg.customProfileField(5, CustomProfileFieldType.user), + eg.customProfileField(6, CustomProfileFieldType.externalAccount, fieldData: '{"subtype": "external1"}'), - mkCustomProfileField(7, CustomProfileFieldType.pronouns), + eg.customProfileField(7, CustomProfileFieldType.pronouns), ], realmDefaultExternalAccounts: { 'external1': RealmDefaultExternalAccount( name: 'external1', @@ -143,12 +264,6 @@ void main() { .deepEquals([1, 2]); }); - testWidgets('page builds; error page shows up if data is missing', (tester) async { - await setupPage(tester, pageUserId: eg.selfUser.userId + 1989); - check(because: 'find no user avatar', find.byType(Avatar).evaluate()).isEmpty(); - check(because: 'find error icon', find.byIcon(Icons.error).evaluate()).isNotEmpty(); - }); - testWidgets('page builds; link type will navigate', (tester) async { const testUrl = 'http://example/url'; final user = eg.user(userId: 1, profileData: { @@ -158,13 +273,13 @@ void main() { await setupPage(tester, users: [user], pageUserId: user.userId, - customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.link)], + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.link)], ); await tester.tap(find.text(testUrl)); check(testBinding.takeLaunchUrlCalls()).single.equals(( url: Uri.parse(testUrl), - mode: LaunchMode.platformDefault, + mode: LaunchMode.inAppBrowserView, )); }); @@ -177,7 +292,7 @@ void main() { users: [user], pageUserId: user.userId, customProfileFields: [ - mkCustomProfileField(0, CustomProfileFieldType.externalAccount, + eg.customProfileField(0, CustomProfileFieldType.externalAccount, fieldData: '{"subtype": "external1"}') ], realmDefaultExternalAccounts: { @@ -191,7 +306,7 @@ void main() { await tester.tap(find.text('externalValue')); check(testBinding.takeLaunchUrlCalls()).single.equals(( url: Uri.parse('http://example/externalValue'), - mode: LaunchMode.platformDefault, + mode: LaunchMode.inAppBrowserView, )); }); @@ -209,7 +324,7 @@ void main() { await setupPage(tester, users: users, pageUserId: 1, - customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)], + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)], navigatorObserver: testNavObserver, ); @@ -230,30 +345,48 @@ void main() { await setupPage(tester, users: users, pageUserId: 1, - customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)], + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)], ); final textFinder = find.text('(unknown user)'); check(textFinder.evaluate()).length.equals(1); }); - testWidgets('page builds; dm links to correct narrow', (tester) async { - final pushedRoutes = >[]; - final testNavObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + testWidgets('page builds; user field with muted user', (tester) async { + prepareBoringImageHttpClient(); + + Finder avatarFinder(int userId) => find.byWidgetPredicate( + (widget) => widget is Avatar && widget.userId == userId); + Finder mutedAvatarFinder(int userId) => find.descendant( + of: avatarFinder(userId), + matching: find.byIcon(ZulipIcons.person)); + Finder nonmutedAvatarFinder(int userId) => find.descendant( + of: avatarFinder(userId), + matching: find.byType(RealmContentNetworkImage)); + + final users = [ + eg.user(userId: 1, profileData: { + 0: ProfileFieldUserData(value: '[2,3]'), + }), + eg.user(userId: 2, fullName: 'test user2', avatarUrl: '/foo.png'), + eg.user(userId: 3, fullName: 'test user3', avatarUrl: '/bar.png'), + ]; await setupPage(tester, - users: [eg.user(userId: 1)], + users: users, + mutedUserIds: [2], pageUserId: 1, - navigatorObserver: testNavObserver, - ); + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)]); + + check(find.text('Muted user')).findsOne(); + check(mutedAvatarFinder(2)).findsOne(); + check(nonmutedAvatarFinder(2)).findsNothing(); + + check(find.text('test user3')).findsOne(); + check(mutedAvatarFinder(3)).findsNothing(); + check(nonmutedAvatarFinder(3)).findsOne(); - final targetWidget = find.byIcon(Icons.email); - await tester.ensureVisible(targetWidget); - await tester.tap(targetWidget); - check(pushedRoutes).last.isA().page - .isA() - .initNarrow.equals(DmNarrow.withUser(1, selfUserId: eg.selfUser.userId)); + debugNetworkImageHttpClientProvider = null; }); testWidgets('page builds; user links render multiple avatars', (tester) async { @@ -268,7 +401,7 @@ void main() { await setupPage(tester, users: users, pageUserId: 1, - customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)], + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)], ); final avatars = tester.widgetList(find.byType(Avatar)); @@ -276,13 +409,6 @@ void main() { .deepEquals([1, 2, 3]); }); - testWidgets('page builds; ensure long name does not overflow', (tester) async { - final longString = 'X' * 400; - final user = eg.user(userId: 1, fullName: longString); - await setupPage(tester, users: [user], pageUserId: user.userId); - check(find.text(longString).evaluate()).isNotEmpty(); - }); - testWidgets('page builds; ensure long customProfileFields do not overflow', (tester) async { final longString = 'X' * 400; final user = eg.user(userId: 1, fullName: 'fullName', profileData: { @@ -298,16 +424,16 @@ void main() { await setupPage(tester, users: [user, user2], pageUserId: user.userId, customProfileFields: [ - mkCustomProfileField(0, CustomProfileFieldType.shortText), - mkCustomProfileField(1, CustomProfileFieldType.longText), - mkCustomProfileField(2, CustomProfileFieldType.choice, + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(1, CustomProfileFieldType.longText), + eg.customProfileField(2, CustomProfileFieldType.choice, fieldData: '{"x": {"text": "$longString", "order": "1"}}'), // no [CustomProfileFieldType.date] because those can't be made long - mkCustomProfileField(3, CustomProfileFieldType.link), - mkCustomProfileField(4, CustomProfileFieldType.user), - mkCustomProfileField(5, CustomProfileFieldType.externalAccount, + eg.customProfileField(3, CustomProfileFieldType.link), + eg.customProfileField(4, CustomProfileFieldType.user), + eg.customProfileField(5, CustomProfileFieldType.externalAccount, fieldData: '{"subtype": "external1"}'), - mkCustomProfileField(6, CustomProfileFieldType.pronouns), + eg.customProfileField(6, CustomProfileFieldType.pronouns), ], realmDefaultExternalAccounts: { 'external1': RealmDefaultExternalAccount( name: 'external1', @@ -318,4 +444,364 @@ void main() { check(find.textContaining(longString).evaluate()).length.equals(7); }); }); + + group('user status', () { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + Finder findStatusButton({required bool statusSet}) { + return find.widgetWithText(ZulipMenuItemButton, + statusSet + ? zulipLocalizations.statusButtonLabelStatusSet + : zulipLocalizations.statusButtonLabelStatusUnset); + } + + testWidgets('non-self profile, status set: no status button, status info appears', (tester) async { + await setupPage(tester, pageUserId: eg.otherUser.userId, users: [eg.otherUser]); + await store.changeUserStatus(eg.otherUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + check(findStatusButton(statusSet: true)).findsNothing(); + + final statusEmojiFinder = find.ancestor(of: find.text('\u{1f6e0}'), + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isFalse(); + check(find.text('Busy')).findsOne(); + }); + + group('self-profile', () { + testWidgets('no status set: status button appears', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId, users: [eg.selfUser]); + check(findStatusButton(statusSet: false)).findsOne(); + }); + + testWidgets('status set: status button appears with status info inside it', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId, users: [eg.selfUser]); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + final statusButtonFinder = findStatusButton(statusSet: true); + final statusEmojiFinder = find.ancestor(of: find.text('\u{1f6e0}'), + matching: find.byType(UserStatusEmoji)); + final statusTextFinder = findText(includePlaceholders: false, 'Busy'); + + check(statusButtonFinder).findsOne(); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isFalse(); + check(statusTextFinder).findsOne(); + + check(find.descendant(of: statusButtonFinder, + matching: statusEmojiFinder)).findsOne(); + check(find.descendant(of: statusButtonFinder, + matching: statusTextFinder)).findsOne(); + }); + + testWidgets('not status text set: status button appears with a placeholder text inside it', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId, users: [eg.selfUser]); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionNone(), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + final statusButtonFinder = findStatusButton(statusSet: true); + final textPlaceholderFinder = findText( + includePlaceholders: false, zulipLocalizations.noStatusText); + + check(statusButtonFinder).findsOne(); + check(textPlaceholderFinder).findsOne(); + check(find.descendant(of: statusButtonFinder, + matching: textPlaceholderFinder)).findsOne(); + }); + }); + }); + + group('invisible mode', () { + final findRow = find.widgetWithText(ZulipMenuItemButton, 'Invisible mode'); + final findToggle = find.descendant(of: findRow, matching: find.byType(Toggle)); + + void checkDoesNotAppear(WidgetTester tester) { + check(findRow).findsNothing(); + check(findToggle).findsNothing(); + } + + void checkAppears(WidgetTester tester) { + check(findRow).findsOne(); + check(findToggle).findsOne(); + } + + bool getValue(WidgetTester tester) => tester.widget(findToggle).value; + + void checkAppearsActive(WidgetTester tester, bool expected) { + check(getValue(tester)).equals(expected); + + check(tester.semantics.find(findRow)).matchesSemantics( + label: 'Invisible mode', + isFocusable: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + hasToggledState: true, + isToggled: expected); + } + + void prepareRequestSuccess([Duration delay = Duration.zero]) { + connection.prepare(json: {}, delay: delay); + } + + void prepareRequestError([Duration delay = Duration.zero]) { + connection.prepare(httpException: SocketException('failed'), delay: delay); + } + + void scheduleEventAfter(Duration duration, bool newInvisibleModeValue) async { + await Future.delayed(duration); + await store.handleEvent(UserSettingsUpdateEvent(id: 1, + property: UserSettingName.presenceEnabled, value: !newInvisibleModeValue)); + } + + void checkRequest(bool requestedInvisibleModeValue) { + check(connection.takeRequests()).single.isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/settings') + ..bodyFields.deepEquals({ + 'presence_enabled': requestedInvisibleModeValue ? 'false' : 'true', + }); + } + + final toggleInteractionModeVariant = ValueVariant<_InvisibleModeToggleInteractionMode>( + _InvisibleModeToggleInteractionMode.values.toSet()); + + Future doToggle(WidgetTester tester, _InvisibleModeToggleInteractionMode mode) async { + switch (mode) { + case _InvisibleModeToggleInteractionMode.tapRow: + await tester.tap(findRow); + case _InvisibleModeToggleInteractionMode.tapToggle: + await tester.tap(findToggle); + case _InvisibleModeToggleInteractionMode.dragToggleThumb: + final textDirection = Directionality.of(tester.element(findToggle)); + final dragDx = switch ((getValue(tester), textDirection)) { + (true, TextDirection.ltr) => -40.0, + (false, TextDirection.ltr) => 40.0, + (true, TextDirection.rtl) => 40.0, + (false, TextDirection.rtl) => -40.0, + }; + await tester.drag(findToggle, Offset(dragDx, 0.0)); + } + } + + testWidgets('self-profile: appears', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId); + checkAppears(tester); + }); + + testWidgets('self-profile, but presence disabled in realm: does not appear', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId, realmPresenceDisabled: true); + checkDoesNotAppear(tester); + }); + + testWidgets('non-self profile: does not appear', (tester) async { + await setupPage(tester, pageUserId: eg.otherUser.userId, users: [eg.otherUser]); + checkDoesNotAppear(tester); + }); + + testWidgets('without recent interaction, event causes immediate update, which sticks', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId); + check(store.userSettings.presenceEnabled).isTrue(); + checkAppearsActive(tester, false); + + await store.handleEvent(UserSettingsUpdateEvent(id: 1, + property: UserSettingName.presenceEnabled, value: false)); + await tester.pump(); + checkAppearsActive(tester, true); + + await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout * 2); + checkAppearsActive(tester, true); + }); + + testWidgets('smoke, turn on', (tester) async { + final toggleInteractionMode = toggleInteractionModeVariant.currentValue!; + + await setupPage(tester, pageUserId: eg.selfUser.userId); + check(store.userSettings.presenceEnabled).isTrue(); + checkAppearsActive(tester, false); + + // The appearance changes and the request is sent, immediately. + prepareRequestSuccess(Duration(milliseconds: 100)); + scheduleEventAfter(Duration(milliseconds: 150), true); + await doToggle(tester, toggleInteractionMode); + await tester.pump(); + await tester.pump(); + checkAppearsActive(tester, true); + checkRequest(true); + + // Wait a while, idly: no change, no extra requests + await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout * 2); + check(connection.takeRequests()).isEmpty(); + checkAppearsActive(tester, true); + }, variant: toggleInteractionModeVariant); + + testWidgets('smoke, turn off', (tester) async { + final toggleInteractionMode = toggleInteractionModeVariant.currentValue!; + + await setupPage(tester, pageUserId: eg.selfUser.userId); + await store.handleEvent(UserSettingsUpdateEvent(id: 1, + property: UserSettingName.presenceEnabled, value: false)); + await tester.pump(); + checkAppearsActive(tester, true); + + // The appearance changes and the request is sent, immediately. + prepareRequestSuccess(Duration(milliseconds: 100)); + scheduleEventAfter(Duration(milliseconds: 150), false); + await doToggle(tester, toggleInteractionMode); + await tester.pump(); + await tester.pump(); + checkAppearsActive(tester, false); + checkRequest(false); + + // Wait a while, idly: no change, no extra requests + await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout * 2); + check(connection.takeRequests()).isEmpty(); + checkAppearsActive(tester, false); + }, variant: toggleInteractionModeVariant); + + testWidgets('event arrives after local-echo timeout', (tester) async { + final toggleInteractionMode = toggleInteractionModeVariant.currentValue!; + + await setupPage(tester, pageUserId: eg.selfUser.userId); + check(store.userSettings.presenceEnabled).isTrue(); + checkAppearsActive(tester, false); + + // The appearance changes and the request is sent, immediately. + prepareRequestSuccess(Duration(milliseconds: 100)); + scheduleEventAfter(Duration(seconds: 10), true); + await doToggle(tester, toggleInteractionMode); + await tester.pump(); + await tester.pump(); + checkAppearsActive(tester, true); + checkRequest(true); + + // Local-echo timeout passes and event hasn't come; change back. + await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout); + await tester.pump(); + checkAppearsActive(tester, false); + + // The event comes after a while; update for the new value. + await tester.pump(Duration(seconds: 10)); + check(connection.takeRequests()).isEmpty(); + checkAppearsActive(tester, true); + }, variant: toggleInteractionModeVariant); + + testWidgets('request has an error', (tester) async { + final toggleInteractionMode = toggleInteractionModeVariant.currentValue!; + + await setupPage(tester, pageUserId: eg.selfUser.userId); + check(store.userSettings.presenceEnabled).isTrue(); + checkAppearsActive(tester, false); + + // The appearance changes and the request is sent, immediately. + final requestDuration = Duration(milliseconds: 100); + prepareRequestError(requestDuration); + await doToggle(tester, toggleInteractionMode); + await tester.pump(); + await tester.pump(); + checkAppearsActive(tester, true); + checkRequest(true); + + // The appearance doesn't change as soon as the request errors, + // if it errored quickly… + await tester.pump(requestDuration); + checkAppearsActive(tester, true); + + // Try waiting a bit longer; it still hasn't changed… + // (https://github.com/zulip/zulip-flutter/pull/1631#discussion_r2191301085 ) + final epsilon = Duration(milliseconds: 50); + await tester.pump(epsilon); + checkAppearsActive(tester, true); + + // …it changes when [RemoteSettingBuilder.localEchoMinimum] + // has passed since the interaction. + await tester.pump( + RemoteSettingBuilder.localEchoMinimum - requestDuration - epsilon); + await tester.pump(); + checkAppearsActive(tester, false); + + // Wait a while, idly: no change, no extra requests + await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout * 2); + check(connection.takeRequests()).isEmpty(); + checkAppearsActive(tester, false); + }, variant: toggleInteractionModeVariant); + + testWidgets('spam-tapping', (tester) async { + final toggleInteractionMode = toggleInteractionModeVariant.currentValue!; + + await setupPage(tester, pageUserId: eg.selfUser.userId); + check(store.userSettings.presenceEnabled).isTrue(); + checkAppearsActive(tester, false); + + Future doSpamTap({required bool expectedCurrentValue}) async { + checkAppearsActive(tester, expectedCurrentValue); + final newValue = !expectedCurrentValue; + // The appearance changes and the request is sent, immediately. + prepareRequestSuccess(Duration(milliseconds: 100)); + scheduleEventAfter(Duration(milliseconds: 150), newValue); + await doToggle(tester, toggleInteractionMode); + await tester.pump(); + await tester.pump(); + checkAppearsActive(tester, newValue); + checkRequest(newValue); + } + + // Events will be coming in, but those don't control the switch; + // only the user interaction does, until there have been no interactions + // for [RemoteSettingBuilder.localEchoMinimum]. + await doSpamTap(expectedCurrentValue: false); + await tester.pump(Duration(milliseconds: 90)); + await doSpamTap(expectedCurrentValue: true); + await tester.pump(Duration(milliseconds: 30)); + await doSpamTap(expectedCurrentValue: false); + await tester.pump(Duration(milliseconds: 60)); + await doSpamTap(expectedCurrentValue: true); + await tester.pump(Duration(milliseconds: 120)); + await doSpamTap(expectedCurrentValue: false); + await tester.pump(Duration(milliseconds: 120)); + await doSpamTap(expectedCurrentValue: true); + await tester.pump(Duration(milliseconds: 120)); + await doSpamTap(expectedCurrentValue: false); + await tester.pump(Duration(milliseconds: 300)); + await doSpamTap(expectedCurrentValue: true); + await tester.pump(Duration(milliseconds: 45)); + await doSpamTap(expectedCurrentValue: false); + await tester.pump(Duration(milliseconds: 600)); + await doSpamTap(expectedCurrentValue: true); + await tester.pump(Duration(milliseconds: 5)); + await doSpamTap(expectedCurrentValue: false); + check(getValue(tester)).equals(true); + + await tester.pump(RemoteSettingBuilder.localEchoMinimum - Duration(milliseconds: 1)); + check(getValue(tester)).equals(true); + await tester.pump(Duration(milliseconds: 2)); + check(getValue(tester)).equals(true); + + // Wait a while, idly: no change, no extra requests + await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout * 2); + check(connection.takeRequests()).isEmpty(); + checkAppearsActive(tester, true); + }, variant: toggleInteractionModeVariant); + }); +} + +enum _InvisibleModeToggleInteractionMode { + tapRow, + tapToggle, + dragToggleThumb, + // TODO(a11y) is there something separate to test? } diff --git a/test/widgets/read_receipts_test.dart b/test/widgets/read_receipts_test.dart new file mode 100644 index 0000000000..a716fc2985 --- /dev/null +++ b/test/widgets/read_receipts_test.dart @@ -0,0 +1,162 @@ +import 'dart:io'; + +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/read_receipts.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../stdlib_checks.dart'; +import 'test_app.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + late PerAccountStore store; + late FakeApiConnection connection; + late TransitionDurationObserver transitionDurationObserver; + + Future setupReceiptsSheet(WidgetTester tester, { + required int messageId, + required List users, + ValueGetter>? prepareReceiptsResponseSuccess, + ValueGetter? prepareReceiptsResponseError, + }) async { + assert((prepareReceiptsResponseSuccess == null) != (prepareReceiptsResponseError == null)); + + addTearDown(testBinding.reset); + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUsers(users); + + final message = eg.streamMessage(id: messageId); + final stream = eg.stream(streamId: message.streamId); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + + connection = store.connection as FakeApiConnection; + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + + transitionDurationObserver = TransitionDurationObserver(); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + navigatorObservers: [transitionDurationObserver], + child: MessageListPage(initNarrow: CombinedFeedNarrow()))); + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + await tester.longPress(find.byType(MessageContent)); + await transitionDurationObserver.pumpPastTransition(tester); + + connection.prepare( + json: prepareReceiptsResponseSuccess == null ? null + : GetReadReceiptsResult(userIds: prepareReceiptsResponseSuccess()).toJson(), + httpException: prepareReceiptsResponseError == null ? null + : prepareReceiptsResponseError(), + delay: transitionDurationObserver.transitionDuration + + const Duration(milliseconds: 100)); + + await tester.tap(find.byIcon(ZulipIcons.check_check)); + await transitionDurationObserver.pumpPastTransition(tester); + + check(find.ancestor(of: find.byType(ReadReceipts), + matching: find.byType(BottomSheet))).findsOne(); // receipts sheet opened + check(find.byType(CircularProgressIndicator)).findsOne(); + check(find.text('Read receipts')).findsOne(); + check(find.text('Close')).findsOne(); + + await tester.pumpAndSettle(); + } + + Finder findUserItem(String fullName) => + find.widgetWithText(ReadReceiptsUserItem, fullName); + + group('success', () { + testWidgets('message read by many people', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await setupReceiptsSheet(tester, messageId: 100, users: [user1, user2], + prepareReceiptsResponseSuccess: () => [1, 2]); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + check(find.text('This message has been read by 2 people:', + findRichText: true)).findsOne(); + check(findUserItem('User 1')).findsOne(); + check(findUserItem('User 2')).findsOne(); + }); + + testWidgets('message read by one person', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await setupReceiptsSheet(tester, messageId: 100, users: [user1, user2], + prepareReceiptsResponseSuccess: () => [1]); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + check(find.text('This message has been read by 1 person:', + findRichText: true)).findsOne(); + check(findUserItem('User 1')).findsOne(); + check(findUserItem('User 2')).findsNothing(); + }); + + testWidgets('message read by no one', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await setupReceiptsSheet(tester, messageId: 100, users: [user1, user2], + prepareReceiptsResponseSuccess: () => []); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + check(find.text('No one has read this message yet.')).findsOne(); + check(findUserItem('User 1')).findsNothing(); + check(findUserItem('User 2')).findsNothing(); + }); + + testWidgets('tapping user item opens their profile', (tester) async { + final user = eg.user(userId: 1, fullName: 'User 1'); + await setupReceiptsSheet(tester, messageId: 100, users: [user], + prepareReceiptsResponseSuccess: () => [1]); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + await tester.tap(findUserItem('User 1')); + await transitionDurationObserver.pumpPastTransition(tester); + check(find.byWidgetPredicate((widget) => widget is ProfilePage && widget.userId == 1)) + .findsOne(); + }); + }); + + testWidgets('failure', (tester) async { + await setupReceiptsSheet(tester, messageId: 100, users: [], + prepareReceiptsResponseError: () => SocketException('failed')); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + check(find.text('Failed to load read receipts.')).findsOne(); + }); +} diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 0976ca1bfd..32c1d3f28d 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -1,54 +1,55 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/narrow.dart'; -import 'package:zulip/widgets/content.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/new_dm_sheet.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/recent_dm_conversations.dart'; +import 'package:zulip/widgets/user.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; -import 'content_checks.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; +import 'checks.dart'; +import 'finders.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupPage(WidgetTester tester, { required List dmMessages, required List users, + User? selfUser, + List? mutedUserIds, NavigatorObserver? navigatorObserver, - String? newNameForSelfUser, }) async { addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + selfUser ??= eg.selfUser; + final selfAccount = eg.account(user: selfUser); + await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( + realmUsers: [selfUser, ...users])); + store = await testBinding.globalStore.perAccount(selfAccount.id); - await store.addUser(eg.selfUser); - for (final user in users) { - await store.addUser(user); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); } - for (final dmMessage in dmMessages) { - await store.handleEvent(MessageEvent(id: 1, message: dmMessage)); - } - - if (newNameForSelfUser != null) { - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, - fullName: newNameForSelfUser)); - } + await store.addMessages(dmMessages); await tester.pumpWidget(TestZulipApp( - accountId: eg.selfAccount.id, + accountId: selfAccount.id, navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [], child: const HomePage())); @@ -58,17 +59,23 @@ Future setupPage(WidgetTester tester, { // Switch to direct messages tab. await tester.tap(find.descendant( of: find.byType(Center), - matching: find.byIcon(ZulipIcons.user))); + matching: find.byIcon(ZulipIcons.two_person))); await tester.pump(); } void main() { TestZulipBinding.ensureInitialized(); + Finder findConversationItem(Narrow narrow) => find.byWidgetPredicate( + (widget) => widget is RecentDmConversationsItem && widget.narrow == narrow, + ); + group('RecentDmConversationsPage', () { - Finder findConversationItem(Narrow narrow) => find.byWidgetPredicate( - (widget) => widget is RecentDmConversationsItem && widget.narrow == narrow, - ); + testWidgets('appearance when empty', (tester) async { + await setupPage(tester, users: [], dmMessages: []); + check(find.text('You have no direct messages yet! Why not start the conversation?')) + .findsOne(); + }); testWidgets('page builds; conversations appear in order', (tester) async { final user1 = eg.user(userId: 1); @@ -108,6 +115,32 @@ void main() { await tester.pumpAndSettle(); check(tester.any(oldestConversationFinder)).isTrue(); // onscreen }); + + testWidgets('opens new DM sheet on New DM button tap', (tester) async { + Route? lastPushedRoute; + Route? lastPoppedRoute; + final testNavObserver = TestNavigatorObserver() + ..onPushed = ((route, _) => lastPushedRoute = route) + ..onPopped = ((route, _) => lastPoppedRoute = route); + + await setupPage(tester, navigatorObserver: testNavObserver, + users: [], dmMessages: []); + + await tester.tap(find.widgetWithText(GestureDetector, 'New DM')); + await tester.pump(); + check(lastPushedRoute).isA>(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); + check(find.byType(NewDmPicker)).findsOne(); + + await tester.tap(find.text('Cancel')); + await tester.pump(); + check(lastPoppedRoute).isA>(); + await tester.pump( + (lastPoppedRoute as TransitionRoute).reverseTransitionDuration + // TODO not sure why a 1ms fudge is needed; investigate. + + Duration(milliseconds: 1)); + check(find.byType(NewDmPicker)).findsNothing(); + }); }); group('RecentDmConversationsItem', () { @@ -140,8 +173,9 @@ void main() { // TODO(#232): syntax like `check(find(…), findsOneWidget)` final widget = tester.widget(find.descendant( of: find.byType(RecentDmConversationsItem), - matching: find.text(expectedText), - )); + // The title might contain a WidgetSpan (for status emoji); exclude + // the resulting placeholder character from the text to be matched. + matching: findText(expectedText, includePlaceholders: false))); if (expectedLines != null) { final renderObject = tester.renderObject(find.byWidget(widget)); check(renderObject.size.height).equals( @@ -150,8 +184,19 @@ void main() { } } + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(RecentDmConversationsItem))).findsOne(); + } + Future markMessageAsRead(WidgetTester tester, Message message) async { - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final store = await testBinding.globalStore.perAccount( + testBinding.globalStore.accounts.single.id); await store.handleEvent(UpdateMessageFlagsAddEvent( id: 1, flag: MessageFlag.read, all: false, messages: [message.id])); await tester.pump(); @@ -180,21 +225,46 @@ void main() { }); testWidgets('short name takes one line', (tester) async { - final message = eg.dmMessage(from: eg.selfUser, to: []); const name = 'Short name'; - await setupPage(tester, users: [], dmMessages: [message], - newNameForSelfUser: name); + final selfUser = eg.user(fullName: name); + await setupPage(tester, selfUser: selfUser, users: [], + dmMessages: [eg.dmMessage(from: selfUser, to: [])]); checkTitle(tester, name, 1); }); testWidgets('very long name takes two lines (must be ellipsized)', (tester) async { - final message = eg.dmMessage(from: eg.selfUser, to: []); const name = 'Long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name'; - await setupPage(tester, users: [], dmMessages: [message], - newNameForSelfUser: name); + final selfUser = eg.user(fullName: name); + await setupPage(tester, selfUser: selfUser, users: [], + dmMessages: [eg.dmMessage(from: selfUser, to: [])]); checkTitle(tester, name, 2); }); + group('User status', () { + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: []); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: []); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.selfUser, to: []); await setupPage(tester, users: [], dmMessages: [message]); @@ -206,16 +276,30 @@ void main() { }); group('1:1', () { - testWidgets('has right title/avatar', (tester) async { - final user = eg.user(userId: 1); - final message = eg.dmMessage(from: eg.selfUser, to: [user]); - await setupPage(tester, users: [user], dmMessages: [message]); - - checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); - checkTitle(tester, user.fullName); + group('has right title/avatar', () { + testWidgets('non-muted user', (tester) async { + final user = eg.user(userId: 1); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, user.fullName); + }); + + testWidgets('muted user', (tester) async { + final user = eg.user(userId: 1); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, + users: [user], + mutedUserIds: [user.userId], + dmMessages: [message]); + + final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); + check(findConversationItem(narrow)).findsNothing(); + }); }); - testWidgets('no error when user somehow missing from store.users', (tester) async { + testWidgets('no error when user somehow missing from user store', (tester) async { final user = eg.user(userId: 1); final message = eg.dmMessage(from: eg.selfUser, to: [user]); await setupPage(tester, @@ -241,6 +325,33 @@ void main() { checkTitle(tester, user.fullName, 2); }); + group('User status', () { + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); await setupPage(tester, users: [], dmMessages: [message]); @@ -260,18 +371,48 @@ void main() { return result; } - testWidgets('has right title/avatar', (tester) async { - final users = usersList(2); - final user0 = users[0]; - final user1 = users[1]; - final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); - await setupPage(tester, users: users, dmMessages: [message]); - - checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); - checkTitle(tester, '${user0.fullName}, ${user1.fullName}'); + group('has right title/avatar', () { + testWidgets('no users muted', (tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, users: users, dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, '${user0.fullName}, ${user1.fullName}'); + }); + + testWidgets('some users muted', (tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, + users: users, + mutedUserIds: [user0.userId], + dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, 'Muted user, ${user1.fullName}'); + }); + + testWidgets('all users muted', (tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, + users: users, + mutedUserIds: [user0.userId, user1.userId], + dmMessages: [message]); + + final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); + check(findConversationItem(narrow)).findsNothing(); + }); }); - testWidgets('no error when one user somehow missing from store.users', (tester) async { + testWidgets('no error when one user somehow missing from user store', (tester) async { final users = usersList(2); final user0 = users[0]; final user1 = users[1]; @@ -299,6 +440,20 @@ void main() { checkTitle(tester, users.map((u) => u.fullName).join(', '), 2); }); + testWidgets('status emoji & text are set -> none of them is displayed', (tester) async { + final users = usersList(4); + final message = eg.dmMessage(from: eg.selfUser, to: users); + await setupPage(tester, users: users, dmMessages: [message]); + await store.changeUserStatus(users.first.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + check(find.text('\u{1f6e0}')).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]); await setupPage(tester, users: [], dmMessages: [message]); diff --git a/test/widgets/remote_settings_test.dart b/test/widgets/remote_settings_test.dart new file mode 100644 index 0000000000..11981dab1f --- /dev/null +++ b/test/widgets/remote_settings_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../model/binding.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + group('RemoteSettingBuilder', () { + // This builder widget is covered in the tests for the "Invisible mode" + // toggle switch in test/widgets/profile_test.dart. + }); +} diff --git a/test/widgets/scrolling_test.dart b/test/widgets/scrolling_test.dart new file mode 100644 index 0000000000..cfcb5870eb --- /dev/null +++ b/test/widgets/scrolling_test.dart @@ -0,0 +1,433 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/widgets/scrolling.dart'; + +import '../flutter_checks.dart'; + +void main() { + group('MessageListScrollView', () { + Widget buildList({ + required MessageListScrollController controller, + required double topHeight, + required double bottomHeight, + }) { + return MessageListScrollView( + controller: controller, + center: const ValueKey('center'), + slivers: [ + SliverToBoxAdapter( + child: SizedBox(height: topHeight, child: Text('top'))), + SliverToBoxAdapter(key: const ValueKey('center'), + child: SizedBox(height: bottomHeight, child: Text('bottom'))), + ]); + } + + late MessageListScrollController controller; + late MessageListScrollPosition position; + + Future prepare(WidgetTester tester, { + bool reuseController = false, + required double topHeight, + required double bottomHeight, + }) async { + if (!reuseController) { + controller = MessageListScrollController(); + } + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: buildList(controller: controller, + topHeight: topHeight, bottomHeight: bottomHeight))); + await tester.pump(); + position = controller.position; + } + + // The `skipOffstage: false` produces more informative output + // when a test fails because one of the slivers is just offscreen. + final findTop = find.text('top', skipOffstage: false); + final findBottom = find.text('bottom', skipOffstage: false); + + testWidgets('short/short -> pinned at bottom', (tester) async { + // Starts out with items at bottom of viewport. + await prepare(tester, topHeight: 100, bottomHeight: 100); + check(tester.getRect(findBottom)).bottom.equals(600); + + // Try scrolling down (by dragging up); doesn't move. + await tester.drag(findTop, Offset(0, -100)); + await tester.pump(); + check(tester.getRect(findBottom)).bottom.equals(600); + + // Try scrolling up (by dragging down); doesn't move. + await tester.drag(findTop, Offset(0, 100)); + await tester.pump(); + check(tester.getRect(findBottom)).bottom.equals(600); + }); + + testWidgets('short/long -> scrolls to ends and no farther', (tester) async { + // Starts out scrolled to top (to show top of the bottom sliver). + await prepare(tester, topHeight: 100, bottomHeight: 800); + check(tester.getRect(findTop)).top.equals(0); + check(tester.getRect(findBottom)).bottom.equals(900); + + // Try scrolling up (by dragging down); doesn't move. + await tester.drag(findBottom, Offset(0, 100)); + await tester.pump(); + check(tester.getRect(findBottom)).bottom.equals(900); + + // Try scrolling down (by dragging up); moves only as far as bottom of list. + await tester.drag(findBottom, Offset(0, -400)); + await tester.pump(); + check(tester.getRect(findBottom)).bottom.equals(600); + }); + + testWidgets('starts by showing top of bottom sliver, long/long', (tester) async { + // Both slivers are long; the bottom sliver gets 75% of the viewport. + await prepare(tester, topHeight: 1000, bottomHeight: 3000); + check(tester.getRect(findBottom)).top.equals(150); + }); + + testWidgets('starts by showing top of bottom sliver, short/long', (tester) async { + // The top sliver is shorter than 25% of the viewport. + // It's shown in full, and the bottom sliver gets the rest (so >75%). + await prepare(tester, topHeight: 50, bottomHeight: 3000); + check(tester.getRect(findTop)).top.equals(0); + check(tester.getRect(findBottom)).top.equals(50); + }); + + testWidgets('starts by showing top of bottom sliver, short/medium', (tester) async { + // The whole list fits in the viewport. It's pinned to the bottom, + // even when that gives the bottom sliver more than 75%. + await prepare(tester, topHeight: 50, bottomHeight: 500); + check(tester.getRect(findTop))..top.equals(50)..bottom.equals(100); + check(tester.getRect(findBottom)).bottom.equals(600); + }); + + testWidgets('starts by showing top of bottom sliver, medium/short', (tester) async { + // The whole list fits in the viewport. It's pinned to the bottom, + // even when that gives the top sliver more than 25%. + await prepare(tester, topHeight: 300, bottomHeight: 100); + check(tester.getRect(findTop))..top.equals(200)..bottom.equals(500); + check(tester.getRect(findBottom)).bottom.equals(600); + }); + + testWidgets('starts by showing top of bottom sliver, long/short', (tester) async { + // The bottom sliver is shorter than 75% of the viewport. + // It's shown in full, and the top sliver gets the rest (so >25%). + await prepare(tester, topHeight: 1000, bottomHeight: 300); + check(tester.getRect(findTop)).bottom.equals(300); + check(tester.getRect(findBottom)).bottom.equals(600); + }); + + testWidgets('short/short -> starts at bottom, immediately without animation', (tester) async { + await prepare(tester, topHeight: 100, bottomHeight: 100); + + final ys = []; + for (int i = 0; i < 10; i++) { + ys.add(tester.getRect(findBottom).bottom - 600); + await tester.pump(Duration(milliseconds: 15)); + } + check(ys).deepEquals(List.generate(10, (_) => 0.0)); + }); + + testWidgets('short/long -> starts at desired start, immediately without animation', (tester) async { + await prepare(tester, topHeight: 100, bottomHeight: 800); + + final ys = []; + for (int i = 0; i < 10; i++) { + ys.add(tester.getRect(findTop).top); + await tester.pump(Duration(milliseconds: 15)); + } + check(ys).deepEquals(List.generate(10, (_) => 0.0)); + }); + + testWidgets('starts at desired start, even when bottom underestimated at first', (tester) async { + const numItems = 10; + const itemHeight = 20.0; + + // A list where the bottom sliver takes several rounds of layout + // to see how long it really is. + final controller = MessageListScrollController(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: MessageListScrollView( + controller: controller, + // The tiny cacheExtent causes each layout round to only reach + // the first item it expects will go beyond the viewport. + cacheExtent: 1.0, // in (logical) pixels! + center: const ValueKey('center'), + slivers: [ + SliverToBoxAdapter( + child: SizedBox(height: 300, child: Text('top'))), + SliverList.list(key: const ValueKey('center'), + children: List.generate(numItems, (i) => + SizedBox(height: (i+1) * itemHeight, child: Text('item $i')))), + ]))); + await tester.pump(); + + // Starts out with the bottom sliver occupying 75% of the viewport… + check(controller.position).pixels.equals(450); + // … even though it has more height than that. + check(tester.getRect(find.text('item 6'))).bottom.isGreaterThan(600); + // (And even though on the first round of layout, it would have looked + // much shorter so that the view would have tried to scroll to its end.) + }); + + testWidgets('stick to end of list when it grows', (tester) async { + await prepare(tester, + topHeight: 400, bottomHeight: 400); + check(tester.getRect(findBottom))..top.equals(200)..bottom.equals(600); + + // Bottom sliver grows; remain scrolled to (new) bottom. + await prepare(tester, reuseController: true, + topHeight: 400, bottomHeight: 500); + check(tester.getRect(findBottom))..top.equals(100)..bottom.equals(600); + }); + + testWidgets('when not at end, let it grow without following', (tester) async { + await prepare(tester, + topHeight: 400, bottomHeight: 400); + check(tester.getRect(findBottom))..top.equals(200)..bottom.equals(600); + + // Scroll up (by dragging down) to detach from end of list. + await tester.drag(findBottom, Offset(0, 100)); + await tester.pump(); + check(tester.getRect(findBottom))..top.equals(300)..bottom.equals(700); + + // Bottom sliver grows; remain at existing position, now farther from end. + await prepare(tester, reuseController: true, + topHeight: 400, bottomHeight: 500); + check(tester.getRect(findBottom))..top.equals(300)..bottom.equals(800); + }); + + testWidgets('position preserved when scrollable rebuilds', (tester) async { + // Tests that [MessageListScrollPosition.absorb] does its job. + // + // In the app, this situation can be triggered by changing the device's + // theme between light and dark. For this simplified example for a test, + // go for devicePixelRatio (which ScrollableState directly depends on). + + final controller = MessageListScrollController(); + final widget = Directionality(textDirection: TextDirection.ltr, + child: buildList(controller: controller, + topHeight: 400, bottomHeight: 400)); + await tester.pumpWidget( + MediaQuery(data: MediaQueryData(devicePixelRatio: 1.0), + child: widget)); + check(tester.getRect(findTop)).bottom.equals(200); + final position = controller.position; + check(position).isA(); + + // Drag away from the initial scroll position. + await tester.drag(findBottom, Offset(0, 200)); + await tester.pump(); + check(tester.getRect(findTop)).bottom.equals(400); + check(controller.position).identicalTo(position); + + // Then cause the ScrollableState to have didChangeDependencies called… + await tester.pumpWidget( + MediaQuery(data: MediaQueryData(devicePixelRatio: 2.0), + child: widget)); + // … so that it constructs a new MessageListScrollPosition… + check(controller.position) + ..not((it) => it.identicalTo(position)) + ..isA(); + // … and check the scroll position is preserved, not reset to initial. + check(tester.getRect(findTop)).bottom.equals(400); + }); + + group('scrollToEnd', () { + testWidgets('short -> slow', (tester) async { + await prepare(tester, topHeight: 300, bottomHeight: 600); + await tester.drag(findBottom, Offset(0, 300)); + await tester.pump(); + check(position).extentAfter.equals(300); + + // Start scrolling to end, from just a short distance up. + position.scrollToEnd(); + await tester.pump(); + check(position).extentAfter.equals(300); + check(position).activity.isA(); + + // The scrolling moves at a stately pace; … + await tester.pump(Duration(milliseconds: 100)); + check(position).extentAfter.equals(200); + + await tester.pump(Duration(milliseconds: 100)); + check(position).extentAfter.equals(100); + + // … then upon reaching the end, … + await tester.pump(Duration(milliseconds: 100)); + check(position).extentAfter.equals(0); + + // … goes idle on the next frame, … + await tester.pump(Duration(milliseconds: 1)); + check(position).activity.isA(); + // … without moving any farther. + check(position).extentAfter.equals(0); + }); + + testWidgets('long -> bounded speed', (tester) async { + const referenceSpeed = 8000.0; + const seconds = 10; + const distance = seconds * referenceSpeed; + await prepare(tester, topHeight: distance + 1000, bottomHeight: 300); + await tester.drag(findBottom, Offset(0, distance)); + await tester.pump(); + check(position).extentAfter.equals(distance); + + // Start scrolling to end. + position.scrollToEnd(); + await tester.pump(); + check(position).activity.isA(); + + // Let it scroll, plotting the trajectory. + final log = []; + for (int i = 0; i < seconds; i++) { + log.add(position.extentAfter); + await tester.pump(const Duration(seconds: 1)); + } + log.add(position.extentAfter); + check(log).deepEquals(List.generate(seconds + 1, + (i) => distance - referenceSpeed * i)); + + // Having reached the end, … + check(position).extentAfter.equals(0); + // … it goes idle on the next frame, … + await tester.pump(Duration(milliseconds: 1)); + check(position).activity.isA(); + // … without moving any farther. + check(position).extentAfter.equals(0); + }); + + testWidgets('starting from overscroll, just drift', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await prepare(tester, topHeight: 400, bottomHeight: 400); + + // Drag into overscroll. + await tester.drag(findBottom, Offset(0, -100)); + await tester.pump(); + final offset1 = position.pixels - position.maxScrollExtent; + check(offset1).isGreaterThan(100 / 2); + check(position).activity.isA(); + + // Start drifting back into range. + await tester.pump(Duration(milliseconds: 10)); + final offset2 = position.pixels - position.maxScrollExtent; + check(offset2)..isGreaterThan(0.0)..isLessThan(offset1); + check(position).activity.isA() + .velocity.isLessThan(0); + + // Invoke `scrollToEnd`. The motion should stop… + position.scrollToEnd(); + await tester.pump(); + check(position.pixels - position.maxScrollExtent).equals(offset2); + check(position).activity.isA() + .velocity.equals(0); + + // … and resume drifting from there… + await tester.pump(Duration(milliseconds: 10)); + final offset3 = position.pixels - position.maxScrollExtent; + check(offset3)..isGreaterThan(0.0)..isLessThan(offset2); + check(position).activity.isA() + .velocity.isLessThan(0); + + // … to eventually return to being in range. + await tester.pump(Duration(seconds: 1)); + check(position.pixels - position.maxScrollExtent).equals(0); + check(position).activity.isA(); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('starting very near end, apply min speed', (tester) async { + await prepare(tester, topHeight: 400, bottomHeight: 400); + // Verify the assumption used for constructing the example numbers below. + check(position.physics.toleranceFor(position).velocity) + .isCloseTo(20/3, .01); + + position.jumpTo(398); + await tester.pump(); + check(position).extentAfter.equals(2); + + position.scrollToEnd(); + await tester.pump(); + check(position).extentAfter.equals(2); + + // Reach the end in just 150ms, not 300ms. + await tester.pump(Duration(milliseconds: 75)); + check(position).extentAfter.equals(1); + await tester.pump(Duration(milliseconds: 75)); + check(position).extentAfter.equals(0); + }); + + testWidgets('on overscroll, stop', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await prepare(tester, topHeight: 400, bottomHeight: 1000); + + // Scroll up… + position.jumpTo(400); + await tester.pump(); + check(position).extentAfter.equals(600); + + // … then invoke `scrollToEnd`… + position.scrollToEnd(); + await tester.pump(); + + // … but have the bottom sliver turn out to be shorter than it was. + await prepare(tester, topHeight: 400, bottomHeight: 600, + reuseController: true); + check(position).extentAfter.equals(200); + + // Let the scrolling animation proceed until it hits the end. + int steps = 0; + while (position.extentAfter > 0) { + check(++steps).isLessThan(100); + await tester.pump(Duration(milliseconds: 11)); + } + + // This is the very first frame where the position reached the end. + // It's at exactly the end, no farther… + check(position.pixels - position.maxScrollExtent).equals(0); + + // … and the animation is done. Nothing further happens. + check(position).activity.isA(); + await tester.pump(Duration(milliseconds: 11)); + check(position.pixels - position.maxScrollExtent).equals(0); + check(position).activity.isA(); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('keep going even if content turns out longer', (tester) async { + await prepare(tester, topHeight: 1000, bottomHeight: 3000); + + // Scroll up… + position.jumpTo(0); + await tester.pump(); + check(position).extentAfter.equals(3000); + + // … then invoke `scrollToEnd`… + position.scrollToEnd(); + await tester.pump(); + + // … but have the bottom sliver turn out to be longer than it was. + await prepare(tester, topHeight: 1000, bottomHeight: 6000, + reuseController: true); + check(position).extentAfter.equals(6000); + + // Let the scrolling animation go until it stops. + int steps = 0; + double prevRemaining; + double remaining = position.extentAfter; + do { + prevRemaining = remaining; + check(++steps).isLessThan(100); + await tester.pump(Duration(milliseconds: 10)); + remaining = position.extentAfter; + } while (remaining < prevRemaining); + + // The scroll position should be all the way at the end. + check(remaining).equals(0); + }); + }); + }); +} diff --git a/test/widgets/set_status_test.dart b/test/widgets/set_status_test.dart new file mode 100644 index 0000000000..c1db94096b --- /dev/null +++ b/test/widgets/set_status_test.dart @@ -0,0 +1,463 @@ +import 'dart:io'; + +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/basic.dart'; +import 'package:zulip/model/emoji.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/button.dart'; +import 'package:zulip/widgets/emoji_reaction.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/page.dart'; +import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/set_status.dart'; +import 'package:zulip/widgets/user.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../example_data.dart'; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../stdlib_checks.dart'; +import '../test_images.dart'; +import '../test_navigation.dart'; + +import 'checks.dart'; +import 'finders.dart'; +import 'test_app.dart'; + +void main() { + late PerAccountStore store; + + TestZulipBinding.ensureInitialized(); + + final ServerEmojiData suggestedEmojiData = ServerEmojiData(codeToNames: { + '1f6e0': ['working_on_it'], + '1f4c5': ['calendar'], + '1f68c': ['bus'], + '1f912': ['sick'], + '1f334': ['palm_tree'], + '1f3e0': ['house'], + '1f3e2': ['office'], + }); + + Future setupPage(WidgetTester tester, { + UserStatusChange? change, + ServerEmojiData? emojiData, + NavigatorObserver? navigatorObserver, + }) async { + addTearDown(testBinding.reset); + + Route? currentRoute; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, _) => currentRoute = route; + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUser(eg.selfUser); + if (change != null) { + await store.changeUserStatus(eg.selfUser.userId, change); + } + if (emojiData != null) { + store.setServerEmojiData(emojiData); + } + + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + navigatorObservers: [testNavObserver, ?navigatorObserver], + child: ProfilePage(userId: eg.selfUser.userId))); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ZulipMenuItemButton, + switch (change) { + null || UserStatusChange(text: OptionNone(), emoji: OptionNone()) + => 'Set status', + _ => 'Status', + })); + await tester.pump(); + await testNavObserver.pumpPastTransition(tester); + check(currentRoute).isA().page.isA(); + } + + final clearButtonFinder = find.widgetWithText(TextButton, 'Clear'); + final saveButtonFinder = find.widgetWithText(TextButton, 'Save'); + + void checkButtonsEnabled(WidgetTester tester, + {required bool expectClear, required bool expectSave}) { + final clearButton = tester.widget(clearButtonFinder); + final saveButton = tester.widget(saveButtonFinder); + + expectClear + ? check(clearButton.onPressed).isNotNull() + : check(clearButton.onPressed).isNull(); + expectSave + ? check(saveButton.onPressed).isNotNull() + : check(saveButton.onPressed).isNull(); + } + + Finder findEmojiButton({bool emojiSelected = false}) { + return find.ancestor(of: emojiSelected + ? find.byType(UserStatusEmoji) : find.byIcon(ZulipIcons.smile), + matching: find.ancestor(of: find.byIcon(ZulipIcons.chevron_down), + matching: find.byType(IconButton))); + } + + Finder findStatusTextField() { + return find.byWidgetPredicate((widget) => switch(widget) { + TextField(decoration: InputDecoration(hintText: 'Your status')) => true, + _ => false + }); + } + + Finder findSuggestion({required String code, required String text}) { + final emojiFinder = find.ancestor( + of: find.text(tryParseEmojiCodeToUnicode(code)!), + matching: find.byType(UserStatusEmoji)); + return find.ancestor(of: emojiFinder, + matching: find.ancestor(of: find.text(text), + matching: find.byType(StatusSuggestionsListEntry))); + } + + testWidgets('set status page renders', (tester) async { + await setupPage(tester, emojiData: suggestedEmojiData); + + check(find.text('Set status')).findsOne(); + check(clearButtonFinder).findsOne(); + check(saveButtonFinder).findsOne(); + check(findEmojiButton()).findsOne(); + check(findStatusTextField()).findsOne(); + + check(findSuggestion(code: '1f6e0', text: 'Busy')).findsOne(); + check(findSuggestion(code: '1f4c5', text: 'In a meeting')).findsOne(); + check(findSuggestion(code: '1f68c', text: 'Commuting')).findsOne(); + check(findSuggestion(code: '1f3e0', text: 'Working remotely')).findsOne(); + }); + + group('"Clear" & "Save" buttons', () { + group('initial state', () { + testWidgets('no status set -> buttons are disabled', (tester) async { + await setupPage(tester); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + }); + + testWidgets('text & emoji are set -> "Clear" is enabled, "Save" is not', (tester) async { + await setupPage(tester, change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + }); + + testWidgets('only text is set -> "Clear" is enabled, "Save" is not', (tester) async { + await setupPage(tester, change: UserStatusChange( + text: OptionSome('Happy'), emoji: OptionNone())); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + }); + + testWidgets('only emoji is set -> "Clear" is enabled, "Save" is not', (tester) async { + await setupPage(tester, change: UserStatusChange( + text: OptionNone(), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + }); + }); + + group('edit status', () { + Future chooseUnicodeEmojiFromPicker(WidgetTester tester, String code, { + bool emojiSelected = false, + required TestNavigatorObserver navObserver, + required ValueGetter> currentRoute, + }) async { + await tester.tap(findEmojiButton(emojiSelected: emojiSelected)); + check(currentRoute()).isA>(); + await navObserver.pumpPastTransition(tester); + // We use `find.descendant` to not match for an emoji in status + // suggestions in the underlying page. + await tester.tap(find.descendant(of: find.byType(EmojiPicker), + matching: find.text(tryParseEmojiCodeToUnicode(code)!))); + await tester.pump(); + await navObserver.pumpPastTransition(tester); + check(currentRoute()).isA() + .page.isA(); + } + + group('no status set, buttons are disabled', () { + testWidgets('emoji is added -> buttons are enabled', (tester) async { + prepareBoringImageHttpClient(); + + Route? currentRoute; + final testNavObserver = TestNavigatorObserver(); + testNavObserver.onPushed = (route, _) => currentRoute = route; + testNavObserver.onPopped = (_, previous) => currentRoute = previous; + + await setupPage(tester, + emojiData: serverEmojiDataPopular, + navigatorObserver: testNavObserver); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + + // Choose 'slight_smile' from popular emojis. + await chooseUnicodeEmojiFromPicker(tester, '1f642', + navObserver: testNavObserver, currentRoute: () => currentRoute!); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('text is added -> buttons are enabled', (tester) async { + await setupPage(tester); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + + await tester.enterText(findStatusTextField(), 'Happy'); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + }); + + testWidgets('empty spaces are added as text -> buttons stays disabled', (tester) async { + await setupPage(tester); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + + await tester.enterText(findStatusTextField(), ' '); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + }); + + testWidgets('a suggestion is selected -> buttons are enabled', (tester) async { + await setupPage(tester, emojiData: suggestedEmojiData); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + }); + + testWidgets('a suggestion is selected, then removed -> buttons are enabled, then disabled', (tester) async { + await setupPage(tester, emojiData: suggestedEmojiData); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + + await tester.tap(clearButtonFinder); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + }); + }); + + group('status set, "Clear" is enabled, "Save" is not', () { + testWidgets('emoji is changed -> buttons are enabled', (tester) async { + prepareBoringImageHttpClient(); + + Route? currentRoute; + final testNavObserver = TestNavigatorObserver(); + testNavObserver.onPushed = (route, _) => currentRoute = route; + testNavObserver.onPopped = (_, previous) => currentRoute = previous; + + await setupPage(tester, + emojiData: ServerEmojiData(codeToNames: { + ...serverEmojiDataPopular.codeToNames, + ...suggestedEmojiData.codeToNames, + }), + navigatorObserver: testNavObserver, + change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + // Choose 'calender' included in suggested emojis. + await chooseUnicodeEmojiFromPicker(tester, '1f4c5', + emojiSelected: true, + navObserver: testNavObserver, currentRoute: () => currentRoute!); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('text is changed -> buttons are enabled', (tester) async { + await setupPage(tester, change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + await tester.enterText(findStatusTextField(), 'Happy as a calm'); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + }); + + testWidgets('empty spaces are added around the text -> buttons stays the same', (tester) async { + await setupPage(tester, change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + await tester.enterText(findStatusTextField(), ' Happy '); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + }); + + testWidgets('a suggestion is selected -> buttons are enabled', (tester) async { + await setupPage(tester, + emojiData: suggestedEmojiData, + change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + }); + + testWidgets('emoji & text are changed, then reset -> buttons are enabled, then "Clear" is enabled, "Save" is not', (tester) async { + prepareBoringImageHttpClient(); + + Route? currentRoute; + final testNavObserver = TestNavigatorObserver(); + testNavObserver.onPushed = (route, _) => currentRoute = route; + testNavObserver.onPopped = (_, previous) => currentRoute = previous; + + await setupPage(tester, + emojiData: ServerEmojiData(codeToNames: { + ...serverEmojiDataPopular.codeToNames, + ...suggestedEmojiData.codeToNames, + }), + navigatorObserver: testNavObserver, + change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + // Choose 'calender' included in suggested emojis. + await chooseUnicodeEmojiFromPicker(tester, '1f4c5', + emojiSelected: true, + navObserver: testNavObserver, currentRoute: () => currentRoute!); + await tester.enterText(findStatusTextField(), 'Happy as a calm'); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + + // Reset to the initial emoji. + await chooseUnicodeEmojiFromPicker(tester, '1f642', + emojiSelected: true, + navObserver: testNavObserver, currentRoute: () => currentRoute!); + // Reset to the initial text. + await tester.enterText(findStatusTextField(), 'Happy'); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('a new suggestion is selected, then reset -> buttons are enabled, then "Clear" is enabled, "Save" is not', (tester) async { + await setupPage(tester, + emojiData: suggestedEmojiData, + // One of the emoji suggestions. + change: UserStatusChange( + text: OptionSome('Working remotely'), + emoji: OptionSome(StatusEmoji(emojiName: 'house', + emojiCode: '1f3e0', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + + // Reset the suggestion. + await tester.tap(findSuggestion(code: '1f3e0', text: 'Working remotely')); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + }); + }); + }); + + testWidgets('"Clear" button removes both emoji and text', (tester) async { + await setupPage(tester, change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + + check(findEmojiButton(emojiSelected: true)).findsOne(); + check(tester.widget(findStatusTextField()).controller!.text) + .equals('Happy'); + + await tester.tap(clearButtonFinder); + await tester.pump(); + + check(findEmojiButton(emojiSelected: false)).findsOne(); + check(tester.widget(findStatusTextField()).controller!.text) + .equals(''); + }); + + group('"Save" button returns to Profile page, saves the status', () { + testWidgets('successful -> status info appears', (tester) async { + final testNavObserver = TestNavigatorObserver(); + + await setupPage(tester, + emojiData: suggestedEmojiData, navigatorObserver: testNavObserver); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + + final connection = store.connection as FakeApiConnection; + connection.prepare(json: {}, delay: Duration(milliseconds: 100)); + + await tester.tap(saveButtonFinder); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/status') + ..bodyFields.deepEquals({ + 'status_text': 'Busy', + 'emoji_name': 'working_on_it', + 'emoji_code': '1f6e0', + 'reaction_type': 'unicode_emoji', + }); + await testNavObserver.pumpPastTransition(tester); + check(find.byType(ProfilePage)).findsOne(); + }); + + testWidgets("error -> status info doesn't appears", (tester) async { + final testNavObserver = TestNavigatorObserver(); + + await setupPage(tester, + emojiData: suggestedEmojiData, navigatorObserver: testNavObserver); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + + final connection = store.connection as FakeApiConnection; + connection.prepare(httpException: SocketException('failed')); + + await tester.tap(saveButtonFinder); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/status') + ..bodyFields.deepEquals({ + 'status_text': 'Busy', + 'emoji_name': 'working_on_it', + 'emoji_code': '1f6e0', + 'reaction_type': 'unicode_emoji', + }); + await testNavObserver.pumpPastTransition(tester); + check(find.byType(ProfilePage)).findsOne(); + + check(find.text('\u{1f6e0}')).findsNothing(); + check(findText('Busy', includePlaceholders: false)).findsNothing(); + }); + }); + }); + + testWidgets('Status text field has a 60-char limit', (tester) async { + await setupPage(tester); + check(tester.widget(findStatusTextField()).maxLength).equals(60); + }); +} diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart new file mode 100644 index 0000000000..e889fa0d3d --- /dev/null +++ b/test/widgets/settings_test.dart @@ -0,0 +1,304 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/settings.dart'; +import 'package:zulip/widgets/page.dart'; +import 'package:zulip/widgets/settings.dart'; +import 'package:zulip/widgets/store.dart'; + +import '../flutter_checks.dart'; +import '../model/binding.dart'; +import '../model/store_checks.dart'; +import '../example_data.dart' as eg; +import '../test_navigation.dart'; +import 'checks.dart'; +import 'test_app.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + late TestNavigatorObserver testNavObserver; + late Route? lastPushedRoute; + late Route? lastPoppedRoute; + + Future prepare(WidgetTester tester) async { + addTearDown(testBinding.reset); + + testNavObserver = TestNavigatorObserver() + ..onPushed = ((route, _) => lastPushedRoute = route) + ..onPopped = ((route, _) => lastPoppedRoute = route); + lastPushedRoute = null; + lastPoppedRoute = null; + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + navigatorObservers: [testNavObserver], + child: SettingsPage())); + await tester.pump(); + await tester.pump(); + } + + void checkTileOnSettingsPage(WidgetTester tester, { + required String expectedTitle, + required String expectedSubtitle, + }) { + check(find.descendant(of: find.widgetWithText(ListTile, expectedTitle), + matching: find.text(expectedSubtitle))).findsOne(); + } + + Finder findRadioListTileWithTitle(String title) => find.ancestor( + of: find.text(title), + matching: find.byType(RadioListTile)); + + void checkRadioButtonAppearsChecked(WidgetTester tester, + String title, bool expectedIsChecked, {String? subtitle}) { + check(tester.semantics.find(findRadioListTileWithTitle(title))) + .containsSemantics( + label: subtitle == null + ? title + : '$title\n$subtitle', + isInMutuallyExclusiveGroup: true, + hasCheckedState: true, isChecked: expectedIsChecked); + } + + group('ThemeSetting', () { + void checkThemeSetting(WidgetTester tester, { + required ThemeSetting? expectedThemeSetting, + }) { + final expectedCheckedTitle = switch (expectedThemeSetting) { + null => 'System', + ThemeSetting.light => 'Light', + ThemeSetting.dark => 'Dark', + }; + for (final title in ['System', 'Light', 'Dark']) { + checkRadioButtonAppearsChecked(tester, title, title == expectedCheckedTitle); + } + check(testBinding.globalStore) + .settings.themeSetting.equals(expectedThemeSetting); + } + + testWidgets('smoke', (tester) async { + debugBrightnessOverride = Brightness.light; + + await testBinding.globalStore.settings.setThemeSetting(ThemeSetting.light); + await prepare(tester); + final element = tester.element(find.byType(SettingsPage)); + check(Theme.of(element)).brightness.equals(Brightness.light); + checkThemeSetting(tester, expectedThemeSetting: ThemeSetting.light); + + await tester.tap(findRadioListTileWithTitle('Dark')); + await tester.pump(); + await tester.pump(Duration(milliseconds: 250)); // wait for transition + check(Theme.of(element)).brightness.equals(Brightness.dark); + checkThemeSetting(tester, expectedThemeSetting: ThemeSetting.dark); + + await tester.tap(findRadioListTileWithTitle('System')); + await tester.pump(); + await tester.pump(Duration(milliseconds: 250)); // wait for transition + check(Theme.of(element)).brightness.equals(Brightness.light); + checkThemeSetting(tester, expectedThemeSetting: null); + + debugBrightnessOverride = null; + }); + + testWidgets('follow system setting when themeSetting is null', (tester) async { + debugBrightnessOverride = Brightness.dark; + + await prepare(tester); + final element = tester.element(find.byType(SettingsPage)); + check(Theme.of(element)).brightness.equals(Brightness.dark); + checkThemeSetting(tester, expectedThemeSetting: null); + + debugBrightnessOverride = null; + }); + }); + + group('BrowserPreference', () { + Finder useInAppBrowserSwitchFinder = find.ancestor( + of: find.text('Open links with in-app browser'), + matching: find.byType(SwitchListTile)); + + void checkSwitchAndGlobalSettings(WidgetTester tester, { + required bool checked, + required BrowserPreference? expectedBrowserPreference, + }) { + check(tester.widget(useInAppBrowserSwitchFinder)) + .value.equals(checked); + check(testBinding.globalStore) + .settings.browserPreference.equals(expectedBrowserPreference); + } + + testWidgets('smoke', (tester) async { + await testBinding.globalStore.settings + .setBrowserPreference(BrowserPreference.external); + await prepare(tester); + checkSwitchAndGlobalSettings(tester, + checked: false, expectedBrowserPreference: BrowserPreference.external); + + await tester.tap(useInAppBrowserSwitchFinder); + await tester.pump(); + checkSwitchAndGlobalSettings(tester, + checked: true, expectedBrowserPreference: BrowserPreference.inApp); + }); + + testWidgets('use our per-platform default browser preference', (tester) async { + await prepare(tester); + bool expectInApp = defaultTargetPlatform == TargetPlatform.android; + checkSwitchAndGlobalSettings(tester, + checked: expectInApp, expectedBrowserPreference: null); + + await tester.tap(useInAppBrowserSwitchFinder); + await tester.pump(); + expectInApp = !expectInApp; + checkSwitchAndGlobalSettings(tester, + checked: expectInApp, + expectedBrowserPreference: expectInApp + ? BrowserPreference.inApp : BrowserPreference.external); + }, variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + }); + + group('VisitFirstUnreadSetting', () { + String settingTitle(VisitFirstUnreadSetting setting) => switch (setting) { + VisitFirstUnreadSetting.always => 'First unread message', + VisitFirstUnreadSetting.conversations => 'First unread message in conversation views, newest message elsewhere', + VisitFirstUnreadSetting.never => 'Newest message', + }; + + void checkPage(WidgetTester tester, { + required VisitFirstUnreadSetting expectedSetting, + }) { + for (final setting in VisitFirstUnreadSetting.values) { + final thisSettingTitle = settingTitle(setting); + checkRadioButtonAppearsChecked(tester, + thisSettingTitle, setting == expectedSetting); + } + } + + testWidgets('smoke', (tester) async { + await prepare(tester); + + // "conversations" is the default, and it appears in the SettingsPage + // (as the setting tile's subtitle) + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .visitFirstUnread.equals(VisitFirstUnreadSetting.conversations); + checkTileOnSettingsPage(tester, + expectedTitle: 'Open message feeds at', + expectedSubtitle: settingTitle(VisitFirstUnreadSetting.conversations)); + + await tester.tap(find.text('Open message feeds at')); + await tester.pump(); + check(lastPushedRoute).isA() + .page.isA(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(VisitFirstUnreadSetting.always))); + await tester.pump(); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.always); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(VisitFirstUnreadSetting.conversations))); + await tester.pump(); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(VisitFirstUnreadSetting.never))); + await tester.pump(); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.never); + + await tester.tap(find.backButton()); + check(lastPoppedRoute).isA() + .page.isA(); + await tester.pump((lastPoppedRoute as TransitionRoute).reverseTransitionDuration); + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .visitFirstUnread.equals(VisitFirstUnreadSetting.never); + + checkTileOnSettingsPage(tester, + expectedTitle: 'Open message feeds at', + expectedSubtitle: settingTitle(VisitFirstUnreadSetting.never)); + }); + }); + + group('MarkReadOnScrollSetting', () { + String settingTitle(MarkReadOnScrollSetting setting) => switch (setting) { + MarkReadOnScrollSetting.always => 'Always', + MarkReadOnScrollSetting.conversations => 'Only in conversation views', + MarkReadOnScrollSetting.never => 'Never', + }; + + String? settingSubtitle(MarkReadOnScrollSetting setting) => switch (setting) { + MarkReadOnScrollSetting.always => null, + MarkReadOnScrollSetting.conversations => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.', + MarkReadOnScrollSetting.never => null, + }; + + void checkPage(WidgetTester tester, { + required MarkReadOnScrollSetting expectedSetting, + }) { + for (final setting in MarkReadOnScrollSetting.values) { + final thisSettingTitle = settingTitle(setting); + checkRadioButtonAppearsChecked(tester, + thisSettingTitle, + setting == expectedSetting, + subtitle: settingSubtitle(setting)); + } + } + + testWidgets('smoke', (tester) async { + await prepare(tester); + + // "conversations" is the default, and it appears in the SettingsPage + // (as the setting tile's subtitle) + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .markReadOnScroll.equals(MarkReadOnScrollSetting.conversations); + checkTileOnSettingsPage(tester, + expectedTitle: 'Mark messages as read on scroll', + expectedSubtitle: settingTitle(MarkReadOnScrollSetting.conversations)); + + await tester.tap(find.text('Mark messages as read on scroll')); + await tester.pump(); + check(lastPushedRoute).isA() + .page.isA(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(MarkReadOnScrollSetting.always))); + await tester.pump(); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.always); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(MarkReadOnScrollSetting.conversations))); + await tester.pump(); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(MarkReadOnScrollSetting.never))); + await tester.pump(); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.never); + + await tester.tap(find.byType(BackButton)); + check(lastPoppedRoute).isA() + .page.isA(); + await tester.pump((lastPoppedRoute as TransitionRoute).reverseTransitionDuration); + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .markReadOnScroll.equals(MarkReadOnScrollSetting.never); + + checkTileOnSettingsPage(tester, + expectedTitle: 'Mark messages as read on scroll', + expectedSubtitle: settingTitle(MarkReadOnScrollSetting.never)); + }); + }); + + // TODO maybe test GlobalSettingType.experimentalFeatureFlag settings + // Or maybe not; after all, it's a developer-facing feature, so + // should be low risk. + // (The main ingredient in writing such tests would be to wire up + // [GlobalSettingsStore.experimentalFeatureFlags] so that tests can + // control making it empty, or non-empty, at will.) +} diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index fc363c71e8..ac7ae4819a 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -1,6 +1,10 @@ import 'dart:math' as math; import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/widgets/sticky_header.dart'; @@ -8,12 +12,14 @@ import 'package:zulip/widgets/sticky_header.dart'; void main() { testWidgets('sticky headers: scroll up, headers overflow items, explicit version', (tester) async { await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, - child: StickyHeaderListView( - reverse: true, - children: List.generate(100, (i) => StickyHeaderItem( - allowOverflow: true, - header: _Header(i, height: 20), - child: _Item(i, height: 100)))))); + child: TouchSlop(touchSlop: 1, + child: StickyHeaderListView( + dragStartBehavior: DragStartBehavior.down, + reverse: true, + children: List.generate(100, (i) => StickyHeaderItem( + allowOverflow: true, + header: _Header(i, height: 20), + child: _Item(i, height: 100))))))); check(_itemIndexes(tester)).deepEquals([0, 1, 2, 3, 4, 5]); check(_headerIndex(tester)).equals(5); check(tester.getTopLeft(find.byType(_Item).last)).equals(const Offset(0, 0)); @@ -43,11 +49,13 @@ void main() { testWidgets('sticky headers: scroll up, headers bounded by items, semi-explicit version', (tester) async { await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, - child: StickyHeaderListView( - reverse: true, - children: List.generate(100, (i) => StickyHeaderItem( - header: _Header(i, height: 20), - child: _Item(i, height: 100)))))); + child: TouchSlop(touchSlop: 1, + child: StickyHeaderListView( + dragStartBehavior: DragStartBehavior.down, + reverse: true, + children: List.generate(100, (i) => StickyHeaderItem( + header: _Header(i, height: 20), + child: _Item(i, height: 100))))))); void checkState(int index, {required double item, required double header}) => _checkHeader(tester, index, first: false, @@ -68,41 +76,210 @@ void main() { for (final reverse in [true, false]) { for (final reverseHeader in [true, false]) { for (final growthDirection in GrowthDirection.values) { - for (final allowOverflow in [true, false]) { - final name = 'sticky headers: ' - 'scroll ${reverse ? 'up' : 'down'}, ' - 'header at ${reverseHeader ? 'bottom' : 'top'}, ' - '$growthDirection, ' - 'headers ${allowOverflow ? 'overflow' : 'bounded'}'; - testWidgets(name, (tester) => - _checkSequence(tester, - Axis.vertical, - reverse: reverse, - reverseHeader: reverseHeader, - growthDirection: growthDirection, - allowOverflow: allowOverflow, - )); - - for (final textDirection in TextDirection.values) { + for (final sliverConfig in _SliverConfig.values) { + for (final allowOverflow in [true, false]) { final name = 'sticky headers: ' - '${textDirection.name.toUpperCase()} ' - 'scroll ${reverse ? 'backward' : 'forward'}, ' - 'header at ${reverseHeader ? 'end' : 'start'}, ' + 'scroll ${reverse ? 'up' : 'down'}, ' + 'header at ${reverseHeader ? 'bottom' : 'top'}, ' '$growthDirection, ' - 'headers ${allowOverflow ? 'overflow' : 'bounded'}'; + 'headers ${allowOverflow ? 'overflow' : 'bounded'}, ' + 'slivers ${sliverConfig.name}'; testWidgets(name, (tester) => _checkSequence(tester, - Axis.horizontal, textDirection: textDirection, + Axis.vertical, reverse: reverse, reverseHeader: reverseHeader, growthDirection: growthDirection, allowOverflow: allowOverflow, + sliverConfig: sliverConfig, )); + + for (final textDirection in TextDirection.values) { + final name = 'sticky headers: ' + '${textDirection.name.toUpperCase()} ' + 'scroll ${reverse ? 'backward' : 'forward'}, ' + 'header at ${reverseHeader ? 'end' : 'start'}, ' + '$growthDirection, ' + 'headers ${allowOverflow ? 'overflow' : 'bounded'}, ' + 'slivers ${sliverConfig.name}'; + testWidgets(name, (tester) => + _checkSequence(tester, + Axis.horizontal, textDirection: textDirection, + reverse: reverse, + reverseHeader: reverseHeader, + growthDirection: growthDirection, + allowOverflow: allowOverflow, + sliverConfig: sliverConfig, + )); + } } } } } } + + testWidgets('sticky headers: propagate scrollOffsetCorrection properly', (tester) async { + Widget page(Widget Function(BuildContext, int) itemBuilder) { + return Directionality(textDirection: TextDirection.ltr, + child: StickyHeaderListView.builder( + dragStartBehavior: DragStartBehavior.down, + 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); + }); + + testWidgets('hit-testing for header overflowing sliver', (tester) async { + const centerKey = ValueKey('center'); + final controller = ScrollController(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: CustomScrollView( + controller: controller, + anchor: 0.0, + center: centerKey, + paintOrder: SliverPaintOrder.firstIsTop, + slivers: [ + SliverStickyHeaderList( + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildListDelegate( + List.generate(100, (i) => StickyHeaderItem( + allowOverflow: true, + 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( + allowOverflow: true, + header: _Header(100 + i, height: 20), + child: _Item(100 + i, height: 100))))), + ]))); + + for (double topHeight in [5, 10, 15, 20]) { + controller.jumpTo(-topHeight); + await tester.pump(); + // The top sliver occupies height [topHeight]. + // Its header overhangs by `20 - topHeight`. + + final expected = >[]; + for (int y = 1; y < 20; y++) { + await tester.tapAt(Offset(400, y.toDouble())); + expected.add((it) => it.isA<_Header>().index.equals(99)); + } + for (int y = 21; y < 40; y += 2) { + await tester.tapAt(Offset(400, y.toDouble())); + expected.add((it) => it.isA<_Item>().index.equals(100)); + } + check(_TapLogged.takeTapLog()).deepEquals(expected); + } + }); +} + +enum _SliverConfig { + single, + backToBack, + followed, } Future _checkSequence( @@ -113,6 +290,7 @@ Future _checkSequence( bool reverseHeader = false, GrowthDirection growthDirection = GrowthDirection.forward, required bool allowOverflow, + _SliverConfig sliverConfig = _SliverConfig.single, }) async { assert(textDirection != null || axis == Axis.vertical); final headerAtCoordinateEnd = switch (axis) { @@ -120,36 +298,70 @@ Future _checkSequence( Axis.vertical => reverseHeader, }; final reverseGrowth = (growthDirection == GrowthDirection.reverse); + final headerPlacement = reverseHeader ^ reverse + ? HeaderPlacement.scrollingEnd : HeaderPlacement.scrollingStart; + + Widget buildItem(int i) { + return StickyHeaderItem( + allowOverflow: allowOverflow, + header: _Header(i, height: 20), + child: _Item(i, height: 100)); + } + + const sliverScrollExtent = 1000; + const center = ValueKey("center"); + final slivers = [ + if (sliverConfig == _SliverConfig.backToBack) + SliverStickyHeaderList( + headerPlacement: headerPlacement, + delegate: SliverChildListDelegate( + List.generate(10, (i) => buildItem(-i - 1)))), + const SliverPadding( + key: center, + padding: EdgeInsets.zero), + SliverStickyHeaderList( + headerPlacement: headerPlacement, + delegate: SliverChildListDelegate( + List.generate(10, (i) => buildItem(i)))), + if (sliverConfig == _SliverConfig.followed) + SliverStickyHeaderList( + headerPlacement: headerPlacement, + delegate: SliverChildListDelegate( + List.generate(10, (i) => buildItem(i + 10)))), + ]; + + final double anchor; + if (reverseGrowth) { + slivers.reverseRange(0, slivers.length); + anchor = 1.0; + } else { + anchor = 0.0; + } + + SliverPaintOrder paintOrder = SliverPaintOrder.firstIsTop; + if (!allowOverflow || (sliverConfig == _SliverConfig.single)) { + // The paint order doesn't matter. + } else { + paintOrder = headerPlacement == HeaderPlacement.scrollingStart + ? SliverPaintOrder.firstIsTop : SliverPaintOrder.lastIsTop; + } final controller = ScrollController(); - const listKey = ValueKey("list"); - const emptyKey = ValueKey("empty"); await tester.pumpWidget(Directionality( textDirection: textDirection ?? TextDirection.rtl, child: CustomScrollView( controller: controller, scrollDirection: axis, reverse: reverse, - anchor: reverseGrowth ? 1.0 : 0.0, - center: reverseGrowth ? emptyKey : listKey, - slivers: [ - SliverStickyHeaderList( - key: listKey, - headerPlacement: (reverseHeader ^ reverse) - ? HeaderPlacement.scrollingEnd : HeaderPlacement.scrollingStart, - delegate: SliverChildListDelegate( - List.generate(100, (i) => StickyHeaderItem( - allowOverflow: allowOverflow, - header: _Header(i, height: 20), - child: _Item(i, height: 100))))), - const SliverPadding( - key: emptyKey, - padding: EdgeInsets.zero), - ]))); - - final overallSize = tester.getSize(find.byType(CustomScrollView)); + anchor: anchor, + center: center, + paintOrder: paintOrder, + slivers: slivers))); + + final overallSize = tester.getSize(find.bySubtype()); final extent = overallSize.onAxis(axis); assert(extent % 100 == 0); + assert(sliverScrollExtent - extent > 100); // A position `inset` from the center of the edge the header is found on. Offset headerInset(double inset) { @@ -160,9 +372,10 @@ Future _checkSequence( final first = !(reverse ^ reverseHeader ^ reverseGrowth); - final itemFinder = first ? find.byType(_Item).first : find.byType(_Item).last; + final itemFinder = first ? _LeastItemFinder(find.byType(_Item)) + : _GreatestItemFinder(find.byType(_Item)); - double insetExtent(Finder finder) { + double insetExtent(FinderBase finder) { return headerAtCoordinateEnd ? extent - tester.getTopLeft(finder).inDirection(axis.coordinateDirection) : tester.getBottomRight(finder).inDirection(axis.coordinateDirection); @@ -174,7 +387,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); @@ -183,33 +395,147 @@ Future _checkSequence( 100 - (first ? scrollOffset % 100 : (-scrollOffset) % 100); final double expectedHeaderInsetExtent = allowOverflow ? 20 : math.min(20, expectedItemInsetExtent); - check(insetExtent(itemFinder)).equals(expectedItemInsetExtent); + if (expectedItemInsetExtent < expectedHeaderInsetExtent) { + // TODO there's a bug here if the header isn't opaque; + // this check would exercise the bug: + // check(insetExtent(itemFinder)).equals(expectedItemInsetExtent); + // Instead, check that things will be fine if the header is opaque. + check(insetExtent(itemFinder)).isLessOrEqual(expectedHeaderInsetExtent); + } else { + check(insetExtent(itemFinder)).equals(expectedItemInsetExtent); + } check(insetExtent(find.byType(_Header))).equals(expectedHeaderInsetExtent); // Check the header gets hit when it should, and not when it shouldn't. await tester.tapAt(headerInset(1)); await tester.tapAt(headerInset(expectedHeaderInsetExtent - 1)); - check(_Header.takeTapCount()).equals(2); + check(_TapLogged.takeTapLog())..length.equals(2) + ..every((it) => it.isA<_Header>()); await tester.tapAt(headerInset(extent - 1)); await tester.tapAt(headerInset(extent - (expectedHeaderInsetExtent - 1))); - check(_Header.takeTapCount()).equals(0); + check(_TapLogged.takeTapLog())..length.equals(2) + ..every((it) => it.isA<_Item>()); } Future jumpAndCheck(double position) async { - controller.jumpTo(position * (reverseGrowth ? -1 : 1)); + final scrollPosition = position * (reverseGrowth ? -1 : 1); + controller.jumpTo(scrollPosition); await tester.pump(); await checkState(); } - await checkState(); - await jumpAndCheck(5); - await jumpAndCheck(10); - await jumpAndCheck(20); - await jumpAndCheck(50); - await jumpAndCheck(80); - await jumpAndCheck(90); - await jumpAndCheck(95); - await jumpAndCheck(100); + Future checkLocally() async { + final scrollOffset = controller.position.pixels * (reverseGrowth ? -1 : 1); + await checkState(); + await jumpAndCheck(scrollOffset + 5); + await jumpAndCheck(scrollOffset + 10); + await jumpAndCheck(scrollOffset + 20); + await jumpAndCheck(scrollOffset + 50); + await jumpAndCheck(scrollOffset + 80); + await jumpAndCheck(scrollOffset + 90); + await jumpAndCheck(scrollOffset + 95); + await jumpAndCheck(scrollOffset + 100); + } + + Iterable listExtents() { + final result = tester.renderObjectList(find.byType(SliverStickyHeaderList, skipOffstage: false)) + .map((renderObject) => (renderObject as RenderSliver) + .geometry!.layoutExtent); + return reverseGrowth ? result.toList().reversed : result; + } + + switch (sliverConfig) { + case _SliverConfig.single: + // Just check the first header, at a variety of offsets, + // and check it hands off to the next header. + await checkLocally(); + + case _SliverConfig.followed: + // Check behavior as the next sliver scrolls into view. + await jumpAndCheck(sliverScrollExtent - extent); + check(listExtents()).deepEquals([extent, 0]); + await checkLocally(); + check(listExtents()).deepEquals([extent - 100, 100]); + + // Check behavior as the original sliver scrolls out of view. + await jumpAndCheck(sliverScrollExtent - 100); + check(listExtents()).deepEquals([100, extent - 100]); + await checkLocally(); + check(listExtents()).deepEquals([0, extent]); + + case _SliverConfig.backToBack: + // Scroll the other sliver into view; + // check behavior as it scrolls back out. + await jumpAndCheck(-100); + check(listExtents()).deepEquals([100, extent - 100]); + await checkLocally(); + check(listExtents()).deepEquals([0, extent]); + + // Scroll the original sliver out of view; + // check behavior as it scrolls back in. + await jumpAndCheck(-extent); + check(listExtents()).deepEquals([extent, 0]); + await checkLocally(); + check(listExtents()).deepEquals([extent - 100, 100]); + } +} + +abstract class _SelectItemFinder extends FinderBase with ChainedFinderMixin { + bool shouldPrefer(_Item candidate, _Item previous); + + @override + Iterable filter(Iterable parentCandidates) { + Element? result; + _Item? resultWidget; + for (final candidate in parentCandidates) { + if (candidate is! ComponentElement) continue; + final widget = candidate.widget; + if (widget is! _Item) continue; + if (resultWidget == null || shouldPrefer(widget, resultWidget)) { + result = candidate; + resultWidget = widget; + } + } + return [if (result != null) result]; + } +} + +/// Finds the [_Item] with least [_Item.index] +/// out of all elements found by the given parent finder. +class _LeastItemFinder extends _SelectItemFinder { + _LeastItemFinder(this.parent); + + @override + final FinderBase parent; + + @override + String describeMatch(Plurality plurality) { + return 'least-index _Item from ${parent.describeMatch(plurality)}'; + } + + @override + bool shouldPrefer(_Item candidate, _Item previous) { + return candidate.index < previous.index; + } +} + +/// Finds the [_Item] with greatest [_Item.index] +/// out of all elements found by the given parent finder. +class _GreatestItemFinder extends _SelectItemFinder { + _GreatestItemFinder(this.parent); + + @override + final FinderBase parent; + + @override + String describeMatch(Plurality plurality) { + return 'greatest-index _Item from ${parent.describeMatch(plurality)}'; + } + + @override + bool shouldPrefer(_Item candidate, _Item previous) { + return candidate.index > previous.index; + } } Future _drag(WidgetTester tester, Offset offset) async { @@ -239,31 +565,43 @@ Iterable _itemIndexes(WidgetTester tester) { return tester.widgetList<_Item>(find.byType(_Item)).map((w) => w.index); } -class _Header extends StatelessWidget { +sealed class _TapLogged { + static List<_TapLogged> takeTapLog() { + final result = _tapLog; + _tapLog = []; + return result; + } + static List<_TapLogged> _tapLog = []; +} + +class _Header extends StatelessWidget implements _TapLogged { const _Header(this.index, {required this.height}); final int index; final double height; - static int takeTapCount() { - final result = _tapCount; - _tapCount = 0; - return result; - } - static int _tapCount = 0; - @override Widget build(BuildContext context) { return SizedBox( height: height, width: height, // TODO clean up child: GestureDetector( - onTap: () => _tapCount++, + onTap: () => _TapLogged._tapLog.add(this), child: Text("Header $index"))); } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IntProperty('index', index)); + } +} + +extension _HeaderChecks on Subject<_Header> { + Subject get index => has((x) => x.index, 'index'); } -class _Item extends StatelessWidget { +class _Item extends StatelessWidget implements _TapLogged { const _Item(this.index, {required this.height}); final int index; @@ -274,6 +612,41 @@ class _Item extends StatelessWidget { return SizedBox( height: height, width: height, - child: Text("Item $index")); + child: GestureDetector( + onTap: () => _TapLogged._tapLog.add(this), + child: Text("Item $index"))); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IntProperty('index', index)); + } +} + +extension _ItemChecks on Subject<_Item> { + Subject get index => has((x) => x.index, 'index'); +} + +/// Sets [DeviceGestureSettings.touchSlop] for the child subtree +/// to the given value, by inserting a [MediaQuery]. +/// +/// For example `TouchSlop(touchSlop: 1, …)` means a touch that moves by even +/// a single pixel will be interpreted as a drag, even if a tap gesture handler +/// is competing for the gesture. For human fingers that'd make it unreasonably +/// difficult to make a tap, but in a test carried out by software it can be +/// convenient for making small drag gestures straightforward. +class TouchSlop extends StatelessWidget { + const TouchSlop({super.key, required this.touchSlop, required this.child}); + + final double touchSlop; + final Widget child; + + @override + Widget build(BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + gestureSettings: DeviceGestureSettings(touchSlop: touchSlop)), + child: child); } } diff --git a/test/widgets/store_checks.dart b/test/widgets/store_checks.dart deleted file mode 100644 index 9754654556..0000000000 --- a/test/widgets/store_checks.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:flutter/widgets.dart'; -import 'package:zulip/widgets/store.dart'; - -extension PerAccountStoreWidgetChecks on Subject { - Subject get accountId => has((x) => x.accountId, 'accountId'); - Subject get child => has((x) => x.child, 'child'); -} diff --git a/test/widgets/store_test.dart b/test/widgets/store_test.dart index e2d7821ae3..fe3ade165d 100644 --- a/test/widgets/store_test.dart +++ b/test/widgets/store_test.dart @@ -1,23 +1,33 @@ +import 'dart:async'; + 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/model/actions.dart'; +import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/inbox.dart'; +import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../example_data.dart' as eg; import '../model/store_checks.dart'; +import '../model/test_store.dart'; +import '../test_navigation.dart'; /// A widget whose state uses [PerAccountStoreAwareStateMixin]. class MyWidgetWithMixin extends StatefulWidget { const MyWidgetWithMixin({super.key}); @override - State createState() => MyWidgetWithMixinState(); + State createState() => _MyWidgetWithMixinState(); } -class MyWidgetWithMixinState extends State with PerAccountStoreAwareStateMixin { +class _MyWidgetWithMixinState extends State with PerAccountStoreAwareStateMixin { int anyDepChangeCounter = 0; int storeChangeCounter = 0; @@ -40,7 +50,7 @@ class MyWidgetWithMixinState extends State with PerAccountSto } } -extension MyWidgetWithMixinStateChecks on Subject { +extension _MyWidgetWithMixinStateChecks on Subject<_MyWidgetWithMixinState> { Subject get anyDepChangeCounter => has((w) => w.anyDepChangeCounter, 'anyDepChangeCounter'); Subject get storeChangeCounter => has((w) => w.storeChangeCounter, 'storeChangeCounter'); } @@ -48,7 +58,7 @@ extension MyWidgetWithMixinStateChecks on Subject { void main() { TestZulipBinding.ensureInitialized(); - testWidgets('GlobalStoreWidget', (tester) async { + testWidgets('GlobalStoreWidget loads data while showing placeholder', (tester) async { addTearDown(testBinding.reset); GlobalStore? globalStore; @@ -60,12 +70,12 @@ void main() { return const SizedBox.shrink(); }))); // First, shows a loading page instead of child. - check(tester.any(find.byType(CircularProgressIndicator))).isTrue(); + check(find.byType(BlankLoadingPlaceholder)).findsOne(); check(globalStore).isNull(); await tester.pump(); // Then after loading, mounts child instead, with provided store. - check(tester.any(find.byType(CircularProgressIndicator))).isFalse(); + check(find.byType(BlankLoadingPlaceholder)).findsNothing(); check(globalStore).identicalTo(testBinding.globalStore); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); @@ -74,6 +84,97 @@ void main() { .equals((accountId: eg.selfAccount.id, account: eg.selfAccount)); }); + testWidgets('GlobalStoreWidget awaits blockingFuture', (tester) async { + addTearDown(testBinding.reset); + + final completer = Completer(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: GlobalStoreWidget( + blockingFuture: completer.future, + child: Text('done')))); + + await tester.pump(); + await tester.pump(); + await tester.pump(); + // Even after the store must have loaded, + // still shows loading page while blockingFuture is pending. + check(find.byType(BlankLoadingPlaceholder)).findsOne(); + check(find.text('done')).findsNothing(); + + // Once blockingFuture completes… + completer.complete(); + await tester.pump(); + await tester.pump(); // TODO why does GlobalStoreWidget need this extra frame? + // … mounts child instead of the loading page. + check(find.byType(BlankLoadingPlaceholder)).findsNothing(); + check(find.text('done')).findsOne(); + }); + + testWidgets('GlobalStoreWidget handles failed blockingFuture like success', (tester) async { + addTearDown(testBinding.reset); + + final completer = Completer(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: GlobalStoreWidget( + blockingFuture: completer.future, + child: Text('done')))); + + await tester.pump(); + await tester.pump(); + await tester.pump(); + // Even after the store must have loaded, + // still shows loading page while blockingFuture is pending. + check(find.byType(BlankLoadingPlaceholder)).findsOne(); + check(find.text('done')).findsNothing(); + + // Once blockingFuture completes, even with an error… + completer.completeError(Exception('oops')); + await tester.pump(); + await tester.pump(); // TODO why does GlobalStoreWidget need this extra frame? + // … mounts child instead of the loading page. + check(find.byType(BlankLoadingPlaceholder)).findsNothing(); + check(find.text('done')).findsOne(); + }); + + testWidgets('GlobalStoreWidget.of updates dependents', (tester) async { + addTearDown(testBinding.reset); + + List? accountIds; + await tester.pumpWidget( + Directionality(textDirection: TextDirection.ltr, + child: GlobalStoreWidget( + child: Builder(builder: (context) { + accountIds = GlobalStoreWidget.of(context).accountIds.toList(); + return SizedBox.shrink(); + })))); + await tester.pump(); + check(accountIds).isNotNull().isEmpty(); + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await tester.pump(); + check(accountIds).isNotNull().deepEquals([eg.selfAccount.id]); + }); + + testWidgets('GlobalStoreWidget.settingsOf updates on settings update', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.settings.setThemeSetting(ThemeSetting.dark); + + ThemeSetting? themeSetting; + await tester.pumpWidget( + GlobalStoreWidget( + child: Builder( + builder: (context) { + themeSetting = GlobalStoreWidget.settingsOf(context).themeSetting; + return const SizedBox.shrink(); + }))); + await tester.pump(); + check(themeSetting).equals(ThemeSetting.dark); + + await testBinding.globalStore.settings.setThemeSetting(ThemeSetting.light); + await tester.pump(); + check(themeSetting).equals(ThemeSetting.light); + }); + testWidgets('PerAccountStoreWidget basic', (tester) async { await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); addTearDown(testBinding.reset); @@ -167,8 +268,77 @@ void main() { tester.widget(find.text('found store, account: ${eg.selfAccount.id}')); }); + testWidgets("PerAccountStoreWidget.routeToRemoveOnLogout logged-out account's routes removed from nav; other accounts' remain", (tester) async { + Future makeUnreadTopicInInbox(int accountId, String topic) async { + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream, topic: topic); + final store = await testBinding.globalStore.perAccount(accountId); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await store.addMessage(message); + await tester.pump(); + } + + addTearDown(testBinding.reset); + + final user1 = eg.user(); + final user2 = eg.user(); + final account1 = eg.account(id: 1, user: user1); + final account2 = eg.account(id: 2, user: user2); + await testBinding.globalStore.add(account1, eg.initialSnapshot( + realmUsers: [user1])); + await testBinding.globalStore.add(account2, eg.initialSnapshot( + realmUsers: [user2])); + + final testNavObserver = TestNavigatorObserver(); + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + await tester.pump(); + final navigator = await ZulipApp.navigator; + navigator.popUntil((_) => false); // clear starting routes + await tester.pumpAndSettle(); + + final pushedRoutes = >[]; + testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route); + // TODO: switch to a realistic setup: + // https://github.com/zulip/zulip-flutter/pull/1076#discussion_r1874124363 + final account1Route = MaterialAccountWidgetRoute( + accountId: account1.id, page: const InboxPageBody()); + final account2Route = MaterialAccountWidgetRoute( + accountId: account2.id, page: const InboxPageBody()); + unawaited(navigator.push(account1Route)); + unawaited(navigator.push(account2Route)); + await tester.pumpAndSettle(); + check(pushedRoutes).deepEquals([account1Route, account2Route]); + + await makeUnreadTopicInInbox(account1.id, 'topic in account1'); + final findAccount1PageContent = find.text('topic in account1', skipOffstage: false); + + await makeUnreadTopicInInbox(account2.id, 'topic in account2'); + final findAccount2PageContent = find.text('topic in account2', skipOffstage: false); + + final findLoadingPage = find.byType(LoadingPlaceholderPage, skipOffstage: false); + + check(findAccount1PageContent).findsOne(); + check(findLoadingPage).findsNothing(); + + final removedRoutes = >[]; + testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route); + + final future = logOutAccount(testBinding.globalStore, account1.id); + await tester.pump(TestGlobalStore.removeAccountDuration); + await future; + check(removedRoutes).single.identicalTo(account1Route); + check(findAccount1PageContent).findsNothing(); + check(findLoadingPage).findsOne(); + + await tester.pump(); + check(findAccount1PageContent).findsNothing(); + check(findLoadingPage).findsNothing(); + check(findAccount2PageContent).findsOne(); + }); + testWidgets('PerAccountStoreAwareStateMixin', (tester) async { - final widgetWithMixinKey = GlobalKey(); + final widgetWithMixinKey = GlobalKey<_MyWidgetWithMixinState>(); final accountId = eg.selfAccount.id; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); @@ -208,7 +378,8 @@ void main() { // production code, where we could reasonably add an assert against it. // If forced, we could let this test code proceed despite such an assert…) // hack; the snapshot probably corresponds to selfAccount, not otherAccount. - await testBinding.globalStore.add(eg.otherAccount, eg.initialSnapshot()); + await testBinding.globalStore.add(eg.otherAccount, eg.initialSnapshot( + realmUsers: [eg.otherUser])); await pumpWithParams(light: false, accountId: eg.otherAccount.id); // Nudge PerAccountStoreWidget to send its updated store to MyWidgetWithMixin. // diff --git a/test/widgets/subscription_list_test.dart b/test/widgets/subscription_list_test.dart index a3fc13dac9..57e8af8e29 100644 --- a/test/widgets/subscription_list_test.dart +++ b/test/widgets/subscription_list_test.dart @@ -57,11 +57,12 @@ void main() { return find.byType(SubscriptionItem).evaluate().length; } - testWidgets('smoke', (tester) async { + testWidgets('empty', (tester) async { await setupStreamListPage(tester, subscriptions: []); check(getItemCount()).equals(0); check(isPinnedHeaderInTree()).isFalse(); check(isUnpinnedHeaderInTree()).isFalse(); + check(find.text('You are not subscribed to any channels yet.')).findsOne(); }); testWidgets('basic subscriptions', (tester) async { diff --git a/test/widgets/test_app.dart b/test/widgets/test_app.dart index 4c76fbb2d8..5cec418cdd 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'; @@ -72,14 +73,16 @@ class TestZulipApp extends StatelessWidget { title: 'Zulip', localizationsDelegates: ZulipLocalizations.localizationsDelegates, supportedLocales: ZulipLocalizations.supportedLocales, + // The context has to be taken from the [Builder] because + // [zulipThemeData] requires access to [GlobalStoreWidget] in the tree. theme: zulipThemeData(context), 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/test/widgets/text_test.dart b/test/widgets/text_test.dart index 158fe4bc96..030b78ef59 100644 --- a/test/widgets/text_test.dart +++ b/test/widgets/text_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/widgets/text.dart'; @@ -418,4 +419,53 @@ void main() { // "und" is a special language code meaning undefined; see [Locale] testLocalizedTextBaseline(const Locale('und'), TextBaseline.alphabetic); }); + + group('TextWithLink', () { + testWidgets('responds correctly to taps', (tester) async { + int calls = 0; + addTearDown(testBinding.reset); + await tester.pumpWidget(TestZulipApp( + child: Center( + child: TextWithLink(onTap: () => calls++, + markup: 'asd fgh jkl')))); + await tester.pump(); + + final findText = find.text('asd fgh jkl', findRichText: true); + final center = tester.getCenter(findText); + final width = tester.getSize(findText).width; + + // No response to tapping the words not in the link. + await tester.tapAt(center + Offset(-0.3 * width, 0)); + check(calls).equals(0); + await tester.tapAt(center + Offset(0.3 * width, 0)); + check(calls).equals(0); + + // Tapping the word in the link calls the callback. + await tester.tapAt(center); + check(calls).equals(1); + await tester.tapAt(center); + check(calls).equals(2); + }); + + testWidgets('rejects extra tags', (tester) async { + final markup = 'spuriousmarkup'; + final plainText = 'spuriousmarkup'; + + int calls = 0; + addTearDown(testBinding.reset); + await tester.pumpWidget(TestZulipApp( + child: Center( + child: TextWithLink(onTap: () => calls++, + markup: markup)))); + await tester.pump(); + + // The widget appears with the markup string as plain text. + check(find.text(plainText, findRichText: true)).findsNothing(); + check(find.text(markup)).findsOne(); + + // Nothing happens on tapping it. + await tester.tap(find.text(markup)); + check(calls).equals(0); + }); + }); } diff --git a/test/widgets/theme_test.dart b/test/widgets/theme_test.dart index 88cad71d0d..678736767d 100644 --- a/test/widgets/theme_test.dart +++ b/test/widgets/theme_test.dart @@ -1,7 +1,11 @@ +import 'dart:ui'; + import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/settings.dart'; import 'package:zulip/widgets/channel_colors.dart'; import 'package:zulip/widgets/text.dart'; import 'package:zulip/widgets/theme.dart'; @@ -9,7 +13,7 @@ import 'package:zulip/widgets/theme.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; -import 'colors_checks.dart'; +import '../model/store_checks.dart'; import 'test_app.dart'; void main() { @@ -55,10 +59,14 @@ void main() { // IconButton can't have text; skip doCheck('MenuItemButton', - button: MenuItemButton(onPressed: () {}, child: const Text(buttonText))); + button: Semantics( + role: SemanticsRole.menu, + child: MenuItemButton(onPressed: () {}, child: const Text(buttonText)))); doCheck('SubmenuButton', - button: const SubmenuButton(menuChildren: [], child: Text(buttonText))); + button: Semantics( + role: SemanticsRole.menu, + child: const SubmenuButton(menuChildren: [], child: Text(buttonText)))); doCheck('OutlinedButton', button: OutlinedButton(onPressed: () {}, child: const Text(buttonText))); @@ -99,6 +107,44 @@ void main() { }); }); + testWidgets('when globalSettings.themeSetting is null, follow system setting', (tester) async { + addTearDown(testBinding.reset); + + tester.platformDispatcher.platformBrightnessTestValue = Brightness.light; + addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); + + await tester.pumpWidget(const TestZulipApp(child: Placeholder())); + await tester.pump(); + check(testBinding.globalStore).settings.themeSetting.isNull(); + + final element = tester.element(find.byType(Placeholder)); + check(zulipThemeData(element)).brightness.equals(Brightness.light); + + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + await tester.pump(); + check(zulipThemeData(element)).brightness.equals(Brightness.dark); + }); + + testWidgets('when globalSettings.themeSetting is non-null, override system setting', (tester) async { + addTearDown(testBinding.reset); + + tester.platformDispatcher.platformBrightnessTestValue = Brightness.light; + addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); + + await tester.pumpWidget(const TestZulipApp(child: Placeholder())); + await tester.pump(); + check(testBinding.globalStore).settings.themeSetting.isNull(); + + final element = tester.element(find.byType(Placeholder)); + check(zulipThemeData(element)).brightness.equals(Brightness.light); + + await testBinding.globalStore.settings.setThemeSetting(ThemeSetting.dark); + check(zulipThemeData(element)).brightness.equals(Brightness.dark); + + await testBinding.globalStore.settings.setThemeSetting(null); + check(zulipThemeData(element)).brightness.equals(Brightness.light); + }); + group('colorSwatchFor', () { const baseColor = 0xff76ce90; @@ -132,5 +178,13 @@ void main() { check(colorSwatchFor(element, subscription)) .isSameColorSwatchAs(ChannelColorSwatch.dark(baseColor)); }); + + testWidgets('fallback to default base color when no subscription', (tester) async { + await tester.pumpWidget(const TestZulipApp()); + await tester.pump(); + final element = tester.element(find.byType(Placeholder)); + check(colorSwatchFor(element, null)).isSameColorSwatchAs( + ChannelColorSwatch.light(kDefaultChannelColorSwatchBaseColor)); + }); }); } diff --git a/test/widgets/topic_list_test.dart b/test/widgets/topic_list_test.dart new file mode 100644 index 0000000000..1cd9c4bb26 --- /dev/null +++ b/test/widgets/topic_list_test.dart @@ -0,0 +1,330 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/channels.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/topic_list.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../stdlib_checks.dart'; +import 'test_app.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + late PerAccountStore store; + late FakeApiConnection connection; + + Future prepare(WidgetTester tester, { + ZulipStream? channel, + List? topics, + List userTopics = const [], + List? messages, + }) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + + await store.addUser(eg.selfUser); + channel ??= eg.stream(); + await store.addStream(channel); + await store.addSubscription(eg.subscription(channel)); + for (final userTopic in userTopics) { + await store.setUserTopic( + channel, userTopic.topicName.apiName, userTopic.visibilityPolicy); + } + topics ??= [eg.getStreamTopicsEntry()]; + messages ??= [eg.streamMessage(stream: channel, topic: topics.first.name.apiName)]; + await store.addMessages(messages); + + connection.prepare(json: GetStreamTopicsResult(topics: topics).toJson()); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: TopicListPage(streamId: channel.streamId))); + await tester.pump(); + await tester.pump(Duration.zero); + check(connection.takeRequests()).single.isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/users/me/${channel.streamId}/topics') + ..url.queryParameters.deepEquals({'allow_empty_topic_name': 'true'}); + } + + group('app bar', () { + testWidgets('unknown channel name', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final channel = eg.stream(); + + (store.connection as FakeApiConnection).prepare( + json: GetStreamTopicsResult(topics: []).toJson()); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: TopicListPage(streamId: channel.streamId))); + await tester.pump(); + await tester.pump(Duration.zero); + check(find.widgetWithText(ZulipAppBar, '(unknown channel)')).findsOne(); + }); + + testWidgets('navigate to channel feed', (tester) async { + final channel = eg.stream(name: 'channel foo'); + await prepare(tester, channel: channel); + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [eg.streamMessage(stream: channel)]).toJson()); + await tester.tap(find.byIcon(ZulipIcons.message_feed)); + await tester.pump(); + await tester.pump(Duration.zero); + check(find.descendant( + of: find.byType(MessageListPage), + matching: find.text('channel foo')), + ).findsOne(); + }); + + testWidgets('show channel action sheet', (tester) async { + final channel = eg.stream(name: 'channel foo'); + await prepare(tester, channel: channel, + messages: [eg.streamMessage(stream: channel)]); + + await tester.longPress(find.text('channel foo')); + await tester.pump(Duration(milliseconds: 100)); // bottom-sheet animation + check(find.text('Mark channel as read')).findsOne(); + }); + }); + + testWidgets('show loading indicator', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final channel = eg.stream(); + + (store.connection as FakeApiConnection).prepare( + json: GetStreamTopicsResult(topics: []).toJson(), + delay: Duration(seconds: 1), + ); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: TopicListPage(streamId: channel.streamId))); + await tester.pump(); + check(find.byType(CircularProgressIndicator)).findsOne(); + + await tester.pump(Duration(seconds: 1)); + check(find.byType(CircularProgressIndicator)).findsNothing(); + }); + + testWidgets('fetch again when navigating away and back', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final connection = store.connection as FakeApiConnection; + final channel = eg.stream(); + + // Start from a message list page in a channel narrow. + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: []).toJson()); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: MessageListPage(initNarrow: ChannelNarrow(channel.streamId)))); + await tester.pump(); + + // Tap "TOPICS" button navigating to the topic-list page… + connection.prepare(json: GetStreamTopicsResult( + topics: [eg.getStreamTopicsEntry(name: 'topic A')]).toJson()); + await tester.tap(find.byIcon(ZulipIcons.topics)); + await tester.pump(); + await tester.pump(Duration.zero); + check(find.text('topic A')).findsOne(); + + // … go back to the message list page… + await tester.pageBack(); + await tester.pump(); + + // … then back to the topic-list page, expecting to fetch again. + connection.prepare(json: GetStreamTopicsResult( + topics: [eg.getStreamTopicsEntry(name: 'topic B')]).toJson()); + await tester.tap(find.byIcon(ZulipIcons.topics)); + await tester.pump(); + await tester.pump(Duration.zero); + check(find.text('topic A')).findsNothing(); + check(find.text('topic B')).findsOne(); + }); + + Finder topicItemFinder = find.descendant( + of: find.byType(ListView), + matching: find.byType(Material)); + + Finder findInTopicItemAt(int index, Finder finder) => find.descendant( + of: topicItemFinder.at(index), + matching: finder); + + testWidgets('show topic action sheet', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic foo')]); + await tester.longPress(topicItemFinder); + await tester.pump(Duration(milliseconds: 150)); // bottom-sheet animation + + connection.prepare(json: {}); + await tester.tap(find.text('Mute topic')); + await tester.pump(); + await tester.pump(Duration.zero); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/user_topics') + ..bodyFields.deepEquals({ + 'stream_id': channel.streamId.toString(), + 'topic': 'topic foo', + 'visibility_policy': UserTopicVisibilityPolicy.muted.apiValue.toString(), + }); + }); + + testWidgets('sort topics by maxId', (tester) async { + await prepare(tester, topics: [ + eg.getStreamTopicsEntry(name: 'A', maxId: 3), + eg.getStreamTopicsEntry(name: 'B', maxId: 2), + eg.getStreamTopicsEntry(name: 'C', maxId: 4), + ]); + + check(findInTopicItemAt(0, find.text('C'))).findsOne(); + check(findInTopicItemAt(1, find.text('A'))).findsOne(); + check(findInTopicItemAt(2, find.text('B'))).findsOne(); + }); + + testWidgets('resolved and unresolved topics', (tester) async { + final resolvedTopic = TopicName('resolved').resolve(); + final unresolvedTopic = TopicName('unresolved'); + await prepare(tester, topics: [ + eg.getStreamTopicsEntry(maxId: 2, name: resolvedTopic.apiName), + eg.getStreamTopicsEntry(maxId: 1, name: unresolvedTopic.apiName), + ]); + + assert(resolvedTopic.displayName == '✔ resolved', resolvedTopic.displayName); + check(findInTopicItemAt(0, find.text('✔ resolved'))).findsNothing(); + + check(findInTopicItemAt(0, find.text('resolved'))).findsOne(); + check(findInTopicItemAt(0, find.byIcon(ZulipIcons.check).hitTestable())) + .findsOne(); + + check(findInTopicItemAt(1, find.text('unresolved'))).findsOne(); + check(findInTopicItemAt(1, find.byType(Icon)).hitTestable()) + .findsNothing(); + }); + + testWidgets('handle empty topics', (tester) async { + await prepare(tester, topics: [ + eg.getStreamTopicsEntry(name: ''), + ]); + check(findInTopicItemAt(0, + find.text(eg.defaultRealmEmptyTopicDisplayName))).findsOne(); + }); + + group('unreads', () { + testWidgets('muted and non-muted topics', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [ + eg.getStreamTopicsEntry(maxId: 2, name: 'muted'), + eg.getStreamTopicsEntry(maxId: 1, name: 'non-muted'), + ], + userTopics: [ + eg.userTopicItem(channel, 'muted', UserTopicVisibilityPolicy.muted), + ], + messages: [ + eg.streamMessage(stream: channel, topic: 'muted'), + eg.streamMessage(stream: channel, topic: 'non-muted'), + eg.streamMessage(stream: channel, topic: 'non-muted'), + ]); + + check(findInTopicItemAt(0, find.text('1'))).findsOne(); + check(findInTopicItemAt(0, find.text('muted'))).findsOne(); + check(findInTopicItemAt(0, find.byIcon(ZulipIcons.mute).hitTestable())) + .findsOne(); + + check(findInTopicItemAt(1, find.text('2'))).findsOne(); + check(findInTopicItemAt(1, find.text('non-muted'))).findsOne(); + check(findInTopicItemAt(1, find.byType(Icon).hitTestable())) + .findsNothing(); + }); + + testWidgets('with and without unread mentions', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [ + eg.getStreamTopicsEntry(maxId: 2, name: 'not mentioned'), + eg.getStreamTopicsEntry(maxId: 1, name: 'mentioned'), + ], + messages: [ + eg.streamMessage(stream: channel, topic: 'not mentioned'), + eg.streamMessage(stream: channel, topic: 'not mentioned'), + eg.streamMessage(stream: channel, topic: 'not mentioned', + flags: [MessageFlag.mentioned, MessageFlag.read]), + eg.streamMessage(stream: channel, topic: 'mentioned', + flags: [MessageFlag.mentioned]), + ]); + + check(findInTopicItemAt(0, find.text('2'))).findsOne(); + check(findInTopicItemAt(0, find.text('not mentioned'))).findsOne(); + check(findInTopicItemAt(0, find.byType(Icons))).findsNothing(); + + check(findInTopicItemAt(1, find.text('1'))).findsOne(); + check(findInTopicItemAt(1, find.text('mentioned'))).findsOne(); + check(findInTopicItemAt(1, find.byIcon(ZulipIcons.at_sign))).findsOne(); + }); + }); + + group('topic visibility', () { + testWidgets('default', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic')]); + + check(find.descendant(of: topicItemFinder, + matching: find.byType(Icons))).findsNothing(); + }); + + testWidgets('muted', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic')], + userTopics: [ + eg.userTopicItem(channel, 'topic', UserTopicVisibilityPolicy.muted), + ]); + check(find.descendant(of: topicItemFinder, + matching: find.byIcon(ZulipIcons.mute))).findsOne(); + }); + + testWidgets('unmuted', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic')], + userTopics: [ + eg.userTopicItem(channel, 'topic', UserTopicVisibilityPolicy.unmuted), + ]); + check(find.descendant(of: topicItemFinder, + matching: find.byIcon(ZulipIcons.unmute))).findsOne(); + }); + + testWidgets('followed', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic')], + userTopics: [ + eg.userTopicItem(channel, 'topic', UserTopicVisibilityPolicy.followed), + ]); + check(find.descendant(of: topicItemFinder, + matching: find.byIcon(ZulipIcons.follow))).findsOne(); + }); + }); +} diff --git a/test/widgets/unread_count_badge_checks.dart b/test/widgets/unread_count_badge_checks.dart deleted file mode 100644 index dcd3f99d74..0000000000 --- a/test/widgets/unread_count_badge_checks.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'dart:ui'; - -import 'package:checks/checks.dart'; -import 'package:zulip/widgets/unread_count_badge.dart'; - -extension UnreadCountBadgeChecks on Subject { - Subject get count => has((b) => b.count, 'count'); - Subject get bold => has((b) => b.bold, 'bold'); - Subject get backgroundColor => has((b) => b.backgroundColor, 'backgroundColor'); -} diff --git a/test/widgets/user_test.dart b/test/widgets/user_test.dart new file mode 100644 index 0000000000..5078da0497 --- /dev/null +++ b/test/widgets/user_test.dart @@ -0,0 +1,82 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/store.dart'; +import 'package:zulip/widgets/user.dart'; + +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../stdlib_checks.dart'; +import '../test_images.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + group('AvatarImage', () { + late PerAccountStore store; + + Future actualUrl(WidgetTester tester, String avatarUrl, [double? size]) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final user = eg.user(avatarUrl: avatarUrl); + await store.addUser(user); + + prepareBoringImageHttpClient(); + await tester.pumpWidget(GlobalStoreWidget( + child: PerAccountStoreWidget(accountId: eg.selfAccount.id, + child: AvatarImage(userId: user.userId, size: size ?? 30)))); + await tester.pump(); + await tester.pump(); + tester.widget(find.byType(AvatarImage)); + final widgets = tester.widgetList( + find.byType(RealmContentNetworkImage)); + return widgets.firstOrNull?.src; + } + + testWidgets('smoke with absolute URL', (tester) async { + const avatarUrl = 'https://example/avatar.png'; + check(await actualUrl(tester, avatarUrl)).isNotNull() + .asString.equals(avatarUrl); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('smoke with relative URL', (tester) async { + const avatarUrl = '/avatar.png'; + check(await actualUrl(tester, avatarUrl)) + .equals(store.tryResolveUrl(avatarUrl)!); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('absolute URL, larger size', (tester) async { + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.resetDevicePixelRatio); + + const avatarUrl = 'https://example/avatar.png'; + check(await actualUrl(tester, avatarUrl, 50)).isNotNull() + .asString.equals(avatarUrl.replaceAll('.png', '-medium.png')); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('relative URL, larger size', (tester) async { + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.resetDevicePixelRatio); + + const avatarUrl = '/avatar.png'; + check(await actualUrl(tester, avatarUrl, 50)) + .equals(store.tryResolveUrl('/avatar-medium.png')!); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('smoke with invalid URL', (tester) async { + const avatarUrl = '::not a URL::'; + check(await actualUrl(tester, avatarUrl)).isNull(); + debugNetworkImageHttpClientProvider = null; + }); + }); +} diff --git a/tools/check b/tools/check index fefcd514ed..5d25abc87f 100755 --- a/tools/check +++ b/tools/check @@ -18,6 +18,9 @@ set -euo pipefail this_dir=${BASH_SOURCE[0]%/*} +# shellcheck source=tools/lib/ensure-coreutils.sh +. "${this_dir}"/lib/ensure-coreutils.sh + # shellcheck source=tools/lib/git.sh . "${this_dir}"/lib/git.sh @@ -258,7 +261,7 @@ run_flutter_version() { # If we find cases where it fails, we can study # how the `flutter` tool actually decides the version name. <^(\d+\.\d+\.\d+-) (\d+) (\.\d+\.pre) -(\d+) -g[0-9a-f]+$> - <$1 . ($2 + 1) . $3 . "." . $4>xe)' + <$1 . ($2 + 1) . $3 . "-" . $4>xe)' ) || return if [ -z "${predicted_version}" ]; then cat >&2 <&2 <&2 < @@ -50,7 +55,7 @@ opt_verbose= opt_steps=() while (( $# )); do case "$1" in - fetch|check) opt_steps+=("$1"); shift;; + fetch|check|katex-check) opt_steps+=("$1"); shift;; --config) shift; opt_zuliprc="$1"; shift;; --verbose) opt_verbose=1; shift;; --help) usage; exit 0;; @@ -98,11 +103,19 @@ run_check() { || return 1 } +run_katex_check() { + flutter test tools/content/unimplemented_katex_test.dart \ + --dart-define=corpusDir="$opt_corpus_dir" \ + --dart-define=verbose="$opt_verbose" \ + || return 1 +} + for step in "${opt_steps[@]}"; do echo "Running ${step}" case "${step}" in fetch) run_fetch ;; check) run_check ;; + katex-check) run_katex_check ;; *) echo >&2 "Internal error: unknown step ${step}" ;; esac done diff --git a/tools/content/unimplemented_features_test.dart b/tools/content/unimplemented_features_test.dart index 5ef4a7493b..446cb962dd 100644 --- a/tools/content/unimplemented_features_test.dart +++ b/tools/content/unimplemented_features_test.dart @@ -12,6 +12,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/model/content.dart'; +import '../../test/model/binding.dart'; import 'model.dart'; /// Check if there are unimplemented features from the given corpora of HTML @@ -33,6 +34,11 @@ import 'model.dart'; /// * lib/model/content.dart, which implements of the content parser. /// * tools/content/fetch_messages.dart, which produces the corpora. void main() async { + // Parsing the HTML content depends on `ZulipBinding` being initialized, + // specifically KaTeX content parser retrieves the `GlobalSettings` to + // for the experimental flag. + TestZulipBinding.ensureInitialized(); + Future checkForUnimplementedFeaturesInFile(File file) async { final messageIdsByFeature = >{}; final contentsByFeature = >{}; diff --git a/tools/content/unimplemented_katex_test.dart b/tools/content/unimplemented_katex_test.dart new file mode 100644 index 0000000000..048499998c --- /dev/null +++ b/tools/content/unimplemented_katex_test.dart @@ -0,0 +1,178 @@ +// Override `flutter test`'s default timeout +@Timeout(Duration(minutes: 10)) +library; + +import 'dart:io'; +import 'dart:math'; + +import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/content.dart'; + +import '../../test/model/binding.dart'; +import 'model.dart'; + +void main() async { + TestZulipBinding.ensureInitialized(); + + Future checkForKatexFailuresInFile(File file) async { + int totalMessageCount = 0; + final Set katexMessageIds = {}; + final Set failedKatexMessageIds = {}; + int totalMathBlockNodes = 0; + int failedMathBlockNodes = 0; + int totalMathInlineNodes = 0; + int failedMathInlineNodes = 0; + + final failedMessageIdsByReason = >{}; + final failedMathNodesByReason = >{}; + + void walk(int messageId, DiagnosticsNode node) { + final value = node.value; + if (value is UnimplementedNode) return; + + for (final child in node.getChildren()) { + walk(messageId, child); + } + + if (value is! MathNode) return; + katexMessageIds.add(messageId); + switch (value) { + case MathBlockNode(): totalMathBlockNodes++; + case MathInlineNode(): totalMathInlineNodes++; + } + + if (value.nodes != null) return; + failedKatexMessageIds.add(messageId); + switch (value) { + case MathBlockNode(): failedMathBlockNodes++; + case MathInlineNode(): failedMathInlineNodes++; + } + + final hardFailReason = value.debugHardFailReason; + final softFailReason = value.debugSoftFailReason; + int failureCount = 0; + + if (hardFailReason != null) { + final message = hardFailReason.message + ?? 'unknown reason at ${_inmostFrame(hardFailReason.stackTrace)}'; + final reason = 'hard fail: $message'; + (failedMessageIdsByReason[reason] ??= {}).add(messageId); + (failedMathNodesByReason[reason] ??= {}).add(value); + failureCount++; + } + + if (softFailReason != null) { + for (final cssClass in softFailReason.unsupportedCssClasses) { + final reason = 'unsupported css class: $cssClass'; + (failedMessageIdsByReason[reason] ??= {}).add(messageId); + (failedMathNodesByReason[reason] ??= {}).add(value); + failureCount++; + } + for (final cssProp in softFailReason.unsupportedInlineCssProperties) { + final reason = 'unsupported inline css property: $cssProp'; + (failedMessageIdsByReason[reason] ??= {}).add(messageId); + (failedMathNodesByReason[reason] ??= {}).add(value); + failureCount++; + } + } + + if (failureCount == 0) { + final reason = 'unknown'; + (failedMessageIdsByReason[reason] ??= {}).add(messageId); + (failedMathNodesByReason[reason] ??= {}).add(value); + } + } + + await for (final message in readMessagesFromJsonl(file)) { + totalMessageCount++; + walk(message.id, parseContent(message.content).toDiagnosticsNode()); + } + + final buf = StringBuffer(); + buf.writeln(); + buf.writeln('Out of $totalMessageCount total messages,' + ' ${katexMessageIds.length} of them were KaTeX containing messages' + ' and ${failedKatexMessageIds.length} of those failed.'); + buf.writeln('There were $totalMathBlockNodes math block nodes out of which $failedMathBlockNodes failed.'); + buf.writeln('There were $totalMathInlineNodes math inline nodes out of which $failedMathInlineNodes failed.'); + buf.writeln(); + + for (final MapEntry(key: reason, value: messageIds) + in failedMessageIdsByReason.entries.sorted((a, b) { + // Sort by number of failed messages descending, then by reason. + final r = - a.value.length.compareTo(b.value.length); + if (r != 0) return r; + return a.key.compareTo(b.key); + })) { + final failedMathNodes = failedMathNodesByReason[reason]!.toList(); + failedMathNodes.shuffle(); + final oldestId = messageIds.reduce(min); + final newestId = messageIds.reduce(max); + + buf.writeln('Because of $reason:'); + buf.writeln(' ${messageIds.length} messages failed.'); + buf.writeln(' Oldest message: $oldestId, Newest message: $newestId'); + if (!_verbose) { + buf.writeln(); + continue; + } + + buf.writeln(' Message IDs (up to 100): ${messageIds.take(100).join(', ')}'); + buf.writeln(' TeX source (up to 30):'); + for (final node in failedMathNodes.take(30)) { + switch (node) { + case MathBlockNode(): + buf.writeln(' ```math'); + for (final line in node.texSource.split('\n')) { + buf.writeln(' $line'); + } + buf.writeln(' ```'); + case MathInlineNode(): + buf.writeln(' \$\$ ${node.texSource} \$\$'); + } + } + buf.writeln(' HTML (up to 3):'); + for (final node in failedMathNodes.take(3)) { + buf.writeln(' ${node.debugHtmlText}'); + } + buf.writeln(); + } + + check(failedKatexMessageIds.length, because: buf.toString()).equals(0); + } + + final corpusFiles = _getCorpusFiles(); + + if (corpusFiles.isEmpty) { + throw Exception('No corpus found in directory "$_corpusDirPath" to check' + ' for katex failures.'); + } + + group('Check for katex failures in', () { + for (final file in corpusFiles) { + test(file.path, () => checkForKatexFailuresInFile(file)); + } + }); +} + +/// The innermost frame of the given stack trace, +/// e.g. the line where an exception was thrown. +/// +/// Inevitably this is a bit heuristic, given the lack of any API guarantees +/// on the structure of [StackTrace]. +String _inmostFrame(StackTrace stackTrace) { + final firstLine = stackTrace.toString().split('\n').first; + return firstLine.replaceFirst(RegExp(r'^#\d+\s+'), ''); +} + +const String _corpusDirPath = String.fromEnvironment('corpusDir'); + +const bool _verbose = int.fromEnvironment('verbose', defaultValue: 0) != 0; + +Iterable _getCorpusFiles() { + final corpusDir = Directory(_corpusDirPath); + return corpusDir.existsSync() ? corpusDir.listSync().whereType() : []; +} diff --git a/tools/generate-logos b/tools/generate-logos index a96751a87b..a1c95c37d6 100755 --- a/tools/generate-logos +++ b/tools/generate-logos @@ -35,8 +35,8 @@ die() { exit 1 } -inkscape --version >/dev/null 2>&1 \ - || die "Need inkscape -- try 'apt install inkscape'." +rsvg-convert --version >/dev/null 2>&1 \ + || die "Need rsvg-convert -- try 'apt install librsvg2-bin'." cwebp -version >/dev/null 2>&1 \ || die "Need cwebp -- try 'apt install webp'." @@ -45,26 +45,16 @@ jq --version >/dev/null 2>&1 \ || die "Need jq -- try 'apt install jq'." -# This should point to a zulip.git worktree. -zulip_root="${root_dir%/*}"/zulip +# White Z, on transparent background. +src_icon_foreground="${root_dir}"/assets/app-icons/zulip-white-z-on-transparent.svg -# White Z in gradient-colored circle. -src_icon_circle="${zulip_root}"/static/images/logo/zulip-icon-circle.svg +# Gradient-colored square, full-bleed. +src_icon_background="${root_dir}"/assets/app-icons/zulip-gradient.svg -# White Z in gradient-colored circle with BETA banner. -# Contains a link to the equivalent of ${src_icon_circle}. -src_icon_circle_beta="${root_dir}"/tools/zulip-icon-circle-beta.svg - -# White Z in gradient-colored square, full-bleed. -# src_icon_square="${zulip_root}"/static/images/logo/zulip-icon-square.svg - -# White Z in gradient-colored square, full-bleed, with BETA banner. -# Contains a link to the equivalent of ${src_icon_square}. -src_icon_square_beta="${root_dir}"/tools/zulip-icon-square-beta.svg - - -[ -r "${src_icon_circle}" ] \ - || die "Expected Zulip worktree at: ${zulip_root}" +# Combination of ${src_icon_foreground} upon ${src_icon_background}... +# more or less. (The foreground layer is larger, with less padding, +# and the SVG is different in random other ways.) +src_icon_combined="${root_dir}"/assets/app-icons/zulip-combined.svg make_one_ios_app_icon() { @@ -75,7 +65,7 @@ make_one_ios_app_icon() { local output_basename=Icon-"${size_pt}x${size_pt}@${scale}x".png local output="${iconset}"/"${output_basename}" if [ ! -f "${output}" ]; then - inkscape "${src_icon_square_beta}" -w "${size_px}" --export-png="${output}" + rsvg-convert "${src_icon_combined}" -w "${size_px}" -o "${output}" fi printf >>"${contents}" \ @@ -103,14 +93,6 @@ make_ios_app_icon() { make_one_ios_app_icon 76 2 ipad make_one_ios_app_icon 1024 1 ios-marketing - # For the App Store logo, it's required to not have transparency. - # We already don't have any intentional transparency, so just - # cut out the alpha channel. - mv "${iconset}"/Icon-1024x1024@1x{,.1}.png - convert "${iconset}"/Icon-1024x1024@1x.1.png \ - -alpha deactivate "${iconset}"/Icon-1024x1024@1x.png - rm -f "${iconset}"/Icon-1024x1024@1x.1.png - # From "Spotlight, Settings, and Notification Icons" # in the same iOS doc make_one_ios_app_icon 40 3 iphone @@ -135,7 +117,7 @@ make_ios() { make_webp() { local input="$1" size="$2" output="$3" - inkscape "${input}" -w "${size}" --export-png="${tmpdir}"/tmp.png + rsvg-convert "${input}" -w "${size}" -o "${tmpdir}"/tmp.png # `cwebp -z 9` means lossless, and max/slowest compression cwebp -z 9 "${tmpdir}"/tmp.png -o "${output}" } @@ -163,9 +145,10 @@ make_android_icon() { } make_android() { - # Launcher icon goes in a mipmap: + # Launcher icons go into mipmaps: # https://developer.android.com/training/multiscreen/screendensities#mipmap - make_android_icon "${src_icon_circle_beta}" 48 main mipmap ic_launcher + make_android_icon "${src_icon_background}" 108 main mipmap ic_launcher_background + make_android_icon "${src_icon_foreground}" 48 main mipmap ic_launcher_monochrome } make_ios diff --git a/tools/zulip-icon-circle-beta.svg b/tools/zulip-icon-circle-beta.svg deleted file mode 100644 index 36d509d0ff..0000000000 --- a/tools/zulip-icon-circle-beta.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - -BETA - - diff --git a/tools/zulip-icon-square-beta.svg b/tools/zulip-icon-square-beta.svg deleted file mode 100644 index 264743d06b..0000000000 --- a/tools/zulip-icon-square-beta.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - -BETA - -