From 88df79d1e26921c7d1835fc8484fade596356fb6 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:26:42 +0400 Subject: [PATCH 01/28] core: enable rtsopts for cli and braodcast bot (#7405) --- simplex-chat.cabal | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 63db5d040c..28f78e1661 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -458,7 +458,7 @@ executable simplex-broadcast-bot Broadcast.Bot Broadcast.Options Paths_simplex_chat - ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded + ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded -rtsopts build-depends: async ==2.2.* , base >=4.7 && <5 @@ -488,7 +488,7 @@ executable simplex-chat apps/simplex-chat default-extensions: StrictData - ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded + ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded -rtsopts build-depends: aeson ==2.2.* , base >=4.7 && <5 From 1196d362ee5dd96b9e7226727000f7f102432139 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:30:11 +0000 Subject: [PATCH 02/28] desktop: animate GIFs and animated WebP (#7365) * desktop: add bounded animated image decoder Skia's Codec is already on the desktop classpath through skiko and decodes both GIF and animated WebP. The frames come from a file somebody else composed, so the decoder is bounded before it allocates: the raster is measured in bytes with the sides multiplied as Long, each side is capped separately so an extreme aspect ratio cannot slip under the byte budget, and the encoded size is checked before the bytes are copied into native memory. Anything outside the bounds, or any failure, keeps the still image the chat already renders. Nothing calls this yet. * desktop: animate GIFs in chat items and full screen Both views drew the first frame only. The full screen view also decoded its still on every recomposition, which an animation recomposes once per frame, so that decode is remembered against the data it comes from. The chat list preview stays a still image: it is a 36dp box that the desktop layout keeps on screen the whole time, so animating it would hold a raster and spend a frame of work per listed chat, without pause. Removes the two markers left for this work. * desktop: don't decode animation frames that cannot be seen With media blur on, a blurred image is only revealed while the mouse is over it, so every frame was decoded, uploaded and then blurred away again for nobody - and the blur is a render effect re-run per frame. Frames now decode only while the image can be seen, which also stops motion showing through a blur that is there to hide it. Passing the blur state to the view is why the shared signature changes; coil drives its own animation on Android, so there is nothing to pause there. * docs: move animated images plan to plans/ * docs: drop file path references from animated images plan * docs: correct animated images plan against the code * desktop: correct animated image comments * desktop: reduce animated image comments * desktop: correct and bound animated image decoding * docs: correct animated images plan against measurements * desktop: fuse the animation prior frame decision * docs: cover desktop animated images in spec and product * desktop: drop the unused animated image component * desktop: return the animation frame instead of its state * docs: correct the animated images documentation * desktop: don't decode animations under the full screen viewer * desktop: bound the frames an animation rebuilds * desktop: pause animations under any full screen modal * desktop: stop animations that alternate expensive frames * desktop: read what playing a frame needs only once * desktop: close the codec of an animation outside the bounds * desktop: wait out what an animation frame cost to decode * docs: correct animated images claims against the code * desktop: bound the frame count where the others are bounded * desktop: don't wait out a stall an animation frame did not spend * desktop: say what the slow frame constants stand for * desktop: don't decode animations behind a minimised window * desktop: make the animation frame wait testable * desktop: bound the file size where the others are bounded * desktop: pin the frame wait clamp in its test * desktop: keep the frame wait clamp private * desktop: reduce animated image comments --------- Co-authored-by: sh --- apps/multiplatform/CODE.md | 1 + apps/multiplatform/common/build.gradle.kts | 1 - .../views/chat/item/CIImageView.android.kt | 2 + .../common/views/chat/item/CIImageView.kt | 3 +- .../views/chat/item/ImageFullScreenView.kt | 2 - .../common/platform/AnimatedImage.desktop.kt | 199 ++++++++++++++ .../views/chat/item/CIImageView.desktop.kt | 10 +- .../chat/item/ImageFullScreenView.desktop.kt | 4 +- .../simplex/app/AnimatedImageBoundsTest.kt | 252 ++++++++++++++++++ apps/multiplatform/product/gaps.md | 7 +- apps/multiplatform/product/views/chat.md | 2 +- apps/multiplatform/spec/client/chat-view.md | 12 + apps/multiplatform/spec/impact.md | 1 + plans/2026-08-11-desktop-animated-images.md | 113 ++++++++ 14 files changed, 598 insertions(+), 11 deletions(-) create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AnimatedImage.desktop.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AnimatedImageBoundsTest.kt create mode 100644 plans/2026-08-11-desktop-animated-images.md diff --git a/apps/multiplatform/CODE.md b/apps/multiplatform/CODE.md index 26a36e75bb..67fa676414 100644 --- a/apps/multiplatform/CODE.md +++ b/apps/multiplatform/CODE.md @@ -289,6 +289,7 @@ desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file) | common/.../common/StoreWindowState.kt (desktopMain) | spec/architecture.md | product/views/settings.md | | common/.../common/model/NtfManager.desktop.kt (desktopMain) | spec/services/notifications.md | product/flows/messaging.md | | common/.../common/views/helpers/AppUpdater.kt (desktopMain) | spec/architecture.md | product/views/settings.md | +| common/.../common/platform/AnimatedImage.desktop.kt (desktopMain) | spec/client/chat-view.md | product/views/chat.md | ### Haskell Core Sources (at `../../src/Simplex/Chat/` relative to `apps/multiplatform/`) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 43bc114f21..ec4235d344 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -72,7 +72,6 @@ kotlin { api("org.jetbrains.compose.ui:ui-text:${rootProject.extra["compose.version"] as String}") implementation("org.jetbrains.compose.material:material-icons-core:1.7.3") implementation("org.jetbrains.compose.material:material-icons-extended:1.7.3") - implementation("org.jetbrains.compose.components:components-animatedimage:${rootProject.extra["compose.version"] as String}") //Barcode api("org.boofcv:boofcv-core:1.1.3") implementation("com.godaddy.android.colorpicker:compose-color-picker-jvm:0.7.0") diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt index ae5b8043ed..5538655a92 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt @@ -3,6 +3,7 @@ package chat.simplex.common.views.chat.item import android.os.Build.VERSION.SDK_INT import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter @@ -24,6 +25,7 @@ actual fun SimpleAndAnimatedImageView( file: CIFile?, imageProvider: () -> ImageGalleryProvider, smallView: Boolean, + blurred: State, // coil drives the animation itself here, so there is nothing to pause ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit ) { val context = LocalContext.current diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 7ce44475b5..67fc0a038c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -210,7 +210,7 @@ fun CIImageView( val loaded = res.value if (loaded != null && file != null) { val (imageBitmap, data, _) = loaded - SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) }) + SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) }) } else { imageView(previewBitmap, onClick = { if (file != null) { @@ -281,5 +281,6 @@ expect fun SimpleAndAnimatedImageView( file: CIFile?, imageProvider: () -> ImageGalleryProvider, smallView: Boolean, + blurred: State, ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 8d96102daa..b1604adc84 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -148,8 +148,6 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> ) } .fillMaxSize() - // LALAL - // https://github.com/JetBrains/compose-multiplatform/pull/2015/files#diff-841b3825c504584012e1d1c834d731bae794cce6acad425d81847c8bbbf239e0R24 if (media is ProviderMedia.Image) { val (data: ByteArray, imageBitmap: ImageBitmap) = media FullScreenImageView(modifier, data, imageBitmap) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AnimatedImage.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AnimatedImage.desktop.kt new file mode 100644 index 0000000000..e12bae4280 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AnimatedImage.desktop.kt @@ -0,0 +1,199 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import chat.simplex.common.simplexWindowState +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Codec +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.Data + +// Animated images are decoded from data received from other users, which is what the bounds below are for + +// In bytes as the file chooses the color type, 1920x1920 at 4 bytes a pixel is ~15MB +private const val MAX_ANIMATED_RASTER_BYTES: Long = 1920L * 1920 * 4 +// 65535x32 is only 2.1MP, so each side is bounded as well +private const val MAX_ANIMATED_SIDE = 4096 +// Skia copies the encoded bytes into native memory and scans them to count frames +private const val MAX_ANIMATED_FILE_SIZE = 32 * 1024 * 1024 +// Counting frames builds a table the codec holds while it plays, several times the file's size for minimal ones +private const val MAX_ANIMATED_FRAMES = 10_000 +// 10ms or less is how "as fast as possible" is written, and browsers substitute 100ms for it +private const val MAX_UNSPECIFIED_FRAME_DURATION_MS = 10 +private const val DEFAULT_FRAME_DURATION_MS = 100L +private const val MIN_FRAME_DURATION_MS = 20L +// A frame costing more than this holds most of a core to show under 10 frames a second +private const val MAX_FRAME_DECODE_MS = 100L +// Far above what a frame within the bounds above can cost, so only a stall reaches it +private const val MAX_WAITED_FRAME_COST_MS = 10 * MAX_FRAME_DECODE_MS +private const val SLOW_FRAME_COST = 2 +internal const val MAX_SLOW_FRAME_DEBT = 4 +private const val NO_PRIOR_FRAME = -1 +// A frame given no prior frame is rebuilt by recursing down its chain, so a long enough one overflows the +// native stack, which no catch can stop. Real animations rebuild nothing. +private const val MAX_REBUILT_FRAMES = 64 + +// Read once, as asking the codec about a frame allocates and the loop may repeat forever +private class Animation(val codec: Codec, val priorFrames: IntArray, val frameDelays: LongArray) + +/** + * The current frame of [data], or [still] when it is not an animation, falls outside the bounds above, or + * fails before showing a frame; after that it stops on the frame it reached. Decoding runs off the UI thread. + */ +@Composable +fun rememberAnimatedImage(data: ByteArray, still: ImageBitmap, hidden: () -> Boolean = { false }): ImageBitmap { + // Keyed as the decoding is, so frames are not written into a replaced state, and hidden is not a key so it + // pauses instead of restarting. Every frame is a new wrapper, and only its identity says the image changed. + val frame = remember(data, still) { mutableStateOf(still, neverEqualPolicy()) } + LaunchedEffect(data, still) { + withContext(animationDecoder) { + val animation = openAnimation(data) ?: return@withContext + try { + playFrames(animation, hidden) { frame.value = it } + } finally { + animation.codec.close() + } + } + } + return frame.value +} + +// Decoding several large animations must not starve the long running calls that share this pool +@OptIn(ExperimentalCoroutinesApi::class) +private val animationDecoder = Dispatchers.Default.limitedParallelism(2) + +private fun openAnimation(data: ByteArray): Animation? { + if (!looksAnimatable(data) || !fileSizeWithinBounds(data.size)) return null + var codec: Codec? = null + var animation: Animation? = null + try { + // Skia retains the encoded bytes, so this native buffer is freed as soon as the codec has taken it + val encoded = Data.makeFromBytes(data) + codec = try { + Codec.makeFromData(encoded) + } finally { + encoded.close() + } + animation = boundedAnimation(codec) + } catch (e: Throwable) { + Log.e(TAG, "Unable to read animated image: $e") + } + // The codec is only left open for an animation that took it, so no bound can return past closing it + if (animation == null) codec?.close() + return animation +} + +private fun boundedAnimation(codec: Codec): Animation? { + val info = codec.imageInfo + if (!rasterWithinBounds(info.width, info.height, info.bytesPerPixel)) return null + // Counting frames scans the file, while dimensions are only read from the header + val frameCount = codec.frameCount + if (!frameCountWithinBounds(frameCount)) return null + val requiredFrames = IntArray(frameCount) + val frameDelays = LongArray(frameCount) + for (i in 0 until frameCount) { + val frameInfo = codec.getFrameInfo(i) + requiredFrames[i] = frameInfo.requiredFrame + frameDelays[i] = frameDuration(frameInfo.duration) + } + if (!rebuiltFramesWithinBounds(requiredFrames)) return null + return Animation(codec, IntArray(frameCount) { priorFrame(it, requiredFrames[it]) }, frameDelays) +} + +internal fun looksAnimatable(data: ByteArray): Boolean = + data.startsWith("GIF8") || (data.startsWith("RIFF") && data.startsWith("WEBP", offset = 8)) + +private fun ByteArray.startsWith(ascii: String, offset: Int = 0): Boolean { + if (size < offset + ascii.length) return false + return ascii.indices.all { this[offset + it] == ascii[it].code.toByte() } +} + +internal fun rasterWithinBounds(width: Int, height: Int, bytesPerPixel: Int): Boolean { + if (width !in 1..MAX_ANIMATED_SIDE || height !in 1..MAX_ANIMATED_SIDE) return false + // 0 bytes per pixel would let any raster pass the bound below + if (bytesPerPixel < 1) return false + // The sides are bounded before they are multiplied, so the product cannot overflow + return width.toLong() * height * bytesPerPixel <= MAX_ANIMATED_RASTER_BYTES +} + +private suspend fun playFrames(animation: Animation, hidden: () -> Boolean, showFrame: (ImageBitmap) -> Unit) { + try { + val codec = animation.codec + val bitmap = Bitmap() + // The codec reports only the first frame's alpha type, and a frame with alpha cannot be read into an + // opaque bitmap. allocPixels returns false rather than throwing. + if (!bitmap.allocPixels(codec.imageInfo.withColorAlphaType(ColorAlphaType.PREMUL))) return + var loopsLeft = codec.repetitionCount // negative repeats forever + var debt = 0 + while (true) { + for (i in animation.priorFrames.indices) { + awaitFramesAreSeen(hidden) + val startedDecoding = System.nanoTime() + codec.readPixels(bitmap, i, animation.priorFrames[i]) + // Wall time, so a frame can overrun by being descheduled rather than by being expensive + val decodedIn = System.nanoTime() - startedDecoding + debt = slowFrameDebt(debt, decodedIn > MAX_FRAME_DECODE_MS * 1_000_000) + // The bitmap is never closed, as the wrapper points at its pixels and a frame may still be drawn + showFrame(bitmap.asComposeImageBitmap()) + if (debt >= MAX_SLOW_FRAME_DEBT) { + Log.d(TAG, "Animation too expensive to decode, stopping on this frame") + return + } + delay(frameWait(animation.frameDelays[i], decodedIn / 1_000_000)) + } + if (loopsLeft == 0) return + if (loopsLeft > 0) loopsLeft-- + } + } catch (e: CancellationException) { + throw e // the view is gone, not a decoding failure + } catch (e: Throwable) { + Log.e(TAG, "Unable to play animated image: $e") + } +} + +// Composition survives the window being minimized or hidden, and the caller knows when its image cannot be seen +private suspend fun awaitFramesAreSeen(hidden: () -> Boolean) { + if (framesAreSeen(hidden)) return + snapshotFlow { framesAreSeen(hidden) }.first { it } +} + +private fun framesAreSeen(hidden: () -> Boolean): Boolean = + simplexWindowState.windowVisible.value && !simplexWindowState.windowState.isMinimized && !hidden() + +// Waiting out the cost as well as the delay leaves an animation about half a decoder thread. The cost is +// wall time, so a stall is only waited out so far. +internal fun frameWait(delayMs: Long, costMs: Long): Long = + maxOf(delayMs, costMs.coerceAtMost(MAX_WAITED_FRAME_COST_MS)) + +internal fun fileSizeWithinBounds(size: Int): Boolean = size <= MAX_ANIMATED_FILE_SIZE + +// A file of no frames would spin the playback loop uncancellably, as it only suspends inside the range +internal fun frameCountWithinBounds(frameCount: Int): Boolean = frameCount in 2..MAX_ANIMATED_FRAMES + +// The frame the codec may decode this one from, which is the one before it when the bitmap still holds it. +// Rebuilding the chain instead costs 9.10ms a frame against 0.05ms, and Skia refuses a frame it did not ask for. +internal fun priorFrame(index: Int, requiredFrame: Int): Int = + if (requiredFrame == index - 1) index - 1 else NO_PRIOR_FRAME + +// requiredFrames is what each frame continues; one that continues nothing starts a chain of its own +internal fun rebuiltFramesWithinBounds(requiredFrames: IntArray): Boolean { + val chain = IntArray(requiredFrames.size) + requiredFrames.forEachIndexed { index, required -> + val continues = required in 0 until index + chain[index] = if (continues) chain[required] + 1 else 1 + if (continues && priorFrame(index, required) == NO_PRIOR_FRAME && chain[required] > MAX_REBUILT_FRAMES) return false + } + return true +} + +// Two expensive frames in a row reach the debt, and so do frames that alternate with cheap ones, which a +// count that reset would miss +internal fun slowFrameDebt(debt: Int, tooSlow: Boolean): Int = + (debt + if (tooSlow) SLOW_FRAME_COST else -1).coerceAtLeast(0) + +internal fun frameDuration(declaredMs: Int): Long = + if (declaredMs <= MAX_UNSPECIFIED_FRAME_DURATION_MS) DEFAULT_FRAME_DURATION_MS + else declaredMs.toLong().coerceAtLeast(MIN_FRAME_DURATION_MS) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index b4a24e3572..98ffa7c8a4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -1,6 +1,7 @@ package chat.simplex.common.views.chat.item import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter @@ -15,10 +16,15 @@ actual fun SimpleAndAnimatedImageView( file: CIFile?, imageProvider: () -> ImageGalleryProvider, smallView: Boolean, + blurred: State, ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit ) { - // LALAL make it animated too - ImageView(BitmapPainter(imageBitmap)) { + // The small view is the chat list preview, which the layout keeps on screen without pause, so it stays a + // still image. A full screen modal is shown beside the chat rather than in place of it, so this item keeps + // composing under one and would otherwise decode where nobody can see it. + val frame = if (smallView) imageBitmap + else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() } + ImageView(BitmapPainter(frame)) { if (getLoadedFilePath(file) != null) { ModalManager.fullscreen.showCustomModal(animated = false) { close -> ImageFullScreenView(imageProvider, close) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index bd395c2c97..583aa8f52f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -19,8 +19,10 @@ import kotlin.math.max @Composable actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) { + // Decoded once, as an animation recomposes this on every frame + val still = remember(data) { getBitmapFromByteArray(data, false) ?: MR.images.decentralized.image.toComposeImageBitmap() } Image( - getBitmapFromByteArray(data, false) ?: MR.images.decentralized.image.toComposeImageBitmap(), + rememberAnimatedImage(data, still), contentDescription = stringResource(MR.strings.image_descr), contentScale = ContentScale.Fit, modifier = modifier, diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AnimatedImageBoundsTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AnimatedImageBoundsTest.kt new file mode 100644 index 0000000000..b79dd69779 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AnimatedImageBoundsTest.kt @@ -0,0 +1,252 @@ +package chat.simplex.app + +import chat.simplex.common.platform.MAX_SLOW_FRAME_DEBT +import chat.simplex.common.platform.frameWait +import chat.simplex.common.platform.fileSizeWithinBounds +import chat.simplex.common.platform.frameCountWithinBounds +import chat.simplex.common.platform.frameDuration +import chat.simplex.common.platform.looksAnimatable +import chat.simplex.common.platform.priorFrame +import chat.simplex.common.platform.rasterWithinBounds +import chat.simplex.common.platform.rebuiltFramesWithinBounds +import chat.simplex.common.platform.slowFrameDebt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +// The bounds an animated image must satisfy, checked as arithmetic: skiko's native library is not on the +// test runtime classpath, and these numbers are the part that has to be right about someone else's file. +class AnimatedImageBoundsTest { + private val BYTES_PER_PIXEL = 4 // what a GIF or WebP decodes to + + @Test + fun testOrdinaryAnimationIsWithinBounds() { + assertTrue(rasterWithinBounds(64, 64, BYTES_PER_PIXEL)) + assertTrue(rasterWithinBounds(1244, 554, BYTES_PER_PIXEL)) + } + + @Test + fun testHugeDeclaredDimensionsAreRejected() { + // A 17GB raster, declared by a GIF of 35 bytes + assertFalse(rasterWithinBounds(65535, 65535, BYTES_PER_PIXEL)) + } + + @Test + fun testDimensionsOverRasterBudgetAreRejected() { + // Plausible-looking, but one raster of this size is ~64MB and a chat shows several at once + assertFalse(rasterWithinBounds(4000, 4000, BYTES_PER_PIXEL)) + } + + @Test + fun testAspectRatioIsBoundedOnEachSideSeparately() { + // Only 2.1MP, so the raster bound alone would animate this with a 65535-pixel scanline + assertFalse(rasterWithinBounds(65535, 32, BYTES_PER_PIXEL)) + assertFalse(rasterWithinBounds(32, 65535, BYTES_PER_PIXEL)) + assertTrue(rasterWithinBounds(3000, 500, BYTES_PER_PIXEL)) + assertTrue(rasterWithinBounds(4096, 900, BYTES_PER_PIXEL)) + } + + @Test + fun testBudgetBoundariesAreExact() { + assertTrue(rasterWithinBounds(1920, 1920, BYTES_PER_PIXEL)) + assertFalse(rasterWithinBounds(1921, 1920, BYTES_PER_PIXEL)) + assertFalse(rasterWithinBounds(4097, 100, BYTES_PER_PIXEL)) + } + + @Test + fun testEmptyDimensionsAreRejected() { + assertFalse(rasterWithinBounds(0, 64, BYTES_PER_PIXEL)) + assertFalse(rasterWithinBounds(64, 0, BYTES_PER_PIXEL)) + assertFalse(rasterWithinBounds(-1, 64, BYTES_PER_PIXEL)) + } + + @Test + fun testWiderColorTypesCountAgainstTheSameBudget() { + // The file chooses its color type, so the 1920x1920 that fits at four bytes is twice the raster at eight + assertFalse(rasterWithinBounds(1920, 1920, 8)) + assertTrue(rasterWithinBounds(1357, 1357, 8)) + // A color type claiming no bytes per pixel would otherwise make any raster look free + assertFalse(rasterWithinBounds(4096, 4096, 0)) + } + + @Test + fun testAnimatableContainersAreRecognized() { + assertTrue(looksAnimatable("GIF89a...".toByteArray())) + assertTrue(looksAnimatable("GIF87a...".toByteArray())) + assertTrue(looksAnimatable("RIFF????WEBPVP8X".toByteArray())) + } + + @Test + fun testPhotosNeverReachTheAnimationDecoder() { + assertFalse(looksAnimatable(bytes(0x89, 'P'.code, 'N'.code, 'G'.code, 0x0D, 0x0A, 0x1A, 0x0A))) + assertFalse(looksAnimatable(bytes(0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46))) + // A RIFF container that is not WebP, a wave file say + assertFalse(looksAnimatable("RIFF????WAVEfmt ".toByteArray())) + } + + @Test + fun testShortDataIsRejectedWithoutReadingPastTheEnd() { + assertFalse(looksAnimatable(ByteArray(0))) + assertFalse(looksAnimatable("GIF".toByteArray())) + // Long enough for the RIFF tag, too short for the format that follows it + assertFalse(looksAnimatable("RIFF".toByteArray())) + assertFalse(looksAnimatable("RIFF1234WEB".toByteArray())) + } + + @Test + fun testPriorFrameIsReusedOnlyWhenTheBitmapHoldsIt() { + assertEquals(4, priorFrame(5, 4)) + // An older required frame is no longer in the bitmap, which is also how a predecessor disposed to what + // came before it is skipped, as Skia never requires one + assertEquals(-1, priorFrame(5, 2)) + assertEquals(-1, priorFrame(5, -1)) + // For the first frame, -1 is both its required frame and no prior frame + assertEquals(-1, priorFrame(0, -1)) + } + + @Test + fun testOnlyFilesSmallEnoughToScanAreWithinBounds() { + assertTrue(fileSizeWithinBounds(0)) + // The largest animation in this repository + assertTrue(fileSizeWithinBounds(6_013_354)) + assertTrue(fileSizeWithinBounds(32 * 1024 * 1024)) + assertFalse(fileSizeWithinBounds(32 * 1024 * 1024 + 1)) + } + + @Test + fun testOnlyAnimationsWorthHoldingFramesForAreWithinBounds() { + assertFalse(frameCountWithinBounds(0)) + assertFalse(frameCountWithinBounds(1)) + assertFalse(frameCountWithinBounds(-1)) + assertTrue(frameCountWithinBounds(2)) + // The longest animation in this repository, and the bound itself + assertTrue(frameCountWithinBounds(1041)) + assertTrue(frameCountWithinBounds(10_000)) + assertFalse(frameCountWithinBounds(10_001)) + } + + @Test + fun testAnimationsThatRebuildNothingAreWithinBounds() { + // What a real animation looks like: each frame continues the one before it, so nothing is rebuilt + assertTrue(rebuiltFramesWithinBounds(IntArray(5000) { it - 1 })) + assertTrue(rebuiltFramesWithinBounds(intArrayOf(-1, -1, -1))) + } + + @Test + fun testShortRebuiltChainsAreWithinBounds() { + // A GIF disposing to what came before it: frame 2 continues frame 0, rebuilding two frames + assertTrue(rebuiltFramesWithinBounds(intArrayOf(-1, 0, 0, 2, 2, 4))) + } + + @Test + fun testLongRebuiltChainsAreRejected() { + // Alternating disposal makes every other frame rebuild the chain before it, which Skia recurses through: + // 8000 frames of that overflows the native stack and kills the app + val alternating = IntArray(8000) { if (it % 2 == 0) it - 2 else it - 1 } + assertFalse(rebuiltFramesWithinBounds(alternating)) + // The bound is on what a rebuild costs, not on how long the animation is + assertTrue(rebuiltFramesWithinBounds(IntArray(8000) { it - 1 })) + } + + @Test + fun testRebuiltChainBoundIsExact() { + fun chainOf(length: Int) = IntArray(length + 2) { if (it == length + 1) it - 2 else it - 1 } + assertTrue(rebuiltFramesWithinBounds(chainOf(64))) + assertFalse(rebuiltFramesWithinBounds(chainOf(65))) + } + + @Test + fun testFramesContinuingSomethingImpossibleStartTheirOwnChain() { + // A file is not trusted to say a frame continues itself, a later frame, or one that is not there + assertTrue(rebuiltFramesWithinBounds(IntArray(5000) { it })) + assertTrue(rebuiltFramesWithinBounds(IntArray(5000) { it + 1 })) + assertTrue(rebuiltFramesWithinBounds(IntArray(5000) { 9999 })) + } + + @Test + fun testAFrameCheaperThanItsDelayWaitsAsItAlwaysDid() { + assertEquals(70, frameWait(70, 0)) + assertEquals(70, frameWait(70, 2)) + assertEquals(70, frameWait(70, 70)) + } + + @Test + fun testAFrameDearerThanItsDelayIsWaitedOut() { + assertEquals(85, frameWait(20, 85)) + assertEquals(500, frameWait(20, 500)) + assertEquals(1000, frameWait(20, 1000)) + } + + @Test + fun testAStallIsNotWaitedOut() { + // Wall time counts a machine that suspended mid-decode, which the frame never spent + assertEquals(1000, frameWait(20, 30_000)) + assertEquals(1000, frameWait(20, 8L * 60 * 60 * 1000)) + assertEquals(5000, frameWait(5000, 30_000)) + } + + @Test + fun testTwoExpensiveFramesInARowStopTheAnimation() { + var debt = slowFrameDebt(0, tooSlow = true) + assertTrue(debt < MAX_SLOW_FRAME_DEBT) + debt = slowFrameDebt(debt, tooSlow = true) + assertTrue(debt >= MAX_SLOW_FRAME_DEBT) + } + + @Test + fun testAFrameThatOnlyOverranIsPaidOff() { + // One expensive frame among cheap ones is a busy machine, not an expensive animation + var debt = slowFrameDebt(0, tooSlow = true) + repeat(4) { debt = slowFrameDebt(debt, tooSlow = false) } + assertEquals(0, debt) + } + + @Test + fun testAlternatingExpensiveFramesStillStopTheAnimation() { + // Frames that alternate are never expensive twice in a row, which is what a count that resets would miss + var debt = 0 + var frames = 0 + while (debt < MAX_SLOW_FRAME_DEBT && frames < 100) { + debt = slowFrameDebt(debt, tooSlow = frames % 2 == 0) + frames++ + } + assertEquals(5, frames) + } + + @Test + fun testCheapFramesEarnNoCreditAgainstLaterExpensiveOnes() { + var debt = 0 + repeat(1000) { debt = slowFrameDebt(debt, tooSlow = false) } + assertEquals(0, debt) + debt = slowFrameDebt(debt, tooSlow = true) + debt = slowFrameDebt(debt, tooSlow = true) + assertTrue(debt >= MAX_SLOW_FRAME_DEBT) + } + + @Test + fun testFrameDurationSubstitutesTheDefaultForFramesInAHurry() { + // Skia reports a GIF delay in milliseconds, so "no delay" and "one centisecond" arrive as 0 and 10 + assertEquals(100, frameDuration(0)) + assertEquals(100, frameDuration(10)) + // Not expected from Skia, but read from the file + assertEquals(100, frameDuration(-1)) + } + + @Test + fun testFrameDurationKeepsAuthoredDelays() { + assertEquals(70, frameDuration(70)) + assertEquals(600, frameDuration(600)) + assertEquals(Int.MAX_VALUE.toLong(), frameDuration(Int.MAX_VALUE)) + } + + @Test + fun testFrameDurationRaisesDelaysBelowTheFloor() { + assertEquals(20, frameDuration(11)) + assertEquals(20, frameDuration(19)) + assertEquals(20, frameDuration(20)) + assertEquals(21, frameDuration(21)) + } + + private fun bytes(vararg values: Int): ByteArray = values.map { it.toByte() }.toByteArray() +} diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md index 25535d8003..aae24bdca8 100644 --- a/apps/multiplatform/product/gaps.md +++ b/apps/multiplatform/product/gaps.md @@ -222,9 +222,10 @@ Desktop users cannot send voice messages. The record button either does nothing Several other Desktop features are also marked with `LALAL` placeholders: - **QR Code Scanner** (`QRCodeScanner.desktop.kt:12`) -- scanning QR codes is not implemented on Desktop -- **Animated Drawables** (`Utils.desktop.kt:179`) -- animated image support (e.g., GIF in-line rendering) is not implemented -- **Animated Chat Images** (`CIImageView.desktop.kt:19`) -- animated image rendering in chat items -- **isImage detection** (`Images.desktop.kt:168`) -- image type detection (implemented but marked as incomplete) +- **Animated Drawables** (`Utils.desktop.kt:236`) -- `getDrawableFromUri` returns null, so `isAnimImage` falls back to the file extension +- **isImage detection** (`Images.desktop.kt:189`) -- image type detection (implemented but marked as incomplete) + +Desktop cannot decode WebP in chat: `decodeBoundedBufferedImage` (`Utils.desktop.kt:191`) reads through ImageIO, which has no WebP reader, so a received `.webp` renders only as the sender's preview and never opens full screen, and a picked one is skipped. Wallpapers and link previews decode WebP, as they read through Skia instead (`Images.desktop.kt:204`). Received GIFs do animate in chat items and full screen; the animated image decoder accepts WebP but is never reached for it. --- diff --git a/apps/multiplatform/product/views/chat.md b/apps/multiplatform/product/views/chat.md index 64abda7ee6..7862574f04 100644 --- a/apps/multiplatform/product/views/chat.md +++ b/apps/multiplatform/product/views/chat.md @@ -55,7 +55,7 @@ Each type has a dedicated composable in `views/chat/item/`: | Type | Composable | Description | |---|---|---| | Text | `FramedItemView` | Rendered with markdown (bold, italic, code, links, `@mentions`) via `CIMarkdownText` | -| Image | `CIImageView` | Thumbnail with tap-to-fullscreen via `ImageFullScreenView` | +| Image | `CIImageView` | Thumbnail with tap-to-fullscreen via `ImageFullScreenView`; animated GIFs play inline and full screen | | Video | `CIVideoView` | Video thumbnail with play button; inline playback via `VideoPlayerHolder` | | Voice | `CIVoiceView` | Waveform visualization with playback controls and duration | | File | `CIFileView` | File icon, name, size; download/open actions with progress indicator | diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index 728ace4936..a6691c2878 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -202,6 +202,18 @@ Long-press or right-click opens a dropdown menu with context-sensitive actions ( | `InvalidJSON` | -- | `CIInvalidJSONView` | `CIInvalidJSONView.kt` | | `CIMemberCreatedContact` | -- | `CIMemberCreatedContactView` | `CIMemberCreatedContactView.kt` | +### Animated Images + +`SimpleAndAnimatedImageView` is `expect`/`actual`. Android delegates to coil, which drives the animation +itself. Desktop decodes frames with Skia's `Codec` in `platform/AnimatedImage.desktop.kt`, where +`rememberAnimatedImage(data, still, hidden)` returns the frame to draw and falls back to the still image when +the data is not an animation, exceeds the decode bounds, or fails before showing a frame. Decoding runs off the UI thread +on two threads of the shared pool, and pauses while the window is minimized or hidden, while the image is behind the +privacy blur, and while a full screen modal covers the chat. An animation whose frames cost too much to +decode stops on the frame it reached rather than falling back to the still. The chat list preview (`smallView`) stays a +still image. Only GIF reaches this path: desktop decodes stills with ImageIO, which has no WebP reader, so a +received `.webp` renders only as the sender's preview and never opens full screen. + --- ## 6. Context Menu Actions diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index f808cf31ba..3a96638310 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -424,6 +424,7 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` | `platform/Videos.desktop.kt` | PC10 | Low | Desktop video utilities | | `platform/Notifications.desktop.kt` | PC18 | Low | Desktop notification setup | | `platform/Images.desktop.kt` | PC10 | Low | Desktop image processing | +| `platform/AnimatedImage.desktop.kt` | PC10 | Low | Desktop animated image frame decoding (bounded) | | `platform/PlatformTextField.desktop.kt` | PC4 | Low | Desktop text field actual implementation | | `platform/Share.desktop.kt` | PC10 | Low | Desktop clipboard/share | | `platform/Back.desktop.kt` | PC1 | Low | Desktop back navigation | diff --git a/plans/2026-08-11-desktop-animated-images.md b/plans/2026-08-11-desktop-animated-images.md new file mode 100644 index 0000000000..82a0257b28 --- /dev/null +++ b/plans/2026-08-11-desktop-animated-images.md @@ -0,0 +1,113 @@ +# Animated images on desktop + +## The problem + +`SimpleAndAnimatedImageView` on desktop drew a single `BitmapPainter` and carried the marker +`// LALAL make it animated too`. Android decodes animations with coil, iOS with SwiftyGif, and desktop showed +the first frame and stopped. `ImageFullScreenView` carried a matching marker over the image branch. + +## Why this shape + +**Skia's `Codec`, which skiko already puts on the desktop classpath.** No new dependency. It decodes both GIF +and animated WebP, reports per-frame durations and repeat counts, and supports random access into frames. + +**Not `components-animatedimage`** (declared and unused until this change removed it). Its `animate()` +ignores the result of `allocPixels` and decodes inside composition. A 35-byte GIF declaring 65535x65535 asks +for a 17GB raster; `allocPixels` returns false, and the following `readPixels` throws +`IllegalArgumentException` from inside the composition — a remote crash from anyone who can send a file. It +also decodes on the UI thread, measured at ~11ms per frame for a 1244x554 animation. + +## Bounds + +Everything below is decoded from bytes somebody else composed, so each bound answers a specific crafted +input, and anything outside them keeps showing the still image the chat already renders. Animation degrades +to a picture, never to an error, and failures are never alerted — an alert per malformed file would itself +let a sender disrupt the app. + +| Bound | What it answers | +| --- | --- | +| raster measured in bytes, sides multiplied as `Long` | `65535 * 65535` overflows `Int` to a negative number and would pass a naive budget check | +| per-side cap, independent of the raster bound | 65535x32 is only 2.1MP and would otherwise animate with a 65535-pixel scanline | +| bytes per pixel read from the codec | the file chooses its color type; the budget must not assume four bytes | +| file size checked before the bytes are copied natively | Skia copies the encoded bytes and scans them to count frames | +| magic-byte prefilter (`GIF8`, `RIFF....WEBP`) | photos are most of what a chat holds and none are animations; they never reach a second decoder | +| `allocPixels` result honoured | it reports failure by returning false, and reading into an unallocated bitmap throws | +| frame count bound | counting the frames also builds a table of them, which a file of minimal frames makes several times its own size, and the codec holds it for as long as the animation plays | +| rebuilt frame chain bound | a frame the codec is given no prior frame for is rebuilt from its whole chain, and Skia recurses to do it: frames alternating their disposal make that chain as long as the file likes, and 8000 frames of it overflows the native stack and kills the app, which no catch can prevent. Real animations rebuild nothing at all | +| destination allocated with a premultiplied alpha type | the codec reports the alpha type of the first frame, and a frame that has alpha cannot be read into an opaque bitmap | +| frame duration floor, and 100ms substituted for delays of 10ms and less | the frames a file is allowed can all declare no delay at all, and Skia reports the usual "as fast as possible" delay of one centisecond as 10ms | +| a frame is waited out for what it cost as well as what it asks for | one very expensive frame among cheap ones owes nothing once the cheap ones have paid the debt off, and held 96.7% of a decoder thread indefinitely; waiting out the cost leaves any animation about half of one | +| an animation that owes too much for its frames stops on the one it reached | frames that alternate expensive with cheap are never slow twice in a row, so a count that resets never stops them | +| every native call that reads the file is inside an exception boundary | the frame count, the frame table and the repeat count are read from it too | + +Long frame delays are honoured rather than clamped - they are the author's, and they cost only the codec, the +raster and the frame table staying alive while nothing decodes. + +## Cost, and the optimisations that were rejected + +A frame continues the one before it, and the codec has to be told that the bitmap already holds it. Without +that it decodes the whole chain back to the last independent frame, so a frame costs as much as its index and +a loop costs the square of the frame count. Measured over one loop of the GIFs in `images/`, decode only: + +| | frames | chain re-decoded | prior frame reused | +| --- | --- | --- | --- | +| files.gif | 196 | 5.93 ms/frame | 0.06 ms | +| connection.gif | 240 | 9.22 ms/frame | 0.09 ms | +| groups.gif | 309 | 9.10 ms/frame | 0.05 ms | +| user-addresses.gif | 1041 | 25.92 ms/frame, worst 77 ms | 0.04 ms | + +Pixels are identical either way. The cost of a frame is then its own, and an animation stops on the frame it +reached once it owes too much: a frame over 100ms counts double what a frame under it forgives. This is wall +time, so a single frame can overrun by being descheduled, and a busy machine should not turn a cheap +animation into a still - but a file whose frames alternate expensive and cheap is never slow twice in a row, +and a run of them is what a count that resets would miss. Measured on a 1920x1920 GIF of 400 such frames, which holds +67% of a core indefinitely against a count that resets. Frames tuned to stay just under the threshold owe +nothing at all, and one expensive frame among cheap enough ones owes nothing for long, which is why a frame +is also waited out for what it cost: a frame of 3s among four cheap ones drops from 96.7% of a decoder thread +to 49.3%, every frame at 99ms from 83.0% to 49.9%, and the GIFs in `images/` stay exactly where they were - +none of their frames decodes in as long as it asks to be shown, by three orders of magnitude. + +Two optimisations were measured and **rejected**. Both were measured before the prior frame was reused, so +their per-frame figures are against a decode that was two orders of magnitude more expensive; the conclusions +are kept because they are about ratios, but the numbers are worth taking again: + +- **Decoding at display size.** Scaled decode is supported at arbitrary sizes, but it costs CPU rather than + saving it: 2000x891 goes from 177.8ms to 300.3ms per frame (+68%) to save 59% of the raster — and it only + engages on the files that are already the most expensive. +- **Half-depth pixels.** Skia refuses `RGB_565` and `ARGB_4444` for GIF outright. It works only for opaque + WebP, at +11% decode for -50% raster, which does not justify a format-specific path. + +What was kept: decoding is confined to two threads of the shared pool, so untrusted decode work cannot starve +the long running calls that share it; and frames are only decoded while they can be seen — not while the app +is minimized or sits in the tray, not while the image is behind the privacy blur, where each frame would otherwise be +decoded, uploaded and then blurred away again for nobody, and not while a full screen modal covers the +chat, which is shown beside it rather than in place of it: the viewer would otherwise leave the same +animation decoding twice, and the rest of the chat decoding where nobody can see it. The chat list preview stays a still image for +the same reason: it is a 36sp box that the desktop layout keeps on screen the whole time, so animating it +would hold a raster and spend a frame of work per listed chat, without pause. + +## Verification + +- 20 000 fuzzed mutations (bit flips, truncations, header corruption) over a real corpus plus crafted hostile + files: no exception escapes the structure, no hangs. +- Frames advance, per-frame delays are read correctly, and the loop wraps back to frame 0 after a full cycle + with byte-identical pixels. +- An oversized animation is refused by the bounds and still renders through the existing still-image path. +- A GIF of 8000 frames alternating their disposal, which passes every other bound at an 8x8 raster, crashed + the process with SIGSEGV before the chain bound and is refused by it now, while the GIFs in `images/`, a + 1920x1920 animation and a GIF disposing to what came before it all still play. +- Every frame of the GIFs in `images/` decodes with the prior frame reused, with pixels identical to decoding + the chain, and a GIF whose first frame is opaque and disposed to the background decodes past its first frame + only into a premultiplied destination. +- Unit tests cover every bound as arithmetic - the raster, the frame count, the rebuilt chains, the frame + durations and the debt an expensive frame owes; skiko's native library is not on the test + runtime classpath, so decoding is measured with the library added to a standalone classpath. + +## Deliberately not in this change + +- **WebP still images do not decode on desktop at all.** Desktop decodes images with ImageIO, which has no + WebP reader, so a received `.webp` never loads and picking one to send is dropped. Both the chat item and + the full screen viewer reach this code only after that decode has succeeded, so until that separate fix + lands it is GIFs that animate in the app, and the WebP path here is exercised by measurement only. +- **The decode raster is left to the collector.** Releasing it explicitly needs to know which thread Compose + Desktop draws on, and skiko uses a different redrawer per platform; guessing risks a use-after-free. From 17cdef16925c921615f886ca588b238372159d6b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 24 Aug 2026 21:36:57 +0100 Subject: [PATCH 03/28] core: include channel link and name when forwarding messages (#7409) * core: include channel link and name when forwarding messages * wip * simplify * add member ID * refactor * refactor * refactor * update api types * store forward source group type * rename * api types * simpler layout * layout, translations * refactor ios * public * simpler * refactor kotlin * padding * padding --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../Views/Chat/ChatItem/FramedItemView.swift | 58 +++++- apps/ios/SimpleXChat/ChatTypes.swift | 22 ++- apps/ios/bg.lproj/Localizable.strings | 2 +- apps/ios/de.lproj/Localizable.strings | 2 +- apps/ios/es.lproj/Localizable.strings | 2 +- apps/ios/fr.lproj/Localizable.strings | 2 +- apps/ios/hu.lproj/Localizable.strings | 2 +- apps/ios/it.lproj/Localizable.strings | 2 +- apps/ios/nl.lproj/Localizable.strings | 2 +- apps/ios/pl.lproj/Localizable.strings | 2 +- apps/ios/ru.lproj/Localizable.strings | 2 +- apps/ios/tr.lproj/Localizable.strings | 2 +- apps/ios/uk.lproj/Localizable.strings | 2 +- apps/ios/zh-Hans.lproj/Localizable.strings | 2 +- .../chat/simplex/common/model/ChatModel.kt | 19 +- .../common/views/chat/item/FramedItemView.kt | 82 ++++++-- .../commonMain/resources/MR/ar/strings.xml | 2 +- .../commonMain/resources/MR/base/strings.xml | 3 +- .../commonMain/resources/MR/bg/strings.xml | 2 +- .../commonMain/resources/MR/ca/strings.xml | 2 +- .../commonMain/resources/MR/cs/strings.xml | 2 +- .../commonMain/resources/MR/da/strings.xml | 2 +- .../commonMain/resources/MR/de/strings.xml | 2 +- .../commonMain/resources/MR/el/strings.xml | 2 +- .../commonMain/resources/MR/es/strings.xml | 2 +- .../commonMain/resources/MR/fa/strings.xml | 2 +- .../commonMain/resources/MR/fr/strings.xml | 2 +- .../commonMain/resources/MR/hr/strings.xml | 2 +- .../commonMain/resources/MR/hu/strings.xml | 2 +- .../commonMain/resources/MR/in/strings.xml | 2 +- .../commonMain/resources/MR/it/strings.xml | 2 +- .../commonMain/resources/MR/iw/strings.xml | 2 +- .../commonMain/resources/MR/ja/strings.xml | 2 +- .../commonMain/resources/MR/lt/strings.xml | 2 +- .../commonMain/resources/MR/lv/strings.xml | 2 +- .../commonMain/resources/MR/nl/strings.xml | 2 +- .../commonMain/resources/MR/pl/strings.xml | 2 +- .../resources/MR/pt-rBR/strings.xml | 2 +- .../commonMain/resources/MR/ro/strings.xml | 2 +- .../commonMain/resources/MR/ru/strings.xml | 2 +- .../commonMain/resources/MR/tr/strings.xml | 2 +- .../commonMain/resources/MR/uk/strings.xml | 2 +- .../commonMain/resources/MR/vi/strings.xml | 2 +- .../resources/MR/zh-rCN/strings.xml | 2 +- .../resources/MR/zh-rTW/strings.xml | 2 +- bots/api/TYPES.md | 13 ++ .../types/typescript/src/types.ts | 22 ++- .../src/simplex_chat/types/_types.py | 22 ++- plans/2026-08-22-forward-link.md | 185 ++++++++++++++++++ simplex-chat.cabal | 2 + src/Simplex/Chat/Library/Commands.hs | 14 +- src/Simplex/Chat/Library/Internal.hs | 57 +++++- src/Simplex/Chat/Messages.hs | 6 +- src/Simplex/Chat/Protocol.hs | 21 +- src/Simplex/Chat/Store/Groups.hs | 62 ++++-- src/Simplex/Chat/Store/Messages.hs | 77 +++++--- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260822_forward_link.hs | 27 +++ .../Store/Postgres/Migrations/chat_schema.sql | 7 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260822_forward_link.hs | 26 +++ .../Store/SQLite/Migrations/chat_schema.sql | 7 +- src/Simplex/Chat/View.hs | 12 +- tests/ChatTests/Forward.hs | 112 +++++++++++ tests/ProtocolTests.hs | 24 ++- 65 files changed, 806 insertions(+), 162 deletions(-) create mode 100644 plans/2026-08-22-forward-link.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260822_forward_link.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260822_forward_link.hs diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 44284350dc..ac27cb27c2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -75,7 +75,35 @@ struct FramedItemView: View { } }) } else if let itemForwarded = chatItem.meta.itemForwarded { - framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true) + let twoRowHeader: Bool = if chat.chatInfo.chatType == .local { + itemForwarded.chatTypeApiIdMsgId != nil || itemForwarded.sourceGroupLink != nil + } else { + switch itemForwarded { + case let .group(_, _, _, _, _, _, groupType): groupType != nil + case .groupLink: true + default: false + } + } + if twoRowHeader { + let caption: LocalizedStringKey = chat.chatInfo.chatType == .local ? "saved from" : "forwarded from" + headerFrame(pad: true) { + VStack(alignment: .leading, spacing: 4) { + headerRow(icon: "arrowshape.turn.up.forward", caption: Text(caption).italic()) + Text(itemForwarded.chatName) + .font(.subheadline) + .lineLimit(1) + } + } + .simultaneousGesture(TapGesture().onEnded { + if let (chatType, apiId, msgId) = itemForwarded.chatTypeApiIdMsgId { + im.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId) + } else if let link = itemForwarded.sourceGroupLink { + planAndConnect(link, theme: theme, dismiss: false) + } + }) + } else { + framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true) + } } ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: framedMsgContentView) @@ -191,8 +219,14 @@ struct FramedItemView: View { } } - @ViewBuilder func framedItemHeader(icon: String? = nil, iconColor: Color? = nil, caption: Text, pad: Bool = false) -> some View { - let v = HStack(spacing: 6) { + func framedItemHeader(icon: String? = nil, iconColor: Color? = nil, caption: Text, pad: Bool = false) -> some View { + headerFrame(pad: pad) { + headerRow(icon: icon, iconColor: iconColor, caption: caption) + } + } + + private func headerRow(icon: String?, iconColor: Color? = nil, caption: Text) -> some View { + HStack(spacing: 6) { if let icon = icon { Image(systemName: icon) .resizable() @@ -204,13 +238,17 @@ struct FramedItemView: View { .font(.caption) .lineLimit(1) } - .foregroundColor(theme.colors.secondary) - .padding(.horizontal, 12) - .padding(.top, 6) - .padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0) - .overlay(DetermineWidth()) - .frame(minWidth: msgWidth, alignment: .leading) - .background(chatItemFrameContextColor(chatItem, theme)) + } + + @ViewBuilder private func headerFrame(pad: Bool = false, @ViewBuilder _ content: () -> some View) -> some View { + let v = content() + .foregroundColor(theme.colors.secondary) + .padding(.horizontal, 12) + .padding(.top, 6) + .padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0) + .overlay(DetermineWidth()) + .frame(minWidth: msgWidth, alignment: .leading) + .background(chatItemFrameContextColor(chatItem, theme)) if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth { v.frame(maxWidth: mediaWidth, alignment: .leading) } else { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 7f9f1a4fcc..e3212ee87a 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -4328,13 +4328,15 @@ public enum MsgDirection: String, Decodable, Hashable { public enum CIForwardedFrom: Decodable, Hashable { case unknown case contact(chatName: String, msgDir: MsgDirection, contactId: Int64?, chatItemId: Int64?) - case group(chatName: String, msgDir: MsgDirection, groupId: Int64?, chatItemId: Int64?) + case group(chatName: String, msgDir: MsgDirection, groupId: Int64?, chatItemId: Int64?, memberId: String?, sharedMsgId_: String?, groupType: GroupType?) + case groupLink(chatName: String, msgDir: MsgDirection, groupLink: String, publicGroupId: String, memberId: String?, sharedMsgId: String, groupType: GroupType?) - var chatName: String { + public var chatName: String { switch self { case .unknown: "" case let .contact(chatName, _, _, _): chatName - case let .group(chatName, _, _, _): chatName + case let .group(chatName, _, _, _, _, _, _): chatName + case let .groupLink(chatName, _, _, _, _, _, _): chatName } } @@ -4345,17 +4347,23 @@ public enum CIForwardedFrom: Decodable, Hashable { if let contactId { (ChatType.direct, contactId, msgId) } else { nil } - case let .group(_, _, groupId, msgId): + case let .group(_, _, groupId, msgId, _, _, _): if let groupId { (ChatType.group, groupId, msgId) } else { nil } + case .groupLink: nil + } + } + + public var sourceGroupLink: String? { + switch self { + case let .groupLink(_, _, groupLink, _, _, _, _): groupLink + default: nil } } public func text(_ chatType: ChatType) -> LocalizedStringKey { - chatType == .local - ? (chatName == "" ? "saved" : "saved from \(chatName)") - : "forwarded" + chatType == .local ? "saved" : "forwarded" } } diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index 7956ef1c17..e27ef696d4 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -3519,7 +3519,7 @@ chat item action */ "Saved from" = "Запазено от"; /* No comment provided by engineer. */ -"saved from %@" = "запазено от %@"; +"saved from" = "запазено от"; /* message info title */ "Saved message" = "Запазено съобщение"; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index cda649f3b9..dda49b67b8 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Abgespeichert von"; /* No comment provided by engineer. */ -"saved from %@" = "abgespeichert von %@"; +"saved from" = "abgespeichert von"; /* message info title */ "Saved message" = "Gespeicherte Nachricht"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index e0d29f36d8..5c72f88b2c 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Guardado desde"; /* No comment provided by engineer. */ -"saved from %@" = "Guardado desde %@"; +"saved from" = "Guardado desde"; /* message info title */ "Saved message" = "Mensaje guardado"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 51dce47465..c3bea2ff40 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Enregistré depuis"; /* No comment provided by engineer. */ -"saved from %@" = "enregistré à partir de %@"; +"saved from" = "enregistré à partir de"; /* message info title */ "Saved message" = "Message enregistré"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 2d719d6983..873fc56584 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Mentve innen"; /* No comment provided by engineer. */ -"saved from %@" = "mentve innen: %@"; +"saved from" = "mentve innen:"; /* message info title */ "Saved message" = "Mentett üzenet"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index c078f17a84..c57daef544 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Salvato da"; /* No comment provided by engineer. */ -"saved from %@" = "salvato da %@"; +"saved from" = "salvato da"; /* message info title */ "Saved message" = "Messaggio salvato"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 6715a09b80..d644779e7d 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -4411,7 +4411,7 @@ chat item action */ "Saved from" = "Opgeslagen van"; /* No comment provided by engineer. */ -"saved from %@" = "opgeslagen van %@"; +"saved from" = "opgeslagen van"; /* message info title */ "Saved message" = "Opgeslagen bericht"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 639d813104..a41a8bc0ea 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -4646,7 +4646,7 @@ chat item action */ "Saved from" = "Zapisane od"; /* No comment provided by engineer. */ -"saved from %@" = "zapisane od %@"; +"saved from" = "zapisane od"; /* message info title */ "Saved message" = "Zachowano wiadomość"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index b60e4ab35e..389cce9efe 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Сохранено из"; /* No comment provided by engineer. */ -"saved from %@" = "сохранено из %@"; +"saved from" = "сохранено из"; /* message info title */ "Saved message" = "Сохранённое сообщение"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index bf570fa52e..4ac1840c6e 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -4629,7 +4629,7 @@ chat item action */ "Saved from" = "Tarafından kaydedildi"; /* No comment provided by engineer. */ -"saved from %@" = "%@ tarafından kaydedildi"; +"saved from" = "kaydedildi:"; /* message info title */ "Saved message" = "Kaydedilmiş mesaj"; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index 55cce558f9..cd46b33067 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -4650,7 +4650,7 @@ chat item action */ "Saved from" = "Збережено з"; /* No comment provided by engineer. */ -"saved from %@" = "збережено з %@"; +"saved from" = "збережено з"; /* message info title */ "Saved message" = "Збережене повідомлення"; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 2781fce74a..b4ae54a6c5 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -5271,7 +5271,7 @@ chat item action */ "Saved from" = "保存自"; /* No comment provided by engineer. */ -"saved from %@" = "保存自 %@"; +"saved from" = "保存自"; /* message info title */ "Saved message" = "已保存的消息"; diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 11da8b874e..d8702d3109 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -3925,13 +3925,15 @@ enum class MsgDirection { sealed class CIForwardedFrom { @Serializable @SerialName("unknown") object Unknown: CIForwardedFrom() @Serializable @SerialName("contact") class Contact(override val chatName: String, val msgDir: MsgDirection, val contactId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom() - @Serializable @SerialName("group") class Group(override val chatName: String, val msgDir: MsgDirection, val groupId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom() + @Serializable @SerialName("group") class Group(override val chatName: String, val msgDir: MsgDirection, val groupId: Long? = null, val chatItemId: Long? = null, val memberId: String? = null, val sharedMsgId_: String? = null, val groupType: GroupType? = null): CIForwardedFrom() + @Serializable @SerialName("groupLink") class GroupLink(override val chatName: String, val msgDir: MsgDirection, val groupLink: String, val publicGroupId: String, val memberId: String? = null, val sharedMsgId: String, val groupType: GroupType? = null): CIForwardedFrom() open val chatName: String get() = when (this) { Unknown -> "" is Contact -> chatName is Group -> chatName + is GroupLink -> chatName } val chatTypeApiIdMsgId: Triple? @@ -3939,18 +3941,15 @@ sealed class CIForwardedFrom { Unknown -> null is Contact -> if (contactId != null) Triple(ChatType.Direct, contactId, chatItemId) else null is Group -> if (groupId != null) Triple(ChatType.Group, groupId, chatItemId) else null + is GroupLink -> null } + val sourceGroupLink: String? + get() = if (this is GroupLink) groupLink else null + fun text(chatType: ChatType): String = - if (chatType == ChatType.Local) { - if (chatName.isEmpty()) { - generalGetString(MR.strings.saved_description) - } else { - generalGetString(MR.strings.saved_from_description).format(chatName) - } - } else { - generalGetString(MR.strings.forwarded_description) - } + if (chatType == ChatType.Local) generalGetString(MR.strings.saved_description) + else generalGetString(MR.strings.forwarded_description) } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index cbd15aca67..c919859b1f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -23,6 +23,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.newchat.planAndConnect import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers @@ -101,14 +102,35 @@ fun FramedItemView( } @Composable - fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) { + fun HeaderText(caption: String, italic: Boolean, fontSize: TextUnit = 12.sp, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = buildAnnotatedString { + withStyle(SpanStyle(fontSize = fontSize, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = MaterialTheme.colors.secondary)) { + append(caption) + } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + @Composable + fun headerModifier(pad: Boolean, onClick: (() -> Unit)? = null): Modifier { val sentColor = MaterialTheme.appColors.sentQuote val receivedColor = MaterialTheme.appColors.receivedQuote + return Modifier + .background(if (sent) sentColor else receivedColor) + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp) + } + + @Composable + fun HeaderRow(modifier: Modifier, caption: String, italic: Boolean, icon: Painter?, iconColor: Color?) { Row( - Modifier - .background(if (sent) sentColor else receivedColor) - .fillMaxWidth() - .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp), + modifier, horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -120,19 +142,15 @@ fun FramedItemView( tint = iconColor ?: if (isInDarkTheme()) FileDark else FileLight ) } - Text( - buildAnnotatedString { - withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = MaterialTheme.colors.secondary)) { - append(caption) - } - }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + HeaderText(caption, italic) } } + @Composable + fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) { + HeaderRow(headerModifier(pad), caption, italic, icon, iconColor) + } + @Composable fun ciQuoteView(qi: CIQuote) { val sentColor = MaterialTheme.appColors.sentQuote @@ -293,8 +311,38 @@ fun FramedItemView( } } else { Header() - if (ci.meta.itemForwarded != null) { - FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + val forwarded = ci.meta.itemForwarded + if (forwarded != null) { + val twoRowHeader = if (chatInfo.chatType == ChatType.Local) { + forwarded.chatTypeApiIdMsgId != null || forwarded.sourceGroupLink != null + } else { + when (forwarded) { + is CIForwardedFrom.Group -> forwarded.groupType != null + is CIForwardedFrom.GroupLink -> true + else -> false + } + } + if (twoRowHeader) { + val caption = stringResource(if (chatInfo.chatType == ChatType.Local) MR.strings.saved_from else MR.strings.forwarded_from) + Column( + headerModifier(pad = true, onClick = { + val target = forwarded.chatTypeApiIdMsgId + val link = forwarded.sourceGroupLink + if (target != null) { + val (chatType, apiId, itemId) = target + withBGApi { openChat(secondaryChatsCtx = null, chat.remoteHostId, chatType, apiId, itemId) } + } else if (link != null) { + withBGApi { planAndConnect(chat.remoteHostId, link, close = null) } + } + }), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + HeaderRow(Modifier, caption, true, painterResource(MR.images.ic_forward), null) + HeaderText(forwarded.chatName, italic = false, fontSize = 15.sp, modifier = Modifier.offset(y = (-2).dp)) + } + } else { + FramedItemHeader(forwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + } } } if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 1d0004bb66..f66d9c8f0f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -1710,7 +1710,7 @@ لا يستطيع المُستلم/ون معرفة مَن أرسل هذه الرسالة. حُفظت حُفظت مِن - حُفظت مِن %s + حُفظت مِن السماعة سماعة الأذن سماعات الرأس diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index d7e9fc936e..9e1da97c27 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -65,8 +65,9 @@ LIVE moderated forwarded + forwarded from saved - saved from %s + saved from invalid chat invalid data error showing message diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index 83e10a81c3..65c19107c7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -1723,7 +1723,7 @@ Препращане и запазване на съобщения Звуци по време на разговор запазено - запазено от %s + запазено от Запазено Запазено от Получателят(ите) не могат да видят от кого е това съобщение. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index 1ab559c09c..5467ddf043 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -1573,7 +1573,7 @@ Notes privades la recepció de fitxers encara no està suportada sol·licitada connexió - desat des de %s + desat des de Adreça de contacte SimpleX Enllaç de grup SimpleX Enllaços SimpleX diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 0fca3db40e..5dfe53fb1c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1693,7 +1693,7 @@ Spolehlivější síťové připojení. Povolit odesílat SimpleX odkazy. uloženo - Uloženo z %s + Uloženo z Uloženo Přeposláno Uloženo z diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml index 21331d5889..03c9a49278 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -201,7 +201,7 @@ modereret videresendt gemt - gemt fra %s + gemt fra ugyldig chat ugyldige data fejl ved visning af besked diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 792b91edfd..f62d8256ba 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -1805,7 +1805,7 @@ Kopfhörer Gelijktijdige ontvangst Empfänger können nicht sehen, von wem die Nachricht stammt. - abgespeichert von %s + abgespeichert von Abgespeichert abgespeichert Weitergeleitet diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index d9feb33f1a..5caed9b913 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -2051,7 +2051,7 @@ αποθηκευμένο Αποθηκευμένο Αποθηκευμένο από - αποθηκευμένο από %s + αποθηκευμένο από Αποθηκευμένο μήνυμα Οι αποθηκευμένοι διακομιστές WebRTC ICE θα αφαιρεθούν. Αποθήκευση προφίλ ομάδας diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index a8cf66f595..41cb15e0c6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -1724,7 +1724,7 @@ todos los miembros Se permite enviar enlaces SimpleX. guardado - guardado desde %s + guardado desde Guardado Guardado desde Reenviado por diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index 6e119a456a..71387e9561 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -156,7 +156,7 @@ اجازه دهید تا اعلان‌ها را فوری دریافت کنید.]]> SimpleX در پس‌زمینه اجرا می‌شود و به جای استفاده از پوش نوتیفیکیشن، کار می‌کند.]]> ذخیره شده - ذخیره شده از %s + ذخیره شده از ذخیره شده از فرستاده شده رمزنگاری انتها به انتها با محرمانگی پیشرو، مردودسازی و بازیابی ورود غیرمجاز محافظت شده‌اند.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index c9f85754da..10b0215d3b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1725,7 +1725,7 @@ Casque audio La source du message reste privée. enregistré - enregistré depuis %s + enregistré depuis Transféré Transféré depuis Le(s) destinataire(s) ne peut(vent) pas voir de qui provient ce message. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index 2d29984da5..b64dc65119 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -590,7 +590,7 @@ Anonimni režim štiti Vašu privatnost koristeći novi nasumični profil za svaki kontakt. nedelje Interna greška - Sačuvano od %s + Sačuvano od sačuvano pozvan Sačuvana poruka diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index 950c9e193b..f6551d7572 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -1694,7 +1694,7 @@ A SimpleX-hivatkozások küldése engedélyezve van. Számukra engedélyezve mentett - mentve innen: %s + mentve innen: Továbbítva innen A címzett(ek) nem látja(k), hogy kitől származik ez az üzenet. Mentett diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml index 783b1a94c6..544e6d572e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -755,7 +755,7 @@ dimoderasi obrolan tidak valid diteruskan - disimpan dari %s + disimpan dari terima berkas belum didukung anda format pesan tak diketahui diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index edb2686c98..d5a4080b61 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1732,7 +1732,7 @@ Inoltra I destinatari non possono vedere da chi proviene questo messaggio. Salvato - salvato da %s + salvato da Bluetooth Auricolari Cuffie diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index ea9e504e98..efa9f0d1f8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1767,7 +1767,7 @@ חיבור קווי סלולרי נשמר - נשמר מ%s + נשמר מ הועבר הועבר מחובר לרשת diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index af501f9b20..61e887f092 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -1733,7 +1733,7 @@ より信頼性の高いネットワーク接続 ネットワーク管理 保存済 - %sから保存 + から保存 転送済 転送元 保存元 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index 87a0afa005..eeba2cb6c0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -1729,7 +1729,7 @@ Garsiakalbis Tinklo valdymas išsaugota - išsaugota iš %s + išsaugota iš Išsaugota Balso žinutės neleidžiamos WiFi diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml index 26b4c51aa3..673fe74c77 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml @@ -53,7 +53,7 @@ Jūs pārsūtīts saglabāts - saglabāts no %s + saglabāts no nederīga tērzēšana nederīgi dati kļūda, rādot ziņojumu diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index d162b8a44d..4db882608f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1722,7 +1722,7 @@ Sta het verzenden van SimpleX-links toe. Leden kunnen SimpleX-links verzenden. opgeslagen - opgeslagen van %s + opgeslagen van Doorsturen Doorgestuurd Ontvanger(s) kunnen niet zien van wie dit bericht afkomstig is. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index a25728ccaf..8792d7ffac 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -1731,7 +1731,7 @@ Przekaż wiadomość… Zapisane zapisane - zapisane od %s + zapisane od Bluetooth Przesyłaj dalej i zapisuj wiadomości Słuchawki douszne diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index ff691624ce..256f388cf7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -1809,7 +1809,7 @@ Erro ao exportar banco de dados Erro ao verificar a senha: salvo - salvo de %s + salvo de Encaminhado de Mensagens de voz não permitidas Nova mensagem diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index f691857fd3..dfde10dbbf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -195,7 +195,7 @@ Repetă cererea de alăturare? Reporniți conversația salvat - salvat de la %s + salvat de la Salvează Salvat Salvat din diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index bd4d3c39ff..e769453e0a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1799,7 +1799,7 @@ Более надёжное соединение с сетью. Статус сети сохранено - сохранено из %s + сохранено из Переслано Переслано из Получатели не видят от кого это сообщение. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 2fff0e4082..42eec6e0bc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -1720,7 +1720,7 @@ Litvanya Kullanıcı Arayüzü Diğer kaydedildi - %s tarafından kaydedildi + kaydedildi: İletildi Kaydedildi İndir diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index 439be068fa..5ef2a424cf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -1807,7 +1807,7 @@ Квадрат, коло або щось середнє між ними. Буде ввімкнено в прямих чатах! збережено - збережено з %s + збережено з Дротова мережа Ethernet Невідомі сервери! Без Tor або VPN ваша IP-адреса буде видимою для цих XFTP-ретрансляторів: diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index 5d97b21a1f..ca361a494b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -1594,7 +1594,7 @@ Các tùy chọn của cuộc trò chuyện được chọn không cho phép tin nhắn này. Quét mã QR đã lưu - đã lưu từ %s + đã lưu từ Đã lưu từ Quét mã QR từ máy tính Đã được bảo mật diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 957d051898..e4849cea39 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1718,7 +1718,7 @@ 已保存 已保存 保存自 - 保存自%s + 保存自 已转发 转发自 蓝牙 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index ad82cb8329..e0f280ee2e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -1935,7 +1935,7 @@ 已封存的報告 只有你和審核員能夠檢視 只有傳送者和審核員能夠檢視 - 已儲存自 %s + 已儲存自 此聊天受到端對端加密保護。 另一個原因 不當的個人檔案 diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 2d26f9bb2f..665dd26889 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -832,6 +832,19 @@ Group: - msgDir: [MsgDirection](#msgdirection) - groupId: int64? - chatItemId: int64? +- memberId: string? +- sharedMsgId_: string? +- groupType: [GroupType](#grouptype)? + +GroupLink: +- type: "groupLink" +- chatName: string +- msgDir: [MsgDirection](#msgdirection) +- groupLink: string +- publicGroupId: string +- memberId: string? +- sharedMsgId: string +- groupType: [GroupType](#grouptype)? --- diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 1779df783e..cae52090e7 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -803,10 +803,14 @@ export namespace CIFileStatus { } } -export type CIForwardedFrom = CIForwardedFrom.Unknown | CIForwardedFrom.Contact | CIForwardedFrom.Group +export type CIForwardedFrom = + | CIForwardedFrom.Unknown + | CIForwardedFrom.Contact + | CIForwardedFrom.Group + | CIForwardedFrom.GroupLink export namespace CIForwardedFrom { - export type Tag = "unknown" | "contact" | "group" + export type Tag = "unknown" | "contact" | "group" | "groupLink" interface Interface { type: Tag @@ -830,6 +834,20 @@ export namespace CIForwardedFrom { msgDir: MsgDirection groupId?: number // int64 chatItemId?: number // int64 + memberId?: string + sharedMsgId_?: string + groupType?: GroupType + } + + export interface GroupLink extends Interface { + type: "groupLink" + chatName: string + msgDir: MsgDirection + groupLink: string + publicGroupId: string + memberId?: string + sharedMsgId: string + groupType?: GroupType } } diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 4c30c83bf5..512a7464db 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -572,10 +572,28 @@ class CIForwardedFrom_group(TypedDict): msgDir: "MsgDirection" groupId: NotRequired[int] # int64 chatItemId: NotRequired[int] # int64 + memberId: NotRequired[str] + sharedMsgId_: NotRequired[str] + groupType: NotRequired["GroupType"] -CIForwardedFrom = CIForwardedFrom_unknown | CIForwardedFrom_contact | CIForwardedFrom_group +class CIForwardedFrom_groupLink(TypedDict): + type: Literal["groupLink"] + chatName: str + msgDir: "MsgDirection" + groupLink: str + publicGroupId: str + memberId: NotRequired[str] + sharedMsgId: str + groupType: NotRequired["GroupType"] -CIForwardedFrom_Tag = Literal["unknown", "contact", "group"] +CIForwardedFrom = ( + CIForwardedFrom_unknown + | CIForwardedFrom_contact + | CIForwardedFrom_group + | CIForwardedFrom_groupLink +) + +CIForwardedFrom_Tag = Literal["unknown", "contact", "group", "groupLink"] class CIGroupInvitation(TypedDict): groupId: int # int64 diff --git a/plans/2026-08-22-forward-link.md b/plans/2026-08-22-forward-link.md new file mode 100644 index 0000000000..091bfc60dd --- /dev/null +++ b/plans/2026-08-22-forward-link.md @@ -0,0 +1,185 @@ +# Forward attribution: `forwardLink` in MsgContainer + +When a message is forwarded from a channel (public group), the sending client +attaches the source channel's name, join link, identity and message id; +recipients see "forwarded from \" and can open or join the channel. + +- The link is attached whenever the source is a public group; for other sources + only `forward: true` is sent. +- `forward = Just True` is always set alongside `forwardLink`, so old clients + show plain "forwarded". +- The simplex name is not included: paired with a forwarder-chosen link it + would be an unverifiable claim. It can be added later as a verifiable claim. +- When a forwarded message is received in a group that prohibits SimpleX links + for the sender, the link is removed. + +## Protocol + +`Protocol.hs`. aeson ignores unknown fields and parses an absent field as +`Nothing`, so the addition is compatible in both directions. + +```haskell +data ForwardLink = ForwardLink + { displayName :: Text, + groupLink :: ShortLinkContact, + publicGroupId :: B64UrlByteString, -- the recipient looks up the local group by this id, then compares groupLink with the stored link + memberId :: Maybe MemberId, -- the author, only for items the author sent as themselves + msgId :: SharedMsgId -- the original item's SharedMsgId + } +``` + +`memberId` is absent for items sent as the channel: their authorship is the +channel's, and subscribers do not see the author's member id. The fill rule is +`chatItemMember` (Messages.hs:369): the member for received authored items, +the membership for own items sent as themselves, absent otherwise. + +- New field `forwardLink :: Maybe ForwardLink` in `MsgContainer` + (Protocol.hs:678) after `forward`; `mcSimple` (:695) sets + `forwardLink = Nothing`. +- `mcForward` (:716) takes `Maybe ForwardLink`: + `mcForward fl c = (mcSimple c) {forward = Just True, forwardLink = fl}`. +- JSON instances: `deriveJSON defaultJSON ''ForwardLink` before the + `''MsgContainer` splice (:899). + +## CIForwardedFrom + +`Messages.hs:1319`: + +```haskell + | CIFFGroup {chatName :: Text, msgDir :: MsgDirection, groupId :: Maybe GroupId, + chatItemId :: Maybe ChatItemId, memberId :: Maybe MemberId, + sharedMsgId_ :: Maybe SharedMsgId, groupType :: Maybe GroupType} + | CIFFGroupLink {chatName :: Text, msgDir :: MsgDirection, + groupLink :: ShortLinkContact, publicGroupId :: B64UrlByteString, + memberId :: Maybe MemberId, sharedMsgId :: SharedMsgId, + groupType :: Maybe GroupType} +``` + +Both variants retain the wire `memberId` and `sharedMsgId`, so a re-forward +re-serializes `ForwardLink` from the CIFF without item lookups. + +- `groupType` in `CIFFGroup` is present exactly when the sent message included + the link, so it doubles as that marker; the apps read it for the source type + icon without lookups. In `CIFFGroupLink` it mirrors the link's type so the + apps avoid inspecting the URI. +- `CIFFGroupLink` is the recipient's variant for an unknown channel; the user + opens it via the connection plan. +- New tag `CIFFGroupLink_` / `"groupLink"` in `CIForwardedFromTag` (:1325). + +## Sending + +`Commands.hs` `APIForwardChatItems`, `prepareForward` group branch (:1094-1110): + +- Local `ciff`: `CIFFGroup` with `memberId = memberId' <$> chatItemMember + gInfo ci`, the item's `itemSharedMsgId`, and `groupType = itemSharedMsgId *> + sourceGroupType gInfo` - `Just` the source profile's type under the same + condition in which `ciffForwardLink` later returns a link (the link value is + computed at the `mcForward` call site, after the `ciff` is built). +- The two `mcForward` call sites - `sendContactContentMessages.prepareMsgs` + (Commands.hs:4772) and `prepareGroupMsg` (Internal.hs:208-209), both matching + `(Nothing, Just _) -> pure (mcForward mc, Nothing)` on + `(quotedItemId, itemForwarded)` - compute the link from the + `CIForwardedFrom` in scope: `ciffForwardLink db ciff` returns the link for + `CIFFGroup` with `groupId` and `sharedMsgId` set, reading the group profile + (current name and link), for `CIFFGroupLink` from its stored fields, and + `Nothing` for other variants. Deriving from the stored `CIForwardedFrom` + attributes a re-forwarded message to the original source. +- `forwardCIFF` (:1130) already returns the original `CIForwardedFrom` when a + forwarded item is forwarded again, so a received `CIFFGroupLink` item is sent + onwards with the same link. + +## Receiving + +`Store/Messages.hs createNewRcvChatItem` (:563-572), inside the existing DB +transaction: + +```haskell +itemForwarded = case chatMsgEvent of + ACME _ (XMsgNew MsgContainer {forward, forwardLink}) | forward == Just True -> ... +``` + +1. `forwardLink = Nothing` -> `CIFFUnknown` (today's behavior). +2. Destination is a group where SimpleX links are prohibited for the sender -> + remove the link: store `CIFFGroup` with only the name and `msgDir = MDRcv` - + attribution text only. The check: the sender's role (the member's for + `CDGroupRcv`, `GROwner` for `CDChannelRcv` - a channel message is posted + with owner authority) against the group's SimplexLinks feature. Direct + chats: the link is kept. +3. Lookup by `publicGroupId`: `group_profiles.public_group_id` is a column + with an existing query that filters on it (Store/Groups.hs:2009-2015). New + query `getGroupViaPublicGroupId`; on a match, compare the received + `groupLink` with the stored one (`sameShortLinkContact`); when both match -> + `CIFFGroup` with `groupId`, the wire `memberId` and `msgId`, `groupType` + from the link's `ContactConnType` (equal to the stored link's type - + `sameShortLinkContact` compares it), and `chatItemId = ciId_` resolved by + the id query factored out of `getGroupChatItemBySharedMsgId` + (`getGroupChatItemBySharedMsgId_`). + The author scope: wire `memberId` absent -> `Nothing` (items sent as the + channel and own items are stored with `group_member_id` NULL); present -> + the member resolved by `member_id`, with the user's own membership mapped + to `Nothing`; an unknown member -> no item. +4. Lookup miss, or the link differs from the stored one -> `CIFFGroupLink` + with the wire fields. + +## DB + +`chat_items` persists `CIForwardedFrom` as columns (`fwd_from_tag, +fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, +fwd_from_chat_item_id`, Store/Messages.hs:606). Migration (SQLite + Postgres, +same shape) adds: + +- `fwd_from_group_type TEXT` (`GroupType`'s `TextEncoding`) +- `fwd_from_group_link BLOB/BYTEA` (the `ToField (ConnShortLink c)` instance + stores `Binary . strEncode`, matching `short_link_contact`) +- `fwd_from_public_group_id BLOB/BYTEA` +- `fwd_from_member_id BLOB/BYTEA` +- `fwd_from_shared_msg_id BLOB/BYTEA` + +Code changes: the CIFF-to-row tuple (Store/Messages.hs:657-660), the +row-to-CIFF case (:2343-2344), the three SELECT lists (:2696, :3085, :3197), +and the INSERT statement in `createNewChatItem_`. Binary columns use `Binary` +on both backends. + +## View / UI + +- `View.hs:1010`: render the source name for `CIFFGroup` and `CIFFGroupLink`. +- `/item info` renders "forwarded from: #\" from `itemForwarded` + when the source item is not stored locally (`CIFFGroupLink` and link-removed + `CIFFGroup`). +- The `CIForwardedFrom` JSON reaches the apps in `CIMeta`: the iOS + (`ChatTypes.swift`) and Kotlin (`ChatModel.kt`) mirrors are extended with the + new field and variant. +- The sender and the recipient of a forwarded message see the same header; the + only difference between them is the goto arrow, shown where the original + item exists locally (`chatTypeApiIdMsgId`), in notes too. +- Two-row header at double the single-header height - row 1: forward icon + + "forwarded from" ("saved from" in notes); row 2: the name in the header text + style, starting under the forward icon. Rendered when the attribution is + part of the message - `CIFFGroupLink`, and `CIFFGroup` with `groupType` + present - and in notes whenever navigation is possible (a local target or a + link), items saved from contacts and p2p groups included. The whole header + opens the source: known - the chat, positioned at the original item when + `chatItemId` is present; unknown - `planAndConnect` with `groupLink`. +- All other forwards keep the single-line header: "forwarded" (p2p forwards + without attribution, the link-removed name-only `CIFFGroup`) or "saved" + (non-navigable notes items). The `forwarded_from_description` and + `saved_from_description` strings are removed; "forwarded from" and + "saved from" are added. +- The goto arrow beside the bubble applies only to locally resolved items + (`chatTypeApiIdMsgId`), never to joining. + +## Tests + +`ChatTests/Groups.hs`: +1. Forward from a channel to a direct chat: the recipient item includes + `CIFFGroupLink` with name/link/publicGroupId/msgId; the view shows + "forwarded from" with the name. +2. Forward to a group where the recipient is a member of the source channel: + the recipient stores `CIFFGroup` with the local groupId. +3. Destination group with SimpleX links prohibited: the link is removed; + attribution text only. +4. Forwarding a received forwarded item again sends the original channel's + link. +5. Old-client compatibility: a container with `forward: true` and no + `forwardLink` parses to `CIFFUnknown`. +6. Private (non-public) source group: the container includes no `forwardLink`. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 28f78e1661..dce74ceece 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -154,6 +154,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations + Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link else exposed-modules: Simplex.Chat.Archive @@ -325,6 +326,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations + Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d98b387097..8629c463af 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -711,7 +711,7 @@ processChatCommand cxt nm = \case getForwardedFromItem user ChatItem {meta = CIMeta {itemForwarded}} = case itemForwarded of Just (CIFFContact _ _ (Just ctId) (Just fwdItemId)) -> Just <$> withFastStore (\db -> getAChatItem db cxt user (ChatRef CTDirect ctId Nothing) fwdItemId) - Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) -> + Just (CIFFGroup _ _ (Just gId) (Just fwdItemId) _ _ _) -> -- TODO [knocking] getAChatItem doesn't differentiate how to read based on scope - it should, instead of using group filter Just <$> withFastStore (\db -> getAChatItem db cxt user (ChatRef CTGroup gId Nothing) fwdItemId) _ -> pure Nothing @@ -1096,9 +1096,11 @@ processChatCommand cxt nm = \case catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items where ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq - ciComposeMsgReq gInfo (CChatItem md ci@ChatItem {mentions, formattedText}) (mc, file) = do + ciComposeMsgReq gInfo (CChatItem md ci@ChatItem {mentions, formattedText, meta = CIMeta {itemSharedMsgId}}) (mc, file) = do let itemId = chatItemId' ci - ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId)) + fwdMemberId = memberId' <$> chatItemMember gInfo ci + fwdGroupType = itemSharedMsgId *> sourceGroupType gInfo + ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId) fwdMemberId itemSharedMsgId fwdGroupType) -- updates text to reflect current mentioned member names (mc', _, mentions') = updatedMentionNames mc formattedText mentions -- only includes mentions when forwarding to the same group @@ -1108,6 +1110,8 @@ processChatCommand cxt nm = \case where forwardName :: GroupInfo -> ContactName forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName + sourceGroupType :: GroupInfo -> Maybe GroupType + sourceGroupType GroupInfo {groupProfile = GroupProfile {publicGroup}} = (\PublicGroupProfile {groupType} -> groupType) <$> publicGroup CTLocal -> do (_, items) <- getCommandLocalChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq ci <$$> prepareMsgReq ci) items @@ -4769,7 +4773,9 @@ processChatCommand cxt nm = \case forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _, _), fInv_) -> do (mc', quotedItem_) <- case (quotedItemId, itemForwarded) of (Nothing, Nothing) -> pure (mcSimple mc, Nothing) - (Nothing, Just _) -> pure (mcForward mc, Nothing) + (Nothing, Just ciff) -> do + fl_ <- liftIO $ ciffForwardLink db ciff + pure (mcForward fl_ mc, Nothing) (Just qiId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- getDirectChatItem db user contactId qiId diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index aa6974a7de..165371c1a3 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -208,7 +208,9 @@ prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> Maybe MsgScope -> ShowG prepareGroupMsg db user g@GroupInfo {membership} msgScope showGroupAsSender mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = do (mc', quotedItem_) <- case (quotedItemId_, itemForwarded) of (Nothing, Nothing) -> pure (mcSimple mc, Nothing) - (Nothing, Just _) -> pure (mcForward mc, Nothing) + (Nothing, Just ciff) -> do + fl_ <- liftIO $ ciffForwardLink db ciff + pure (mcForward fl_ mc, Nothing) (Just quotedItemId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <- getGroupCIWithReactions db user g quotedItemId @@ -231,6 +233,56 @@ prepareGroupMsg db user g@GroupInfo {membership} msgScope showGroupAsSender mc m quoteData ChatItem {chatDir = CIChannelRcv, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv Nothing, False, Nothing) quoteData _ _ = throwError SEInvalidQuote +-- re-forwarded message is attributed to the original source +ciffForwardLink :: DB.Connection -> CIForwardedFrom -> IO (Maybe ForwardLink) +ciffForwardLink db = \case + CIFFGroup {groupId = Just gId, memberId, sharedMsgId_ = Just msgId} -> + getGroupProfileById db gId >>= \case + Just GroupProfile {displayName, publicGroup = Just PublicGroupProfile {groupLink, publicGroupId}} -> + pure $ Just ForwardLink {displayName, groupLink, publicGroupId, memberId, msgId} + _ -> pure Nothing + CIFFGroupLink {chatName, groupLink, publicGroupId, memberId, sharedMsgId} -> + pure $ Just ForwardLink {displayName = chatName, groupLink, publicGroupId, memberId, msgId = sharedMsgId} + _ -> pure Nothing + +rcvForwardedFrom :: DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> IO (Maybe CIForwardedFrom) +rcvForwardedFrom db user chatDirection RcvMessage {chatMsgEvent} = case chatMsgEvent of + ACME _ (XMsgNew MsgContainer {forward = Just True, forwardLink}) -> case forwardLink of + Nothing -> pure $ Just CIFFUnknown + Just fl@ForwardLink {displayName} + | linkAllowed -> Just <$> forwardLinkCIFF db user fl + | otherwise -> pure $ Just $ CIFFGroup displayName MDRcv Nothing Nothing Nothing Nothing Nothing + _ -> pure Nothing + where + linkAllowed = case chatDirection of + CDGroupRcv gInfo _ GroupMember {memberRole} -> allowed memberRole gInfo + CDChannelRcv gInfo _ -> allowed GROwner gInfo + _ -> True + where + allowed role = groupFeatureMemberAllowed' SGFSimplexLinks role . fullGroupPreferences + +forwardLinkCIFF :: DB.Connection -> User -> ForwardLink -> IO CIForwardedFrom +forwardLinkCIFF db user ForwardLink {displayName, groupLink, publicGroupId, memberId, msgId} = + getGroupViaPublicGroupId db user publicGroupId >>= \case + Just (gId, Just storedLink) + | sameShortLinkContact groupLink storedLink -> do + ciId_ <- itemId_ gId + pure $ CIFFGroup displayName MDRcv (Just gId) ciId_ memberId (Just msgId) linkGroupType + _ -> pure $ CIFFGroupLink displayName MDRcv groupLink publicGroupId memberId msgId linkGroupType + where + linkGroupType = case groupLink of + CSLContact _ CCTChannel _ _ -> Just GTChannel + CSLContact _ CCTGroup _ _ -> Just GTGroup + _ -> Nothing + itemId_ gId = case memberId of + Nothing -> getGroupChatItemBySharedMsgId_ db user gId Nothing msgId + Just mId -> + getGroupMemberViaMemberId_ db user gId mId >>= \case + Just (gmId, category) -> + let scope = if category == GCUserMember then Nothing else Just gmId + in getGroupChatItemBySharedMsgId_ db user gId scope msgId + Nothing -> pure Nothing + updatedMentionNames :: MsgContent -> Maybe MarkdownList -> Map MemberName CIMention -> (MsgContent, Maybe MarkdownList, Map MemberName CIMention) updatedMentionNames mc ft_ mentions = case ft_ of Just ft @@ -2767,7 +2819,8 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem else pure $ toChatInfo cd let showAsGroup = case cd of CDChannelRcv {} -> True; _ -> False hasLink_ = ciContentHasLink content ft_ - (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention hasLink_ brokerTs createdAt + itemForwarded <- rcvForwardedFrom db user cd msg + (ciId, quotedItem) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemForwarded itemTimed live userMention hasLink_ brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd showAsGroup ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember (toMsgVerified (signMessagesRequired cd) msgSigned) createdAt ci' <- case toChatInfo cd of diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 836fb004fe..df3921f508 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -1319,13 +1319,15 @@ itemDeletedTs = \case data CIForwardedFrom = CIFFUnknown | CIFFContact {chatName :: Text, msgDir :: MsgDirection, contactId :: Maybe ContactId, chatItemId :: Maybe ChatItemId} - | CIFFGroup {chatName :: Text, msgDir :: MsgDirection, groupId :: Maybe GroupId, chatItemId :: Maybe ChatItemId} + | CIFFGroup {chatName :: Text, msgDir :: MsgDirection, groupId :: Maybe GroupId, chatItemId :: Maybe ChatItemId, memberId :: Maybe MemberId, sharedMsgId_ :: Maybe SharedMsgId, groupType :: Maybe GroupType} + | CIFFGroupLink {chatName :: Text, msgDir :: MsgDirection, groupLink :: ShortLinkContact, publicGroupId :: B64UrlByteString, memberId :: Maybe MemberId, sharedMsgId :: SharedMsgId, groupType :: Maybe GroupType} deriving (Show) data CIForwardedFromTag = CIFFUnknown_ | CIFFContact_ | CIFFGroup_ + | CIFFGroupLink_ instance FromField CIForwardedFromTag where fromField = fromTextField_ textDecode @@ -1336,11 +1338,13 @@ instance TextEncoding CIForwardedFromTag where "unknown" -> Just CIFFUnknown_ "contact" -> Just CIFFContact_ "group" -> Just CIFFGroup_ + "groupLink" -> Just CIFFGroupLink_ _ -> Nothing textEncode = \case CIFFUnknown_ -> "unknown" CIFFContact_ -> "contact" CIFFGroup_ -> "group" + CIFFGroupLink_ -> "groupLink" data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 2f57140200..37d33094b8 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -688,7 +688,17 @@ data MsgContainer = MsgContainer asGroup :: Maybe Bool, quote :: Maybe QuotedMsg, parent :: Maybe MsgRef, - forward :: Maybe Bool + forward :: Maybe Bool, + forwardLink :: Maybe ForwardLink + } + deriving (Eq, Show) + +data ForwardLink = ForwardLink + { displayName :: Text, + groupLink :: ShortLinkContact, + publicGroupId :: B64UrlByteString, + memberId :: Maybe MemberId, + msgId :: SharedMsgId } deriving (Eq, Show) @@ -704,7 +714,8 @@ mcSimple content = asGroup = Nothing, quote = Nothing, parent = Nothing, - forward = Nothing + forward = Nothing, + forwardLink = Nothing } mcQuote :: QuotedMsg -> MsgContent -> MsgContainer @@ -713,8 +724,8 @@ mcQuote q c = (mcSimple c) {quote = Just q} mcComment :: MsgRef -> MsgContent -> MsgContainer mcComment p c = (mcSimple c) {parent = Just p} -mcForward :: MsgContent -> MsgContainer -mcForward c = (mcSimple c) {forward = Just True} +mcForward :: Maybe ForwardLink -> MsgContent -> MsgContainer +mcForward fl c = (mcSimple c) {forward = Just True, forwardLink = fl} data MsgContent = MCText {text :: Text} @@ -896,6 +907,8 @@ instance ToJSON MsgContent where MCReport {text, reason} -> J.pairs $ "type" .= MCReport_ <> "text" .= text <> "reason" .= reason MCChat {text, chatLink, ownerSig} -> J.pairs $ "type" .= MCChat_ <> "text" .= text <> "chatLink" .= chatLink <> maybe mempty ("ownerSig" .=) ownerSig +$(JQ.deriveJSON defaultJSON ''ForwardLink) + $(JQ.deriveJSON defaultJSON ''MsgContainer) -- this limit reserves space for metadata in forwarded messages diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index c8fd232e3f..bac41a0dff 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -63,6 +63,7 @@ module Simplex.Chat.Store.Groups getGroupMemberByMemberId, getCreateUnknownGMByMemberId, getGroupMemberIdViaMemberId, + getGroupMemberViaMemberId_, getScopeMemberIdViaMemberId, getGroupMembers, getGroupMembersByIndexes, @@ -128,6 +129,8 @@ module Simplex.Chat.Store.Groups getRelayServedGroups, getRelayPublishableGroups, getRelayInactiveGroups, + getGroupViaPublicGroupId, + getGroupProfileById, createJoiningMember, getMemberJoinRequest, createJoiningMemberConnection, @@ -1212,11 +1215,16 @@ getScopeMemberIdViaMemberId db user g@GroupInfo {membership} sender scopeMemberI | otherwise = getGroupMemberIdViaMemberId db user g scopeMemberId getGroupMemberIdViaMemberId :: DB.Connection -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMemberId -getGroupMemberIdViaMemberId db User {userId} GroupInfo {groupId} memberId = - ExceptT . firstRow fromOnly (SEGroupMemberNotFoundByMemberId memberId) $ +getGroupMemberIdViaMemberId db user GroupInfo {groupId} memberId = do + m_ <- liftIO $ getGroupMemberViaMemberId_ db user groupId memberId + maybe (throwError $ SEGroupMemberNotFoundByMemberId memberId) (pure . fst) m_ + +getGroupMemberViaMemberId_ :: DB.Connection -> User -> GroupId -> MemberId -> IO (Maybe (GroupMemberId, GroupMemberCategory)) +getGroupMemberViaMemberId_ db User {userId} groupId memberId = + maybeFirstRow id $ DB.query db - "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ?" + "SELECT group_member_id, member_category FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ?" (userId, groupId, memberId) getGroupMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] @@ -2018,6 +2026,20 @@ getRelayPublishableGroups db User {userId, userContactId} = where toRow ((gId, pgId) :. accessRow) = (gId, pgId, toPublicGroupAccess accessRow) +getGroupViaPublicGroupId :: DB.Connection -> User -> B64UrlByteString -> IO (Maybe (GroupId, Maybe ShortLinkContact)) +getGroupViaPublicGroupId db User {userId} publicGroupId = + maybeFirstRow id $ + DB.query + db + [sql| + SELECT g.group_id, gp.group_link + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = ? AND gp.public_group_id = ? + LIMIT 1 + |] + (userId, publicGroupId) + getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime -> IO [GroupInfo] getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do currentTs <- getCurrentTime @@ -2759,26 +2781,28 @@ updateGroupPreferences db User {userId} g@GroupInfo {groupId, groupProfile = p} updateGroupProfileFromMember :: DB.Connection -> User -> GroupInfo -> Profile -> ExceptT StoreError IO GroupInfo updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName = n, fullName = fn, shortDescr = sd, description = descr, image = img} = do - p <- getGroupProfile -- to avoid any race conditions with UI + p_ <- liftIO $ getGroupProfileById db groupId -- to avoid any race conditions with UI + p <- maybe (throwError $ SEGroupNotFound groupId) pure p_ let g' = g {groupProfile = p} :: GroupInfo p' = p {displayName = n, fullName = fn, shortDescr = sd, description = descr, image = img} :: GroupProfile updateGroupProfile db user g' p' + +getGroupProfileById :: DB.Connection -> GroupId -> IO (Maybe GroupProfile) +getGroupProfileById db groupId = + maybeFirstRow toGroupProfile $ + DB.query + db + [sql| + SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, + gp.group_type, gp.group_link, gp.public_group_id, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, + gp.preferences, gp.member_admission + FROM group_profiles gp + JOIN groups g ON gp.group_profile_id = g.group_profile_id + WHERE g.group_id = ? + |] + (Only groupId) where - getGroupProfile = - ExceptT $ - firstRow toGroupProfile (SEGroupNotFound groupId) $ - DB.query - db - [sql| - SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, - gp.group_type, gp.group_link, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - gp.preferences, gp.member_admission - FROM group_profiles gp - JOIN groups g ON gp.group_profile_id = g.group_profile_id - WHERE g.group_id = ? - |] - (Only groupId) toGroupProfile ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (groupPreferences, memberAdmission)) = let publicGroupAccess = toPublicGroupAccess accessRow in GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ publicGroupAccess, groupPreferences, memberAdmission} diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 773d9c1f32..e5057f38dd 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -100,6 +100,7 @@ module Simplex.Chat.Store.Messages getDirectChatItem, getDirectCIWithReactions, getDirectChatItemBySharedMsgId, + getGroupChatItemBySharedMsgId_, getDirectChatItemsByAgentMsgId, getGroupChatItem, getGroupCIWithReactions, @@ -560,16 +561,13 @@ createNewSndChatItem db user chatDirection showGroupAsSender SndMessage {msgId, CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId) CIQGroupRcv Nothing -> (Just False, Nothing) -createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) -createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgSigned, signedMsg_, signedByGMId_, forwardedByMember} sharedMsgId_ ciContent timed live userMention hasLink itemTs createdAt = do +createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c)) +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgSigned, signedMsg_, signedByGMId_, forwardedByMember} sharedMsgId_ ciContent itemForwarded timed live userMention hasLink itemTs createdAt = do let showAsGroup = case chatDirection of CDChannelRcv {} -> True; _ -> False ciId <- createNewChatItem_ db user chatDirection showAsGroup (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember (toMsgVerified (signMessagesRequired chatDirection) msgSigned) signedMsg_ signedByGMId_ createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg - pure (ciId, quotedItem, itemForwarded) + pure (ciId, quotedItem) where - itemForwarded = case chatMsgEvent of - ACME _ (XMsgNew MsgContainer {forward}) | forward == Just True -> Just CIFFUnknown - _ -> Nothing quotedMsg = cmToQuotedMsg chatMsgEvent quoteRow :: NewQuoteRow quoteRow = case quotedMsg of @@ -603,8 +601,9 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from - fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id, + fwd_from_group_type, fwd_from_group_link, fwd_from_public_group_id, fwd_from_member_id, fwd_from_shared_msg_id + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((userId, msgId_) :. idsRow :. groupScopeRow :. itemRow :. quoteRow' :. forwardedFromRow) ciId <- insertedRowId db @@ -648,16 +647,20 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share SMDSnd -> isJust mcTag_ SMDRcv -> False mcTag_ = msgContentTag <$> ciMsgContent ciContent - forwardedFromRow :: (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) + forwardedFromRow :: ChatItemForwardedFromRow forwardedFromRow = case itemForwarded of Nothing -> - (Nothing, Nothing, Nothing, Nothing, Nothing, Nothing) + (Nothing, Nothing, Nothing, Nothing, Nothing, Nothing) :. noLinkRow Just CIFFUnknown -> - (Just CIFFUnknown_, Nothing, Nothing, Nothing, Nothing, Nothing) + (Just CIFFUnknown_, Nothing, Nothing, Nothing, Nothing, Nothing) :. noLinkRow Just CIFFContact {chatName, msgDir, contactId, chatItemId} -> - (Just CIFFContact_, Just chatName, Just msgDir, contactId, Nothing, chatItemId) - Just CIFFGroup {chatName, msgDir, groupId, chatItemId} -> - (Just CIFFGroup_, Just chatName, Just msgDir, Nothing, groupId, chatItemId) + (Just CIFFContact_, Just chatName, Just msgDir, contactId, Nothing, chatItemId) :. noLinkRow + Just CIFFGroup {chatName, msgDir, groupId, chatItemId, memberId, sharedMsgId_, groupType} -> + (Just CIFFGroup_, Just chatName, Just msgDir, Nothing, groupId, chatItemId) :. (groupType, Nothing, Nothing, memberId, sharedMsgId_) + Just CIFFGroupLink {chatName, msgDir, groupLink, publicGroupId, memberId, sharedMsgId = fwdSharedMsgId, groupType} -> + (Just CIFFGroupLink_, Just chatName, Just msgDir, Nothing, Nothing, Nothing) :. (groupType, Just groupLink, Just publicGroupId, memberId, Just fwdSharedMsgId) + noLinkRow :: ChatItemForwardedLinkRow + noLinkRow = (Nothing, Nothing, Nothing, Nothing, Nothing) ciTimedRow :: Maybe CITimed -> (Maybe Int, Maybe UTCTime) ciTimedRow (Just CITimed {ttl, deleteAt}) = (Just ttl, deleteAt) @@ -2277,7 +2280,9 @@ type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt, BoolInt, Maybe MsgVerified) -type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) +type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) :. ChatItemForwardedLinkRow + +type ChatItemForwardedLinkRow = (Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString, Maybe MemberId, Maybe SharedMsgId) type ChatItemRow = (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe BoolInt, Maybe SharedMsgId) @@ -2337,11 +2342,14 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} toCIForwardedFrom :: ChatItemForwardedFromRow -> Maybe CIForwardedFrom -toCIForwardedFrom (fwdFromTag, fwdFromChatName, fwdFromMsgDir, fwdFromContactId, fwdFromGroupId, fwdFromChatItemId) = - case (fwdFromTag, fwdFromChatName, fwdFromMsgDir, fwdFromContactId, fwdFromGroupId, fwdFromChatItemId) of +toCIForwardedFrom (fwdFromRow :. (groupType_, groupLink_, publicGroupId_, memberId_, sharedMsgId_)) = + case fwdFromRow of (Just CIFFUnknown_, Nothing, Nothing, Nothing, Nothing, Nothing) -> Just CIFFUnknown (Just CIFFContact_, Just chatName, Just msgDir, contactId, Nothing, ciId) -> Just $ CIFFContact chatName msgDir contactId ciId - (Just CIFFGroup_, Just chatName, Just msgDir, Nothing, groupId, ciId) -> Just $ CIFFGroup chatName msgDir groupId ciId + (Just CIFFGroup_, Just chatName, Just msgDir, Nothing, groupId, ciId) -> Just $ CIFFGroup chatName msgDir groupId ciId memberId_ sharedMsgId_ groupType_ + (Just CIFFGroupLink_, Just chatName, Just msgDir, Nothing, Nothing, Nothing) + | Just groupLink <- groupLink_, Just publicGroupId <- publicGroupId_, Just sharedMsgId <- sharedMsgId_ -> + Just $ CIFFGroupLink chatName msgDir groupLink publicGroupId memberId_ sharedMsgId groupType_ _ -> Nothing type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow @@ -2694,6 +2702,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, @@ -3030,21 +3039,25 @@ markReceivedGroupReportsDeleted db User {userId} GroupInfo {groupId, membership} (DBCIDeleted, deletedTs, groupMemberId' membership, currentTs, userId, groupId, MCReport_, DBCINotDeleted) getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupInfo -> Maybe GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) -getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupMemberId_ sharedMsgId = do - itemId <- - ExceptT . firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ - DB.query - db - [sql| - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? - ORDER BY chat_item_id DESC - LIMIT 1 - |] - (userId, groupId, groupMemberId_, sharedMsgId) +getGroupChatItemBySharedMsgId db user g@GroupInfo {groupId} groupMemberId_ sharedMsgId = do + itemId_ <- liftIO $ getGroupChatItemBySharedMsgId_ db user groupId groupMemberId_ sharedMsgId + itemId <- maybe (throwError $ SEChatItemSharedMsgIdNotFound sharedMsgId) pure itemId_ getGroupCIWithReactions db user g itemId +getGroupChatItemBySharedMsgId_ :: DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> SharedMsgId -> IO (Maybe ChatItemId) +getGroupChatItemBySharedMsgId_ db User {userId} groupId groupMemberId_ sharedMsgId = + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + |] + (userId, groupId, groupMemberId_, sharedMsgId) + getGroupMemberCIBySharedMsgId :: DB.Connection -> User -> GroupInfo -> MemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupMemberCIBySharedMsgId db user@User {userId} g@GroupInfo {groupId} memberId sharedMsgId = do itemId <- @@ -3083,6 +3096,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, @@ -3195,6 +3209,7 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 62021bbe4b..a8b4958de4 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -47,6 +47,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history import Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles import Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection import Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations +import Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -93,7 +94,8 @@ schemaMigrations = ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history), ("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles), ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection), - ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations) + ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), + ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260822_forward_link.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260822_forward_link.hs new file mode 100644 index 0000000000..d8cf34448d --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260822_forward_link.hs @@ -0,0 +1,27 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260822_forward_link :: Text +m20260822_forward_link = + [r| +ALTER TABLE chat_items ADD COLUMN fwd_from_group_type TEXT; +ALTER TABLE chat_items ADD COLUMN fwd_from_group_link BYTEA; +ALTER TABLE chat_items ADD COLUMN fwd_from_public_group_id BYTEA; +ALTER TABLE chat_items ADD COLUMN fwd_from_member_id BYTEA; +ALTER TABLE chat_items ADD COLUMN fwd_from_shared_msg_id BYTEA; +|] + +down_m20260822_forward_link :: Text +down_m20260822_forward_link = + [r| +ALTER TABLE chat_items DROP COLUMN fwd_from_group_type; +ALTER TABLE chat_items DROP COLUMN fwd_from_group_link; +ALTER TABLE chat_items DROP COLUMN fwd_from_public_group_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_member_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_shared_msg_id; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 0978a54115..ab6384cb29 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -349,7 +349,12 @@ CREATE TABLE test_chat_schema.chat_items ( item_msg_body bytea, item_chat_binding text, item_signatures bytea, - item_signed_by_group_member_id bigint + item_signed_by_group_member_id bigint, + fwd_from_group_type text, + fwd_from_group_link bytea, + fwd_from_public_group_id bytea, + fwd_from_member_id bytea, + fwd_from_shared_msg_id bytea ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index cb046f6bc5..de5acc3c82 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -170,6 +170,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history import Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles import Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection import Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations +import Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -339,7 +340,8 @@ schemaMigrations = ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history), ("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles), ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection), - ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations) + ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), + ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260822_forward_link.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260822_forward_link.hs new file mode 100644 index 0000000000..e10352521d --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260822_forward_link.hs @@ -0,0 +1,26 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260822_forward_link :: Query +m20260822_forward_link = + [sql| +ALTER TABLE chat_items ADD COLUMN fwd_from_group_type TEXT; +ALTER TABLE chat_items ADD COLUMN fwd_from_group_link BLOB; +ALTER TABLE chat_items ADD COLUMN fwd_from_public_group_id BLOB; +ALTER TABLE chat_items ADD COLUMN fwd_from_member_id BLOB; +ALTER TABLE chat_items ADD COLUMN fwd_from_shared_msg_id BLOB; +|] + +down_m20260822_forward_link :: Query +down_m20260822_forward_link = + [sql| +ALTER TABLE chat_items DROP COLUMN fwd_from_group_type; +ALTER TABLE chat_items DROP COLUMN fwd_from_group_link; +ALTER TABLE chat_items DROP COLUMN fwd_from_public_group_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_member_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_shared_msg_id; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index a49a6d0db7..a488d7da21 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -514,7 +514,12 @@ CREATE TABLE chat_items( item_msg_body BLOB, item_chat_binding TEXT, item_signatures BLOB, - item_signed_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL + item_signed_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, + fwd_from_group_type TEXT, + fwd_from_group_link BLOB, + fwd_from_public_group_id BLOB, + fwd_from_member_id BLOB, + fwd_from_shared_msg_id BLOB ) STRICT; CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 985e3c0666..6731b62fd6 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -790,7 +790,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa prohibited = styled (colored Red) ("[unexpected chat item created, please report to developers]" :: String) viewChatItemInfo :: AChatItem -> ChatItemInfo -> TimeZone -> [StyledString] -viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTimed, createdAt}}) ChatItemInfo {itemVersions, forwardedFromChatItem} tz = +viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTimed, createdAt, itemForwarded}}) ChatItemInfo {itemVersions, forwardedFromChatItem} tz = ["sent at: " <> ts itemTs] <> receivedAt <> toBeDeletedAt @@ -822,7 +822,10 @@ viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTime (SMDRcv, GroupChat gInfo _scopeInfo) -> Just $ "#" <> viewGroupName gInfo _ -> Nothing fwdItemId = "chat item id: " <> (T.pack . show $ aChatItemId fwdACI) - _ -> [] + _ -> case itemForwarded of + Just (CIFFGroup g _ _ _ _ _ _) -> ["forwarded from: #" <> (plain . viewName) g] + Just (CIFFGroupLink g _ _ _ _ _ _) -> ["forwarded from: #" <> (plain . viewName) g] + _ -> [] localTs :: TimeZone -> UTCTime -> String localTs tz ts = do @@ -1010,8 +1013,9 @@ forwardedFrom = \case CIFFUnknown -> ["-> forwarded"] CIFFContact c MDSnd _ _ -> ["<- you @" <> (plain . viewName) c] CIFFContact c MDRcv _ _ -> ["<- @" <> (plain . viewName) c] - CIFFGroup g MDSnd _ _ -> ["<- you #" <> (plain . viewName) g] - CIFFGroup g MDRcv _ _ -> ["<- #" <> (plain . viewName) g] + CIFFGroup g MDSnd _ _ _ _ _ -> ["<- you #" <> (plain . viewName) g] + CIFFGroup g MDRcv _ _ _ _ _ -> ["<- #" <> (plain . viewName) g] + CIFFGroupLink g _ _ _ _ _ _ -> ["<- #" <> (plain . viewName) g] sentByMember :: GroupInfo -> CIQDirection 'CTGroup -> Maybe GroupMember sentByMember GroupInfo {membership} = \case diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs index 483c2269b1..91ad46ba43 100644 --- a/tests/ChatTests/Forward.hs +++ b/tests/ChatTests/Forward.hs @@ -5,8 +5,10 @@ module ChatTests.Forward where import ChatClient import ChatTests.DBUtils +import ChatTests.Groups (memberJoinChannel, prepareChannel1Relay) import ChatTests.Utils import Control.Concurrent (threadDelay) +import Control.Concurrent.Async (concurrently_) import qualified Data.ByteString.Char8 as B import Data.List (intercalate) import qualified Data.Text as T @@ -18,6 +20,9 @@ import Test.Hspec hiding (it) chatForwardTests :: SpecWith TestParams chatForwardTests = do describe "forward messages" $ do + it "from channel: the recipient receives the channel link" testForwardChannelToContact + it "from channel: the channel is known to the recipient" testForwardChannelKnownGroup + it "from channel: the link is removed when the destination group prohibits links" testForwardChannelLinkRemoved it "from contact to contact" testForwardContactToContact it "from contact to group" testForwardContactToGroup it "from contact to notes" testForwardContactToNotes @@ -43,6 +48,113 @@ chatForwardTests = do it "from group to group" testForwardGroupToGroupMulti it "with relative paths: multiple files from contact to contact" testMultiForwardFiles +testForwardChannelToContact :: HasCallStack => TestParams -> IO () +testForwardChannelToContact ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + connectUsers cath dan + alice #> "#team hi" + bob <# "#team> hi" + cath <# "#team> hi [>>]" + threadDelay 1000000 + -- the channel is not known to dan: the item includes the channel name and link + cath `send` "@dan <- #team hi" + cath <# "@dan <- #team" + cath <## " hi" + dan <# "cath> <- #team" + dan <## " hi" + dan ##> "/item info @cath hi" + dan <##. "sent at: " + dan <##. "received at: " + dan <## "message history:" + dan .<## ": hi" + dan <## "forwarded from: #team" + -- forwarding the received item onwards sends the same link + connectUsers dan alice + dan `send` "@alice <- @cath hi" + dan <# "@alice <- #team" + dan <## " hi" + alice <# "dan> <- #team" + alice <## " hi" + +testForwardChannelKnownGroup :: HasCallStack => TestParams -> IO () +testForwardChannelKnownGroup ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + connectUsers cath dan + alice #> "#team hi" + bob <# "#team> hi" + cath <# "#team> hi [>>]" + dan <# "#team> hi [>>]" + threadDelay 1000000 + -- the channel is known to dan: the item references the local group + cath `send` "@dan <- #team hi" + cath <# "@dan <- #team" + cath <## " hi" + dan <# "cath> <- #team" + dan <## " hi" + -- forwarding onwards rebuilds the link from the local group + dan ##> "/c" + inv <- getInvitation dan + alice ##> ("/c " <> inv) + alice <## "confirmation sent!" + concurrently_ + (alice <## "dan_1 (Daniel): contact is connected") + (dan <## "alice_1 (Alice): contact is connected") + dan `send` "@alice_1 <- @cath hi" + dan <# "@alice_1 <- #team" + dan <## " hi" + alice <# "dan_1> <- #team" + alice <## " hi" + +testForwardChannelLinkRemoved :: HasCallStack => TestParams -> IO () +testForwardChannelLinkRemoved ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + createGroup2 "club" cath dan + cath ##> "/set links #club off" + cath <## "updated group preferences:" + cath <## "SimpleX links: off" + dan <## "cath updated group #club:" + dan <## "updated group preferences:" + dan <## "SimpleX links: off" + alice #> "#team hi" + bob <# "#team> hi" + cath <# "#team> hi [>>]" + threadDelay 1000000 + cath `send` "#club <- #team hi" + cath <# "#club <- #team" + cath <## " hi" + -- the link is removed; the name text remains + dan <# "#club cath> <- #team" + dan <## " hi" + dan ##> "/item info #club hi" + dan <##. "sent at: " + dan <##. "received at: " + dan <## "message history:" + dan .<## ": hi" + dan <## "forwarded from: #team" + -- forwarding the received item onwards sends no link + connectUsers dan alice + dan `send` "@alice <- #club hi" + dan <# "@alice <- #team" + dan <## " hi" + alice <# "dan> -> forwarded" + alice <## " hi" + testForwardContactToContact :: HasCallStack => TestParams -> IO () testForwardContactToContact = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index f5b55fbddd..cf613a1b26 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -81,6 +81,16 @@ testE2ERatchetParams = E2ERatchetParamsUri supportedE2EEncryptVRange testDhPubKe testConnReq :: ConnectionRequestUri 'CMInvitation testConnReq = CRInvitationUri connReqData testE2ERatchetParams +testForwardLink :: ForwardLink +testForwardLink = + ForwardLink + { displayName = "team", + groupLink = CSLContact SLSSimplex CCTChannel srv (LinkKey "\1\2\3\4\5\6\7\8\1\2\3\4\5\6\7\8\1\2\3\4\5\6\7\8\1\2\3\4\5\6\7\8"), + publicGroupId = B64UrlByteString "\1\2\3\4", + memberId = Just $ MemberId "\1\2\3\4", + msgId = SharedMsgId "\5\6\7\8" + } + quotedMsg :: QuotedMsg quotedMsg = QuotedMsg @@ -200,13 +210,19 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do (XMsgNew ((mcQuote quotedMsg (MCText "hello to you too")) {live = Just True})) it "x.msg.new forward" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward (MCText "hello")) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward Nothing (MCText "hello")) it "x.msg.new forward - timed message TTL" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward (MCText "hello")) {ttl = Just 3600}) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {ttl = Just 3600}) it "x.msg.new forward - live message" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward (MCText "hello")) {live = Just True}) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {live = Just True}) + it "x.msg.new forward with channel link" $ + "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"forwardLink\":{\"displayName\":\"team\",\"groupLink\":\"simplex:/c#AQIDBAUGBwgBAgMEBQYHCAECAwQFBgcIAQIDBAUGBwg?h=smp.simplex.im&p=5223&c=1234-w\",\"publicGroupId\":\"AQIDBA==\",\"memberId\":\"AQIDBA==\",\"msgId\":\"BQYHCA==\"}}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward (Just testForwardLink) (MCText "hello")) + it "x.msg.new forward with channel link without author" $ + "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"forwardLink\":{\"displayName\":\"team\",\"groupLink\":\"simplex:/c#AQIDBAUGBwgBAgMEBQYHCAECAwQFBgcIAQIDBAUGBwg?h=smp.simplex.im&p=5223&c=1234-w\",\"publicGroupId\":\"AQIDBA==\",\"msgId\":\"BQYHCA==\"}}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward (Just (testForwardLink {memberId = Nothing} :: ForwardLink)) (MCText "hello")) it "x.msg.new simple text with file" $ "{\"v\":\"9\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XMsgNew ((mcSimple (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}}) @@ -230,7 +246,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do (XMsgNew (mcQuote quotedMsg (MCReport "" RRSpam))) it "x.msg.new forward with file" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"},\"forward\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}}) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}}) it "x.msg.update" $ "{\"v\":\"9\",\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing Nothing Nothing From 0210591af7ad288798592b446095f219f82f452f Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:40:13 +0400 Subject: [PATCH 04/28] desktop: fix nanohttpd submodule check never running (#7408) --- .../external/nanohttpd/build.gradle.kts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/multiplatform/external/nanohttpd/build.gradle.kts b/apps/multiplatform/external/nanohttpd/build.gradle.kts index fb24922208..7841d8f803 100644 --- a/apps/multiplatform/external/nanohttpd/build.gradle.kts +++ b/apps/multiplatform/external/nanohttpd/build.gradle.kts @@ -26,16 +26,25 @@ java { targetCompatibility = jvmVersion } -// Without this the jar records the build machine's timestamps, file order and file modes, -// which makes the desktop packages unreproducible -tasks.jar { - // Checked here and not during configuration, so that Android builds, which don't use nanohttpd, - // work without the submodule +val upstreamSources = sourceSets.main.get().java.matching { include("org/nanohttpd/**") } + +// compileJava and jar silently succeed without the sources, so the check needs its own task. +// It cannot run during configuration, Android builds must work without the submodule. +val checkUpstreamSources by tasks.registering { doFirst { - if (!upstream.file("core/src/main/java").asFile.isDirectory) { + if (upstreamSources.isEmpty) { throw GradleException("nanohttpd sources are missing, run: git submodule update --init --recursive") } } +} + +tasks.compileJava { + dependsOn(checkUpstreamSources) +} + +// Without this the jar records the build machine's timestamps, file order and file modes, +// which makes the desktop packages unreproducible +tasks.jar { isPreserveFileTimestamps = false isReproducibleFileOrder = true filePermissions { unix("644") } From c14770ce51f50d5b3ea2e04b471385b47a6cd4a4 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 25 Aug 2026 13:13:16 +0100 Subject: [PATCH 05/28] core: fix test for aborting connection switching (pin to previous version) (#7418) --- .../SQLite/Migrations/chat_query_plans.txt | 73 +++++++++++-------- tests/ChatTests/Direct.hs | 4 +- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 2edce7cc3c..5105f7b610 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -437,7 +437,7 @@ Query: AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL OR (g.enable_ntfs = 2 AND i.user_mention = 1)) Plan: -SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_group_scope_stats_all (user_id=?) SEARCH g USING INTEGER PRIMARY KEY (rowid=?) Query: @@ -1035,19 +1035,6 @@ Query: Plan: SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_next (group_id=? AND worker_scope=? AND failed=? AND task_status=?) -Query: - SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, - gp.group_type, gp.group_link, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - gp.preferences, gp.member_admission - FROM group_profiles gp - JOIN groups g ON gp.group_profile_id = g.group_profile_id - WHERE g.group_id = ? - -Plan: -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT group_id FROM groups @@ -1367,6 +1354,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol @@ -1384,6 +1372,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, @@ -1440,6 +1429,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, @@ -1528,16 +1518,6 @@ Plan: SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? - ORDER BY chat_item_id DESC - LIMIT 1 - -Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) - Query: SELECT chat_item_id FROM chat_items @@ -3596,6 +3576,16 @@ Query: Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) + Query: SELECT chat_item_id FROM chat_items @@ -3844,6 +3834,17 @@ Plan: SEARCH g USING INDEX sqlite_autoindex_groups_2 (user_id=?) SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT g.group_id, gp.group_link + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = ? AND gp.public_group_id = ? + LIMIT 1 + +Plan: +SEARCH g USING COVERING INDEX sqlite_autoindex_groups_2 (user_id=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT g.group_id, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof @@ -3858,6 +3859,19 @@ SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) SEARCH g USING INTEGER PRIMARY KEY (rowid=?) SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, + gp.group_type, gp.group_link, gp.public_group_id, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, + gp.preferences, gp.member_admission + FROM group_profiles gp + JOIN groups g ON gp.group_profile_id = g.group_profile_id + WHERE g.group_id = ? + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT group_member_id FROM group_members @@ -4791,8 +4805,9 @@ Query: -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from - fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id, + fwd_from_group_type, fwd_from_group_link, fwd_from_public_group_id, fwd_from_member_id, fwd_from_shared_msg_id + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -6007,7 +6022,7 @@ Query: JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? Plan: -SEARCH i USING COVERING INDEX idx_chat_items_groups_item_viewed (user_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_user_id_item_status (user_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -6941,7 +6956,7 @@ SEARCH protocol_servers USING COVERING INDEX idx_smp_servers_user_id (user_id=?) SEARCH settings USING COVERING INDEX idx_settings_user_id (user_id=?) SEARCH commands USING COVERING INDEX idx_commands_user_id (user_id=?) SEARCH calls USING COVERING INDEX idx_calls_user_id (user_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_viewed (user_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_user_id_item_status (user_id=?) SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_2 (user_id=?) SEARCH user_contact_links USING COVERING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) SEARCH connections USING COVERING INDEX idx_connections_to_subscribe (user_id=?) @@ -7355,7 +7370,7 @@ Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id Plan: SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) -Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ? +Query: SELECT group_member_id, member_category FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ? Plan: SEARCH group_members USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index a828d428d3..732bc465b4 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -2898,7 +2898,7 @@ testSwitchContact = testAbortSwitchContact :: HasCallStack => TestParams -> IO () testAbortSwitchContact ps = do - withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps testCfgVPrev "alice" aliceProfile $ \alice -> do withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #$> ("/switch bob", id, "switch started") @@ -2945,7 +2945,7 @@ testSwitchGroupMember = testAbortSwitchGroupMember :: HasCallStack => TestParams -> IO () testAbortSwitchGroupMember ps = do - withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps testCfgVPrev "alice" aliceProfile $ \alice -> do withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #$> ("/switch #team bob", id, "switch started") From 315b3f932d2b0dcde609dbe59b259990e0a2b2bd Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 26 Aug 2026 08:45:22 +0100 Subject: [PATCH 06/28] website: livestream page (#7420) * website: livestream page * update livestream page * fix footer * footer years * add link --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- website/langs/ar.json | 2 +- website/langs/bg.json | 2 +- website/langs/cs.json | 2 +- website/langs/de.json | 2 +- website/langs/en.json | 2 +- website/langs/es.json | 2 +- website/langs/fi.json | 2 +- website/langs/fr.json | 2 +- website/langs/he.json | 2 +- website/langs/id.json | 2 +- website/langs/it.json | 2 +- website/langs/ja.json | 2 +- website/langs/nl.json | 2 +- website/langs/pl.json | 2 +- website/langs/pt_BR.json | 2 +- website/langs/ro.json | 2 +- website/langs/ru.json | 2 +- website/langs/tr.json | 2 +- website/langs/uk.json | 2 +- website/langs/zh_Hans.json | 2 +- website/langs/zh_Hant.json | 2 +- website/src/_includes/navbar.html | 2 +- website/src/crowdfunding.md | 35 ++- website/src/css/livestream.css | 294 ++++++++++++++++++++++ website/src/img/crowdfunding/why-now.webp | Bin 128278 -> 0 bytes website/src/js/livestream.js | 71 ++++++ website/src/livestream.html | 116 +++++++++ 27 files changed, 529 insertions(+), 31 deletions(-) create mode 100644 website/src/css/livestream.css delete mode 100644 website/src/img/crowdfunding/why-now.webp create mode 100644 website/src/js/livestream.js create mode 100644 website/src/livestream.html diff --git a/website/langs/ar.json b/website/langs/ar.json index d74f6db9ff..3f5c220f19 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -32,7 +32,7 @@ "simplex-explained-tab-2-p-1": "لكل اتصال، تستخدم قائمتي انتظار منفصلتين للمُراسلة لإرسال واستلام الرسائل عبر خوادم مختلفة.", "simplex-explained-tab-2-p-2": "تمرّر الخوادم الرسائل في اتجاه واحد فقط، دون الحصول على الصورة الكاملة لمُحادثات المستخدم أو اتصالاته.", "simplex-explained-tab-3-p-1": "تحتوي الخوادم على بيانات اعتماد مجهولة منفصلة لكل قائمة انتظار، ولا تعرف المستخدمين الذين ينتمون إليهم.", - "copyright-label": "مشروع مفتوح المصدر © © 2020-2025 SimpleX Chat | مشروع مفتوح المصدرSimpleX 2020-2025", + "copyright-label": "مشروع مفتوح المصدر © © 2020-2026 SimpleX Chat | مشروع مفتوح المصدرSimpleX 2020-2026", "simplex-chat-protocol": "بروتوكول دردشة SimpleX", "developers": "المطورين", "hero-subheader": "أول نظام مُراسلة
دون معرّفات مُستخدم", diff --git a/website/langs/bg.json b/website/langs/bg.json index b6391ee85e..990d23ad7e 100644 --- a/website/langs/bg.json +++ b/website/langs/bg.json @@ -26,7 +26,7 @@ "index-token-p1": "За да запазят независимостта си, големите канали и общности ще плащат за сървърите си.", "index-token-p2-cf": "А сега потребителите могат да инвестират в SimpleX Chat — краудфандингът е активен!", "index-token-cta-cf": "Инвестирайте в SimpleX Chat", - "copyright-label": "© 2020-2025 SimpleX Chat | Проект с отворен код", + "copyright-label": "© 2020-2026 SimpleX Chat | Проект с отворен код", "simplex-chat-protocol": "SimpleX Чат протокол", "terminal-cli": "Системна конзола", "terms-and-privacy-policy": "Политика за поверителност", diff --git a/website/langs/cs.json b/website/langs/cs.json index f73de01ff2..ef06c53676 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -26,7 +26,7 @@ "chat-protocol": "Chat protokol", "donate": "Darovat", "invest": "Investovat", - "copyright-label": "© 2020-2025 SimpleX Chat | Projekt s otevřeným zdrojovým kódem", + "copyright-label": "© 2020-2026 SimpleX Chat | Projekt s otevřeným zdrojovým kódem", "simplex-chat-protocol": "SimpleX Chat protokol", "terminal-cli": "Terminálové rozhraní příkazového řádku", "terms-and-privacy-policy": "Ochrana soukromí", diff --git a/website/langs/de.json b/website/langs/de.json index 8962368b49..ac35879b3a 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -22,7 +22,7 @@ "chat-bot-example": "Beispiel für einen Chatbot", "donate": "Spenden", "invest": "Investieren", - "copyright-label": "© 2020-2025 SimpleX Chat | Open-Source-Projekt", + "copyright-label": "© 2020-2026 SimpleX Chat | Open-Source-Projekt", "chat-protocol": "Chat-Protokoll", "simplex-chat-protocol": "SimpleX Chat-Protokoll", "terminal-cli": "Terminal-Kommandozeilen-Schnittstelle", diff --git a/website/langs/en.json b/website/langs/en.json index 9f67576ef7..8e4fba8470 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -24,7 +24,7 @@ "chat-protocol": "Chat protocol", "donate": "Donate", "invest": "Invest", - "copyright-label": "© 2020-2025 SimpleX Chat | Open-Source Project", + "copyright-label": "© 2020-2026 SimpleX Chat | Open-Source Project", "simplex-chat-protocol": "SimpleX Chat protocol", "terminal-cli": "Terminal CLI", "about-and-contact-us": "About & Contact us", diff --git a/website/langs/es.json b/website/langs/es.json index 05ba4779c8..c9e0747e7a 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -11,7 +11,7 @@ "smp-protocol": "Protocolo SMP", "donate": "Donación", "invest": "Invertir", - "copyright-label": "© 2020-2025 SimpleX Chat | Proyecto de Código Abierto", + "copyright-label": "© 2020-2026 SimpleX Chat | Proyecto de Código Abierto", "simplex-chat-protocol": "Protocolo SimpleX Chat", "terms-and-privacy-policy": "Política de Privacidad", "hero-header": "Privacidad redefinida", diff --git a/website/langs/fi.json b/website/langs/fi.json index e0768634a1..3a138aa7c4 100644 --- a/website/langs/fi.json +++ b/website/langs/fi.json @@ -117,7 +117,7 @@ "index-token-p1": "Pysyäkseen riippumattomina suuret kanavat ja yhteisöt maksavat palvelimistaan.", "index-token-p2-cf": "Ja käyttäjät voivat nyt sijoittaa SimpleX Chatiin — joukkorahoitus on käynnissä!", "index-token-cta-cf": "Sijoita SimpleX Chatiin", - "copyright-label": "© 2020-2025 SimpleX Chat | Avoin projekti", + "copyright-label": "© 2020-2026 SimpleX Chat | Avoin projekti", "hero-p-1": "Muissa sovelluksissa on käyttäjätunnuksia: Signal, Matrix, Session, Briar, Jami, Cwtch, jne.
SimpleX ei käytä niitä, ei edes satunnaisia numeroita.
Tämä parantaa yksityisyyttäsi radikaalisti.", "simplex-private-1-title": "2 kerrosta päästä päähän salattua viestintää", "simplex-private-2-title": "Lisäkerros palvelimen salaukselle", diff --git a/website/langs/fr.json b/website/langs/fr.json index 3a1e6067b3..760ac037c3 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -22,7 +22,7 @@ "chat-protocol": "Protocole de chat", "donate": "Faire un don", "invest": "Investir", - "copyright-label": "© 2020-2025 SimpleX Chat | Projet Open-Source", + "copyright-label": "© 2020-2026 SimpleX Chat | Projet Open-Source", "simplex-chat-protocol": "Protocole SimpleX Chat", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Politique de confidentialité", diff --git a/website/langs/he.json b/website/langs/he.json index 4fde0c0333..11dc4a0b4d 100644 --- a/website/langs/he.json +++ b/website/langs/he.json @@ -58,7 +58,7 @@ "index-token-p1": "כדי להישאר עצמאיים, ערוצים וקהילות גדולים ישלמו עבור השרתים שלהם.", "index-token-p2-cf": "וכעת המשתמשים יכולים להשקיע ב-SimpleX Chat — מימון ההמונים פעיל!", "index-token-cta-cf": "השקיעו ב-SimpleX Chat", - "copyright-label": "© 2020-2025 SimpleX Chat | פרויקט קוד פתוח", + "copyright-label": "© 2020-2026 SimpleX Chat | פרויקט קוד פתוח", "hero-p-1": "לאפליקציות אחרות יש מזהי משתמש: Signal, Matrix, Session, Briar, Jami, Cwtch וכו'.
ל-SimpleX אין, אפילו לא מספרים אקראיים.
זה משפר באופן קיצוני את הפרטיות שלך.", "hero-overlay-2-title": "מדוע מזהי משתמש מזיקים לפרטיות?", "feature-6-title": "שיחות שמע ווידאו
מוצפנות מקצה לקצה", diff --git a/website/langs/id.json b/website/langs/id.json index 6a21ba7fff..2811873b46 100644 --- a/website/langs/id.json +++ b/website/langs/id.json @@ -31,7 +31,7 @@ "simplex-explained-tab-2-text": "2. Bagaimana cara kerjanya", "simplex-chat-protocol": "Protokol SimpleX Chat", "hero-overlay-2-title": "Mengapa ID pengguna buruk untuk privasi?", - "copyright-label": "© 2020-2025 SimpleX Chat | Open-Source Project", + "copyright-label": "© 2020-2026 SimpleX Chat | Open-Source Project", "simplex-explained-tab-3-text": "3. Apa yang dilihat server", "smp-protocol": "Protokol SMP", "please-use-link-in-mobile-app": "Mohon gunakan tautan di aplikasi seluler", diff --git a/website/langs/it.json b/website/langs/it.json index 66be14ccde..f6befff000 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -11,7 +11,7 @@ "chat-protocol": "Protocollo di chat", "donate": "Dona", "invest": "Investi", - "copyright-label": "© 2020-2025 SimpleX Chat | Progetto Open-Source", + "copyright-label": "© 2020-2026 SimpleX Chat | Progetto Open-Source", "simplex-chat-protocol": "Protocollo di SimpleX Chat", "terminal-cli": "Terminale CLI", "terms-and-privacy-policy": "Informativa sulla privacy", diff --git a/website/langs/ja.json b/website/langs/ja.json index ae689e02e8..087a18ac6a 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -53,7 +53,7 @@ "chat-bot-example": "チャットボットの例", "donate": "寄付", "invest": "投資", - "copyright-label": "© 2020-2025 SimpleX Chat | Open-Source Project", + "copyright-label": "© 2020-2026 SimpleX Chat | Open-Source Project", "hero-p-1": "他のアプリにはユーザー ID があります: Signal、Matrix、Session、Briar、Jami、Cwtch など。
SimpleX にはありません。乱数さえもありません
これにより、プライバシーが大幅に向上します。", "copy-the-command-below-text": "以下のコマンドをコピーしてチャットで使用します:", "simplex-private-card-9-point-1": "各メッセージ キューは、異なる送信アドレスと受信アドレスを使用してメッセージを一方向に渡します。", diff --git a/website/langs/nl.json b/website/langs/nl.json index e0ed27d17e..aebab52842 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -22,7 +22,7 @@ "index-token-p1": "Om onafhankelijk te blijven, zullen grote kanalen en gemeenschappen voor hun servers betalen.", "index-token-p2-cf": "En gebruikers kunnen nu investeren in SimpleX Chat — de crowdfunding is live!", "index-token-cta-cf": "Investeer in SimpleX Chat", - "copyright-label": "© 2020-2025 SimpleX Chat | Open-sourceproject", + "copyright-label": "© 2020-2026 SimpleX Chat | Open-sourceproject", "simplex-chat-protocol": "SimpleX Chat protocol", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Privacybeleid", diff --git a/website/langs/pl.json b/website/langs/pl.json index 73587a71d8..21a3576cb8 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -16,7 +16,7 @@ "chat-protocol": "Protokół czatu", "donate": "Darowizna", "invest": "Inwestuj", - "copyright-label": "© 2020-2025 SimpleX Chat | Projekt Open-Source", + "copyright-label": "© 2020-2026 SimpleX Chat | Projekt Open-Source", "simplex-chat-protocol": "Protokół SimpleX Chat", "terminal-cli": "Terminal wiersza poleceń", "terms-and-privacy-policy": "Polityka prywatności", diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index 242a371ab5..52848e5cea 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -26,7 +26,7 @@ "chat-protocol": "Protocolo de bate-papo", "donate": "Doar", "invest": "Investir", - "copyright-label": "© 2020-2025 SimpleX Chat | Projeto de Código Livre", + "copyright-label": "© 2020-2026 SimpleX Chat | Projeto de Código Livre", "simplex-chat-protocol": "Protocolo Chat SimpleX", "terminal-cli": "CLI Terminal", "hero-header": "Privacidade redefinida", diff --git a/website/langs/ro.json b/website/langs/ro.json index 75d28967a1..57925505bb 100644 --- a/website/langs/ro.json +++ b/website/langs/ro.json @@ -25,7 +25,7 @@ "index-token-p1": "Pentru a rămâne independente, canalele și comunitățile mari vor plăti pentru serverele lor.", "index-token-p2-cf": "Iar utilizatorii pot acum investi în SimpleX Chat — finanțarea participativă este activă!", "index-token-cta-cf": "Investește în SimpleX Chat", - "copyright-label": "© 2020-2025 SimpleX Chat | Proiect Open-Source", + "copyright-label": "© 2020-2026 SimpleX Chat | Proiect Open-Source", "simplex-chat-protocol": "Protocolul SimpleX Chat", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Politică de confidențialitate", diff --git a/website/langs/ru.json b/website/langs/ru.json index 116757b6b6..9e714e59ff 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -1,6 +1,6 @@ { "copy-the-command-below-text": "скопируйте приведенную ниже команду и используйте ее в чате:", - "copyright-label": "© 2020-2025 SimpleX Chat | Проект с открытым исходным кодом", + "copyright-label": "© 2020-2026 SimpleX Chat | Проект с открытым исходным кодом", "chat-bot-example": "Пример Чат бота", "simplex-private-card-9-point-1": "Каждая очередь сообщений передает сообщения в одном направлении с разными адресами отправки и получения.", "simplex-private-card-1-point-2": "NaCL cryptobox в каждой очереди для предотвращения корреляции трафика между очередями сообщений, в случае компрометированного TLS.", diff --git a/website/langs/tr.json b/website/langs/tr.json index efa458e962..de607f689a 100644 --- a/website/langs/tr.json +++ b/website/langs/tr.json @@ -26,7 +26,7 @@ "index-token-p1": "Bağımsız kalmak için büyük kanallar ve topluluklar sunucuları için ödeme yapacak.", "index-token-p2-cf": "Ve kullanıcılar artık SimpleX Chat'e yatırım yapabilir — kitle fonlaması başladı!", "index-token-cta-cf": "SimpleX Chat'e Yatırım Yap", - "copyright-label": "© 2020-2025 SimpleX Chat | Açık Kaynak Projesi", + "copyright-label": "© 2020-2026 SimpleX Chat | Açık Kaynak Projesi", "simplex-chat-protocol": "SimpleX Sohbet Protokolü", "terminal-cli": "Terminal Komut Satırı Arayüzü", "terms-and-privacy-policy": "Gizlilik Politikası", diff --git a/website/langs/uk.json b/website/langs/uk.json index 53be9975cf..1848b5ce96 100644 --- a/website/langs/uk.json +++ b/website/langs/uk.json @@ -83,7 +83,7 @@ "index-token-p1": "Щоб залишатися незалежними, великі канали та спільноти платитимуть за свої сервери.", "index-token-p2-cf": "А тепер користувачі можуть інвестувати в SimpleX Chat — краудфандинг запущено!", "index-token-cta-cf": "Інвестувати в SimpleX Chat", - "copyright-label": "© 2020-2025 SimpleX Chat | Проект з відкритим кодом", + "copyright-label": "© 2020-2026 SimpleX Chat | Проект з відкритим кодом", "simplex-chat-protocol": "Протокол чату SimpleX", "terminal-cli": "Термінал CLI", "hero-header": "Приватність переосмислена", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index d13f67e351..2ead3da487 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -58,7 +58,7 @@ "simplex-chat-protocol": "SimpleX 聊天协议", "smp-protocol": "SMP协议", "chat-protocol": "聊天协议", - "copyright-label": "© 2020-2025 SimpleX Chat | 开源项目", + "copyright-label": "© 2020-2026 SimpleX Chat | 开源项目", "terminal-cli": "命令行程式", "simplex-explained-tab-1-p-1": "您可以创建联系人和群组,并进行双向对话,就像是任何其他即时通讯软件一样。", "hero-p-1": "其他应用——如Signal、Matrix、Session、Briar、Jami、Cwtch 等——都需要用户 ID。
而SimpleX 不需要用户ID,连随机生成的也不需要。
这从根本上改善了您的隐私。", diff --git a/website/langs/zh_Hant.json b/website/langs/zh_Hant.json index c3c2370ebf..ced46c6b69 100644 --- a/website/langs/zh_Hant.json +++ b/website/langs/zh_Hant.json @@ -19,7 +19,7 @@ "simplex-explained-tab-2-p-2": "伺服器僅單向傳遞消息,無法全面瞭解使用者的對話記錄或連接。", "simplex-explained-tab-2-p-1": "對於每個連接,您可以使用兩個單獨的消息佇列通過不同的伺服器發送和接收消息。", "chat-protocol": "聊天協定", - "copyright-label": "© 2020-2025 SimpleX Chat |開源專案", + "copyright-label": "© 2020-2026 SimpleX Chat |開源專案", "donate": "捐助", "invest": "投資", "index-token-h2": "由用戶資助", diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index 8f26f59df6..a3b0308802 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -148,7 +148,7 @@ - {% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('credits' not in page.url) and ('file' not in page.url) and ('links' not in page.url) and ('news' not in page.url) and ('crowdfunding' not in page.url) %} + {% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('credits' not in page.url) and ('file' not in page.url) and ('links' not in page.url) and ('news' not in page.url) and ('crowdfunding' not in page.url) and ('livestream' not in page.url) %}