From ebf1571b92f677c1b23d883e34e96bbad73e07be Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Thu, 8 May 2025 18:05:06 +0200 Subject: [PATCH 01/14] share images from coil cache to other apps --- .../amethyst/ui/components/ShareHelper.kt | 102 ++++++++++++++++++ .../ui/components/ZoomableContentView.kt | 18 ++++ amethyst/src/main/res/xml/file_paths.xml | 2 + 3 files changed, 122 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt new file mode 100644 index 000000000..94e1e1752 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.core.content.FileProvider +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware +import coil3.size.Size +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File +import java.io.IOException + +object ShareHelper { + suspend fun shareImageFromUrl( + context: Context, + imageUrl: String, + ) { + val file = getCachedFileForUrl(context, imageUrl) + + if (file != null) { + shareMediaFile(context, getSharableUri(context, file), "image/*") + } else { + throw IOException("Image file does not exist at path: $imageUrl") + } + } + + private suspend fun getCachedFileForUrl( + context: Context, + url: String, + ): File? { + val loader = context.imageLoader + val request = + ImageRequest + .Builder(context) + .data(url) + .size(Size.ORIGINAL) + .allowHardware(true) + .build() + + val result = loader.execute(request) + if (result is SuccessResult) { + val diskCacheKey = result.diskCacheKey ?: return null + val snapshot = loader.diskCache?.openSnapshot(diskCacheKey) + return snapshot?.data?.toFile() + } else { + Log.d("ShareHelper", "Failed to get cached file for URL: $url") + return null + } + } + + private fun getSharableUri( + context: Context, + file: File, + ): Uri = + FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + file, + ) + + private fun shareMediaFile( + context: Context, + uri: Uri, + mimeType: String = "image/*", + ) { + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = mimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + CoroutineScope(Dispatchers.Main).launch { + context.startActivity(Intent.createChooser(shareIntent, "Share Image")) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index c3af28ddd..4f0560954 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -47,6 +47,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -104,6 +105,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.seconds @@ -751,6 +753,22 @@ fun ShareImageAction( }, ) } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + videoUri?.let { + if (videoUri.isNotEmpty()) { + DropdownMenuItem( + text = { Text("Share media...") }, + onClick = { + scope.launch(Dispatchers.IO) { + ShareHelper.shareImageFromUrl(context, videoUri) + } + onDismiss() + }, + ) + } + } } } diff --git a/amethyst/src/main/res/xml/file_paths.xml b/amethyst/src/main/res/xml/file_paths.xml index a075ef96b..0b339a9cf 100644 --- a/amethyst/src/main/res/xml/file_paths.xml +++ b/amethyst/src/main/res/xml/file_paths.xml @@ -3,4 +3,6 @@ + + From 13ee0a23de05aa667a427486b55152a63d5dc570 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 16:44:31 +0200 Subject: [PATCH 02/14] Use disk cache reference from app Detect image type based on magic number --- .../amethyst/ui/components/ShareHelper.kt | 83 ++++++++++++------- 1 file changed, 53 insertions(+), 30 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index 94e1e1752..bc17ba229 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -23,55 +23,78 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent import android.net.Uri -import android.util.Log import androidx.core.content.FileProvider -import coil3.imageLoader -import coil3.request.ImageRequest -import coil3.request.SuccessResult -import coil3.request.allowHardware -import coil3.size.Size +import com.vitorpamplona.amethyst.Amethyst import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.io.File +import java.io.FileInputStream import java.io.IOException object ShareHelper { - suspend fun shareImageFromUrl( + fun shareImageFromUrl( context: Context, imageUrl: String, ) { - val file = getCachedFileForUrl(context, imageUrl) + // get snapshot + val snapShot = Amethyst.instance.diskCache.openSnapshot(imageUrl) + + // get file from snapshot + val file = snapShot?.data?.toFile() if (file != null) { - shareMediaFile(context, getSharableUri(context, file), "image/*") + // get file extension + val fileExtension = getImageExtension(file) + + // copy to local file with correct extension + val fileCopy = prepareSharableImageFile(context, file, fileExtension) + + // share with intent + shareMediaFile(context, getSharableUri(context, fileCopy), "image/*") } else { throw IOException("Image file does not exist at path: $imageUrl") } + + // close snapshot + snapShot.close() } - private suspend fun getCachedFileForUrl( - context: Context, - url: String, - ): File? { - val loader = context.imageLoader - val request = - ImageRequest - .Builder(context) - .data(url) - .size(Size.ORIGINAL) - .allowHardware(true) - .build() - - val result = loader.execute(request) - if (result is SuccessResult) { - val diskCacheKey = result.diskCacheKey ?: return null - val snapshot = loader.diskCache?.openSnapshot(diskCacheKey) - return snapshot?.data?.toFile() - } else { - Log.d("ShareHelper", "Failed to get cached file for URL: $url") - return null + private fun getImageExtension(file: File): String { + val header = ByteArray(12) + FileInputStream(file).use { + it.read(header) } + + return when { + // JPEG magic number: FF D8 FF + header[0] == 0xFF.toByte() && header[1] == 0xD8.toByte() -> "jpg" + + // PNG magic number: 89 50 4E 47 + header[0] == 0x89.toByte() && + header[1] == 0x50.toByte() && + header[2] == 0x4E.toByte() && + header[3] == 0x47.toByte() -> "png" + + // WEBP magic number: "RIFF....WEBP" + header[0] == 'R'.code.toByte() && + header[1] == 'I'.code.toByte() && + header[2] == 'F'.code.toByte() && + header[3] == 'F'.code.toByte() && + header.sliceArray(8..11).contentEquals("WEBP".toByteArray()) -> "webp" + + else -> "jpg" // default fallback + } + } + + private fun prepareSharableImageFile( + context: Context, + originalFile: File, + extension: String, + ): File { + val sharableFile = File(context.cacheDir, "shared_image.$extension") + originalFile.copyTo(sharableFile, overwrite = true) + return sharableFile } private fun getSharableUri( From fee85a611185e35387c45d22ae5d7fb67f500e74 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 17:06:05 +0200 Subject: [PATCH 03/14] remove scope launch --- .../amethyst/ui/components/ZoomableContentView.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 4f0560954..3cd6a9a33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -47,7 +47,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -105,7 +104,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.seconds @@ -755,15 +753,13 @@ fun ShareImageAction( } val context = LocalContext.current - val scope = rememberCoroutineScope() videoUri?.let { if (videoUri.isNotEmpty()) { DropdownMenuItem( text = { Text("Share media...") }, onClick = { - scope.launch(Dispatchers.IO) { - ShareHelper.shareImageFromUrl(context, videoUri) - } + ShareHelper.shareImageFromUrl(context, videoUri) + onDismiss() }, ) From 0192a4eb2044cbabd6ce2a33d44fd4b0571da482 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 17:13:29 +0200 Subject: [PATCH 04/14] Add test to only share images. Other media not supported atm --- .../ui/components/ZoomableContentView.kt | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 3cd6a9a33..a933e38da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -752,17 +752,21 @@ fun ShareImageAction( ) } - val context = LocalContext.current - videoUri?.let { - if (videoUri.isNotEmpty()) { - DropdownMenuItem( - text = { Text("Share media...") }, - onClick = { - ShareHelper.shareImageFromUrl(context, videoUri) + mimeType?.let { + if (mimeType.startsWith("image")) { + val context = LocalContext.current + videoUri?.let { + if (videoUri.isNotEmpty()) { + DropdownMenuItem( + text = { Text("Share image...") }, + onClick = { + ShareHelper.shareImageFromUrl(context, videoUri) - onDismiss() - }, - ) + onDismiss() + }, + ) + } + } } } } From 8c1888b4d54e1b37ca9851a57b6b46eabaf29884 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 17:35:06 +0200 Subject: [PATCH 05/14] added error handling .use {} for automatic resource management with snapshots --- .../amethyst/ui/components/ShareHelper.kt | 94 ++++++++++--------- 1 file changed, 50 insertions(+), 44 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index bc17ba229..6f9675f8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent import android.net.Uri +import android.util.Log import androidx.core.content.FileProvider import com.vitorpamplona.amethyst.Amethyst import kotlinx.coroutines.CoroutineScope @@ -37,56 +38,61 @@ object ShareHelper { context: Context, imageUrl: String, ) { - // get snapshot - val snapShot = Amethyst.instance.diskCache.openSnapshot(imageUrl) + try { + // Safely get snapshot and file + val snapshot = + Amethyst.instance.diskCache.openSnapshot(imageUrl) + ?: throw IOException("Unable to open snapshot for: $imageUrl") - // get file from snapshot - val file = snapShot?.data?.toFile() + snapshot.use { snapshot -> + val file = + snapshot.data.toFile() - if (file != null) { - // get file extension - val fileExtension = getImageExtension(file) + // Determine file extension and prepare sharable file + val fileExtension = getImageExtension(file) + val fileCopy = prepareSharableImageFile(context, file, fileExtension) - // copy to local file with correct extension - val fileCopy = prepareSharableImageFile(context, file, fileExtension) - - // share with intent - shareMediaFile(context, getSharableUri(context, fileCopy), "image/*") - } else { - throw IOException("Image file does not exist at path: $imageUrl") - } - - // close snapshot - snapShot.close() - } - - private fun getImageExtension(file: File): String { - val header = ByteArray(12) - FileInputStream(file).use { - it.read(header) - } - - return when { - // JPEG magic number: FF D8 FF - header[0] == 0xFF.toByte() && header[1] == 0xD8.toByte() -> "jpg" - - // PNG magic number: 89 50 4E 47 - header[0] == 0x89.toByte() && - header[1] == 0x50.toByte() && - header[2] == 0x4E.toByte() && - header[3] == 0x47.toByte() -> "png" - - // WEBP magic number: "RIFF....WEBP" - header[0] == 'R'.code.toByte() && - header[1] == 'I'.code.toByte() && - header[2] == 'F'.code.toByte() && - header[3] == 'F'.code.toByte() && - header.sliceArray(8..11).contentEquals("WEBP".toByteArray()) -> "webp" - - else -> "jpg" // default fallback + // Share the file + shareMediaFile(context, getSharableUri(context, fileCopy), "image/*") + } + } catch (e: IOException) { + Log.e("ShareHelper", "Error sharing image", e) + throw e } } + private fun getImageExtension(file: File): String = + try { + FileInputStream(file).use { inputStream -> + val header = ByteArray(12) + inputStream.read(header) + + when { + // JPEG magic number: FF D8 FF + header.sliceArray(0..1).contentEquals(byteArrayOf(0xFF.toByte(), 0xD8.toByte())) -> "jpg" + + // PNG magic number: 89 50 4E 47 + header.sliceArray(0..3).contentEquals( + byteArrayOf( + 0x89.toByte(), + 0x50.toByte(), + 0x4E.toByte(), + 0x47.toByte(), + ), + ) -> "png" + + // WEBP magic number: "RIFF....WEBP" + header.sliceArray(0..3).contentEquals("RIFF".toByteArray()) && + header.sliceArray(8..11).contentEquals("WEBP".toByteArray()) -> "webp" + + else -> "jpg" // default fallback + } + } + } catch (e: IOException) { + Log.w("ShareHelper", "Could not determine image type, defaulting to jpg", e) + "jpg" + } + private fun prepareSharableImageFile( context: Context, originalFile: File, From ce96ab873f6980d07230c63f9f06189c1ca6e093 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 18:42:56 +0200 Subject: [PATCH 06/14] added todos --- .../com/vitorpamplona/amethyst/ui/components/ShareHelper.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index 6f9675f8c..e32bcd05c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -33,6 +33,9 @@ import java.io.File import java.io.FileInputStream import java.io.IOException +// TODO use passed in context type rather than hard coding +// TODO Add unit tests for sharehelper +// TODO Move intent sharing back to ZoomableContentView for coroutine management? object ShareHelper { fun shareImageFromUrl( context: Context, From 35001c0b002ffae5d4caaefe134dd72bd3b5d948 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 20:56:16 +0200 Subject: [PATCH 07/14] moved sharing intent back to view --- .../amethyst/ui/components/ShareHelper.kt | 33 ++++--------------- .../ui/components/ZoomableContentView.kt | 27 +++++++++++++-- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index e32bcd05c..b248959a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -21,26 +21,23 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context -import android.content.Intent import android.net.Uri import android.util.Log import androidx.core.content.FileProvider import com.vitorpamplona.amethyst.Amethyst -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import java.io.File import java.io.FileInputStream import java.io.IOException -// TODO use passed in context type rather than hard coding +// TODO use passed in mime type rather than hard coding // TODO Add unit tests for sharehelper -// TODO Move intent sharing back to ZoomableContentView for coroutine management? +// TODO Rely on passed in mime type for image type? object ShareHelper { - fun shareImageFromUrl( + fun getSharableUriFromUrl( context: Context, imageUrl: String, - ) { + mimeType: String, + ): Uri { try { // Safely get snapshot and file val snapshot = @@ -55,8 +52,8 @@ object ShareHelper { val fileExtension = getImageExtension(file) val fileCopy = prepareSharableImageFile(context, file, fileExtension) - // Share the file - shareMediaFile(context, getSharableUri(context, fileCopy), "image/*") + // Return sharable uri + return getSharableUri(context, fileCopy) } } catch (e: IOException) { Log.e("ShareHelper", "Error sharing image", e) @@ -115,20 +112,4 @@ object ShareHelper { "${context.packageName}.provider", file, ) - - private fun shareMediaFile( - context: Context, - uri: Uri, - mimeType: String = "image/*", - ) { - val shareIntent = - Intent(Intent.ACTION_SEND).apply { - type = mimeType - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - CoroutineScope(Dispatchers.Main).launch { - context.startActivity(Intent.createChooser(shareIntent, "Share Image")) - } - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index a933e38da..c9ec199f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.content.Context +import android.content.Intent +import android.net.Uri import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope @@ -102,8 +105,10 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.seconds @@ -758,10 +763,11 @@ fun ShareImageAction( videoUri?.let { if (videoUri.isNotEmpty()) { DropdownMenuItem( + // TODO localise text = { Text("Share image...") }, onClick = { - ShareHelper.shareImageFromUrl(context, videoUri) - + val uri = ShareHelper.getSharableUriFromUrl(context, videoUri, mimeType) + shareMediaFile(context, uri, mimeType) onDismiss() }, ) @@ -772,6 +778,23 @@ fun ShareImageAction( } } +private fun shareMediaFile( + context: Context, + uri: Uri, + mimeType: String = "image/*", +) { + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = mimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + // TODO is this the right scope to avoid leaks? + CoroutineScope(Dispatchers.Main).launch { + context.startActivity(Intent.createChooser(shareIntent, "Share Image")) + } +} + private suspend fun verifyHash(content: MediaUrlContent): Boolean? { if (content.hash == null) return null From 91b6bf6e88911214305b28cad98e04606995024b Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 21:03:17 +0200 Subject: [PATCH 08/14] localisation --- .../amethyst/ui/components/ZoomableContentView.kt | 3 +-- amethyst/src/main/res/values-cs/strings.xml | 1 + amethyst/src/main/res/values-de/strings.xml | 1 + amethyst/src/main/res/values-pt-rBR/strings.xml | 1 + amethyst/src/main/res/values/strings.xml | 1 + 5 files changed, 5 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index c9ec199f9..a944d6c93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -763,8 +763,7 @@ fun ShareImageAction( videoUri?.let { if (videoUri.isNotEmpty()) { DropdownMenuItem( - // TODO localise - text = { Text("Share image...") }, + text = { Text(stringRes(R.string.share_image)) }, onClick = { val uri = ShareHelper.getSharableUriFromUrl(context, videoUri, mimeType) shareMediaFile(context, uri, mimeType) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 9a8369877..02c89a154 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -936,4 +936,5 @@ Pro otevření a stažení souboru nejsou nainstalovány žádné torrent aplikace. Vyberte seznam pro filtrování kanálu Odhlásit se na zámek zařízení + Sdílet obrázek… diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index 0a62275d1..2c0c4ed3b 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -941,4 +941,5 @@ anz der Bedingungen ist erforderlich Keine Torrent-Apps installiert, um die Datei zu öffnen und herunterzuladen. Liste zum Filtern des Feeds auswählen Beim Sperren des Geräts abmelden + Bild teilen… diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 1e7c8e877..0ca3700a8 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -958,4 +958,5 @@ Mensagem Privada Relé de chat O relé a qual todos os usuários deste chat se conectam + Compartilhar imagem… diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5646d9b57..8f8a5fad2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1159,4 +1159,5 @@ Chat Relay The relay that all users of this chat connect to + Share image… From 7efec030561c9cc42771a04a86e7501d22da19dd Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 22:26:24 +0200 Subject: [PATCH 09/14] Return file extension from ShareHelper just in case mimetype is null Use content to determine whether it's an image --- .../amethyst/ui/components/ShareHelper.kt | 7 +++---- .../ui/components/ZoomableContentView.kt | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index b248959a5..c3e24ff3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -36,8 +36,7 @@ object ShareHelper { fun getSharableUriFromUrl( context: Context, imageUrl: String, - mimeType: String, - ): Uri { + ): Pair { try { // Safely get snapshot and file val snapshot = @@ -53,7 +52,7 @@ object ShareHelper { val fileCopy = prepareSharableImageFile(context, file, fileExtension) // Return sharable uri - return getSharableUri(context, fileCopy) + return Pair(getSharableUri(context, fileCopy), fileExtension) } } catch (e: IOException) { Log.e("ShareHelper", "Error sharing image", e) @@ -61,7 +60,7 @@ object ShareHelper { } } - private fun getImageExtension(file: File): String = + fun getImageExtension(file: File): String = try { FileInputStream(file).use { inputStream -> val header = ByteArray(12) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index a944d6c93..6f73b1b68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -685,6 +685,7 @@ fun ShareImageAction( hash = content.hash, mimeType = content.mimeType, onDismiss = onDismiss, + content = content, ) } else if (content is MediaPreloadedContent) { ShareImageAction( @@ -713,6 +714,7 @@ fun ShareImageAction( hash: String?, mimeType: String?, onDismiss: () -> Unit, + content: BaseMediaContent? = null, ) { DropdownMenu( expanded = popupExpanded.value, @@ -757,16 +759,20 @@ fun ShareImageAction( ) } - mimeType?.let { - if (mimeType.startsWith("image")) { + content?.let { + if (content is MediaUrlImage) { val context = LocalContext.current videoUri?.let { if (videoUri.isNotEmpty()) { DropdownMenuItem( text = { Text(stringRes(R.string.share_image)) }, onClick = { - val uri = ShareHelper.getSharableUriFromUrl(context, videoUri, mimeType) - shareMediaFile(context, uri, mimeType) + val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri) + if (mimeType == null) { + shareMediaFile(context, uri, "image/$fileExtension") + } else { + shareMediaFile(context, uri, mimeType) + } onDismiss() }, ) @@ -780,7 +786,7 @@ fun ShareImageAction( private fun shareMediaFile( context: Context, uri: Uri, - mimeType: String = "image/*", + mimeType: String, ) { val shareIntent = Intent(Intent.ACTION_SEND).apply { @@ -790,7 +796,7 @@ private fun shareMediaFile( } // TODO is this the right scope to avoid leaks? CoroutineScope(Dispatchers.Main).launch { - context.startActivity(Intent.createChooser(shareIntent, "Share Image")) + context.startActivity(Intent.createChooser(shareIntent, null)) } } From 2e4c9f77e6295950fa53e582f4be6722b405b1be Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Mon, 12 May 2025 22:33:15 +0200 Subject: [PATCH 10/14] refactor --- .../amethyst/ui/components/ShareHelper.kt | 122 ++++++++++-------- .../ui/components/ZoomableContentView.kt | 23 ++-- 2 files changed, 80 insertions(+), 65 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index c3e24ff3c..f4d29bbfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -29,86 +29,100 @@ import java.io.File import java.io.FileInputStream import java.io.IOException -// TODO use passed in mime type rather than hard coding -// TODO Add unit tests for sharehelper -// TODO Rely on passed in mime type for image type? object ShareHelper { + private const val TAG = "ShareHelper" + private const val DEFAULT_EXTENSION = "jpg" + private const val SHARED_FILE_PREFIX = "shared_media" + + // Media type magic numbers + private val JPEG_MAGIC = byteArrayOf(0xFF.toByte(), 0xD8.toByte()) + private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50.toByte(), 0x4E.toByte(), 0x47.toByte()) + private val WEBP_HEADER_START = "RIFF".toByteArray() + private val WEBP_HEADER_END = "WEBP".toByteArray() + fun getSharableUriFromUrl( context: Context, imageUrl: String, ): Pair { - try { - // Safely get snapshot and file - val snapshot = - Amethyst.instance.diskCache.openSnapshot(imageUrl) - ?: throw IOException("Unable to open snapshot for: $imageUrl") + // Safely get snapshot and file + Amethyst.instance.diskCache.openSnapshot(imageUrl)?.use { snapshot -> + val file = snapshot.data.toFile() - snapshot.use { snapshot -> - val file = - snapshot.data.toFile() + // Determine file extension and prepare sharable file + val fileExtension = getImageExtension(file) + val fileCopy = prepareSharableFile(context, file, fileExtension) - // Determine file extension and prepare sharable file - val fileExtension = getImageExtension(file) - val fileCopy = prepareSharableImageFile(context, file, fileExtension) - - // Return sharable uri - return Pair(getSharableUri(context, fileCopy), fileExtension) - } - } catch (e: IOException) { - Log.e("ShareHelper", "Error sharing image", e) - throw e - } + // Return sharable uri + return Pair( + FileProvider.getUriForFile(context, "${context.packageName}.provider", fileCopy), + fileExtension, + ) + } ?: throw IOException("Unable to open snapshot for: $imageUrl") } - fun getImageExtension(file: File): String = + private fun getImageExtension(file: File): String = try { FileInputStream(file).use { inputStream -> val header = ByteArray(12) - inputStream.read(header) + val bytesRead = inputStream.read(header) + + if (bytesRead < 4) { + // If we couldn't read at least 4 bytes, default to jpg + return DEFAULT_EXTENSION + } when { - // JPEG magic number: FF D8 FF - header.sliceArray(0..1).contentEquals(byteArrayOf(0xFF.toByte(), 0xD8.toByte())) -> "jpg" + // JPEG: Check first 2 bytes + matchesMagicNumbers(header, 0, JPEG_MAGIC) -> "jpg" - // PNG magic number: 89 50 4E 47 - header.sliceArray(0..3).contentEquals( - byteArrayOf( - 0x89.toByte(), - 0x50.toByte(), - 0x4E.toByte(), - 0x47.toByte(), - ), - ) -> "png" + // PNG: Check first 4 bytes + matchesMagicNumbers(header, 0, PNG_MAGIC) -> "png" - // WEBP magic number: "RIFF....WEBP" - header.sliceArray(0..3).contentEquals("RIFF".toByteArray()) && - header.sliceArray(8..11).contentEquals("WEBP".toByteArray()) -> "webp" + // WEBP: Check "RIFF" (bytes 0-3) and "WEBP" (bytes 8-11) + matchesMagicNumbers(header, 0, WEBP_HEADER_START) && + bytesRead >= 12 && + matchesMagicNumbers(header, 8, WEBP_HEADER_END) -> "webp" - else -> "jpg" // default fallback + else -> DEFAULT_EXTENSION } } } catch (e: IOException) { - Log.w("ShareHelper", "Could not determine image type, defaulting to jpg", e) - "jpg" + Log.w(TAG, "Could not determine image type for ${file.name}, defaulting to $DEFAULT_EXTENSION", e) + DEFAULT_EXTENSION } - private fun prepareSharableImageFile( + private fun matchesMagicNumbers( + data: ByteArray, + offset: Int, + magicBytes: ByteArray, + ): Boolean { + if (offset + magicBytes.size > data.size) { + return false + } + + for (i in magicBytes.indices) { + if (data[offset + i] != magicBytes[i]) { + return false + } + } + return true + } + + private fun prepareSharableFile( context: Context, originalFile: File, extension: String, ): File { - val sharableFile = File(context.cacheDir, "shared_image.$extension") - originalFile.copyTo(sharableFile, overwrite = true) + val timestamp = System.currentTimeMillis() + val sharableFile = File(context.cacheDir, "${SHARED_FILE_PREFIX}_$timestamp.$extension") + + try { + originalFile.copyTo(sharableFile, overwrite = true) + } catch (e: IOException) { + Log.e(TAG, "Failed to copy file for sharing", e) + throw e + } + return sharableFile } - - private fun getSharableUri( - context: Context, - file: File, - ): Uri = - FileProvider.getUriForFile( - context, - "${context.packageName}.provider", - file, - ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 6f73b1b68..c3e78f334 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent -import android.net.Uri import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope @@ -767,12 +766,7 @@ fun ShareImageAction( DropdownMenuItem( text = { Text(stringRes(R.string.share_image)) }, onClick = { - val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri) - if (mimeType == null) { - shareMediaFile(context, uri, "image/$fileExtension") - } else { - shareMediaFile(context, uri, mimeType) - } + shareImageFile(context, videoUri, mimeType) onDismiss() }, ) @@ -783,14 +777,21 @@ fun ShareImageAction( } } -private fun shareMediaFile( +private fun shareImageFile( context: Context, - uri: Uri, - mimeType: String, + videoUri: String, + mimeType: String?, ) { + // Get sharable URI and file extension + val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri) + + // Determine mime type, use provided or derive from extension + val determinedMimeType = mimeType ?: "image/$fileExtension" + + // Create share intent val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = mimeType + type = determinedMimeType putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } From 095d054d6216254e85817efec564de3fcd0e896c Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Tue, 13 May 2025 08:41:55 +0200 Subject: [PATCH 11/14] add support for gifs --- .../com/vitorpamplona/amethyst/ui/components/ShareHelper.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index f4d29bbfa..3ebfdde0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -39,6 +39,7 @@ object ShareHelper { private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50.toByte(), 0x4E.toByte(), 0x47.toByte()) private val WEBP_HEADER_START = "RIFF".toByteArray() private val WEBP_HEADER_END = "WEBP".toByteArray() + private val GIF_MAGIC = "GIF8".toByteArray() fun getSharableUriFromUrl( context: Context, @@ -78,6 +79,9 @@ object ShareHelper { // PNG: Check first 4 bytes matchesMagicNumbers(header, 0, PNG_MAGIC) -> "png" + // GIF: Check first 4 bytes for "GIF8" + matchesMagicNumbers(header, 0, GIF_MAGIC) -> "gif" + // WEBP: Check "RIFF" (bytes 0-3) and "WEBP" (bytes 8-11) matchesMagicNumbers(header, 0, WEBP_HEADER_START) && bytesRead >= 12 && From a848ea6135751aa615c9fd20afa189dc4637264d Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Tue, 13 May 2025 09:43:23 +0200 Subject: [PATCH 12/14] add content param when making call using a MediaPreloadedContent. Just in case --- .../vitorpamplona/amethyst/ui/components/ZoomableContentView.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index c3e78f334..af3627ada 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -697,6 +697,7 @@ fun ShareImageAction( hash = null, mimeType = content.mimeType, onDismiss = onDismiss, + content = content, ) } } From 3b5d12b477578139903b0bd4525099d5c2e12d82 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Wed, 14 May 2025 19:15:16 +0200 Subject: [PATCH 13/14] make helper method suspend, use different scope in View --- .../amethyst/ui/components/ShareHelper.kt | 35 ++++++++++--------- .../ui/components/ZoomableContentView.kt | 16 ++++----- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index 3ebfdde0f..279308bfc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -25,6 +25,8 @@ import android.net.Uri import android.util.Log import androidx.core.content.FileProvider import com.vitorpamplona.amethyst.Amethyst +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File import java.io.FileInputStream import java.io.IOException @@ -41,25 +43,26 @@ object ShareHelper { private val WEBP_HEADER_END = "WEBP".toByteArray() private val GIF_MAGIC = "GIF8".toByteArray() - fun getSharableUriFromUrl( + suspend fun getSharableUriFromUrl( context: Context, imageUrl: String, - ): Pair { - // Safely get snapshot and file - Amethyst.instance.diskCache.openSnapshot(imageUrl)?.use { snapshot -> - val file = snapshot.data.toFile() + ): Pair = + withContext(Dispatchers.IO) { + // Safely get snapshot and file + Amethyst.instance.diskCache.openSnapshot(imageUrl)?.use { snapshot -> + val file = snapshot.data.toFile() - // Determine file extension and prepare sharable file - val fileExtension = getImageExtension(file) - val fileCopy = prepareSharableFile(context, file, fileExtension) + // Determine file extension and prepare sharable file + val fileExtension = getImageExtension(file) + val fileCopy = prepareSharableFile(context, file, fileExtension) - // Return sharable uri - return Pair( - FileProvider.getUriForFile(context, "${context.packageName}.provider", fileCopy), - fileExtension, - ) - } ?: throw IOException("Unable to open snapshot for: $imageUrl") - } + // Return sharable uri + return@use Pair( + FileProvider.getUriForFile(context, "${context.packageName}.provider", fileCopy), + fileExtension, + ) + } ?: throw IOException("Unable to open snapshot for: $imageUrl") + } private fun getImageExtension(file: File): String = try { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index d6c611bf0..ea77ff3b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -49,6 +49,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -104,7 +105,6 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -716,6 +716,8 @@ fun ShareImageAction( onDismiss: () -> Unit, content: BaseMediaContent? = null, ) { + val scope = rememberCoroutineScope() + DropdownMenu( expanded = popupExpanded.value, onDismissRequest = onDismiss, @@ -767,7 +769,7 @@ fun ShareImageAction( DropdownMenuItem( text = { Text(stringRes(R.string.share_image)) }, onClick = { - shareImageFile(context, videoUri, mimeType) + scope.launch { shareImageFile(context, videoUri, mimeType) } onDismiss() }, ) @@ -778,7 +780,7 @@ fun ShareImageAction( } } -private fun shareImageFile( +private suspend fun shareImageFile( context: Context, videoUri: String, mimeType: String?, @@ -796,13 +798,11 @@ private fun shareImageFile( putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - // TODO is this the right scope to avoid leaks? - CoroutineScope(Dispatchers.Main).launch { - context.startActivity(Intent.createChooser(shareIntent, null)) - } + + context.startActivity(Intent.createChooser(shareIntent, null)) } -private suspend fun verifyHash(content: MediaUrlContent): Boolean? { +private fun verifyHash(content: MediaUrlContent): Boolean? { if (content.hash == null) return null Amethyst.instance.diskCache.openSnapshot(content.url)?.use { snapshot -> From 12ac1c48bbfffd46b0333951d25c599c0afc2c48 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Thu, 15 May 2025 12:37:54 +0000 Subject: [PATCH 14/14] New Crowdin translations by GitHub Action --- .../src/main/res/values-hi-rIN/strings.xml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 323b06714..f553aa377 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -216,6 +216,13 @@ अनुचरण ना करें प्रणाली बनायी गयी "प्रणाली जानकारी परिवर्तित की गयी" + नश्वर चर्चा + पुनःप्रसारक चर्चा + पुनःप्रसारक चर्चाएँ + पुनःप्रसारक चर्चाएँ आवासी पुनःप्रसारक द्वारा नियन्त्रित चर्चा समुदाएँ हैं। + वे नोस्टर पर सभी के लिए दृश्य हैं तथा उनमें सभी भाग ले सकते हैं। + वे उत्कृष्ट हैं खुले समुदायों के लिए विशिष्ट विषयों पर। इनमें से कुछ समुदाएँ अस्थायी हैं। + तथा इसीलिए समय के साथ चर्चा सन्देश अदृश्य हो जाते हैं सार्वजनिक चर्चा सार्वजनिक चर्चा उपतथ्य सार्वजनिक चर्चाएँ सबके लिए दृश्यमान हैं नोस्टर पर तथा सभी @@ -489,9 +496,18 @@ संवेदनशील विषयवस्तु सर्वदा छिपाएँ संवेदनशील विषयवस्तु सर्वदा दिखाएँ विषयवस्तु चेतावनियाँ सर्वदा दिखाएँ + छिपाएँ + दिखाएँ + चेतावनी दें अनुशंसित : अपरिचित जन के भेजे गये कचरालेखों को छलनी द्वारा हटाएँ चेतावनी दें जब पत्र सूचित किये गये हों आपके द्वारा अनुचरित व्यक्तियों से + कचरालेख छलनी + अज्ञात लोगों से पत्र छिपाएँ जो निरन्तर 5 अथवा अधिक बार यथावत समान थे + सूचनाएँ प्राप्त होने पर चेतावनी दें + चेतावनी सन्देश दिखाता है जब प्रकाशित पत्र 5 अथवा अधिक बार सूचित हो आपके द्वारा अनुचरित लेखाओं से + संवेदनशील विषयवस्तु दिखाएँ + चेतावनी सन्देश दिखाता है जब प्रकाशित पत्र के लेखक ने उसे संवेदनशील चिह्नित किया नया प्रतिक्रिया चिह्न इस उपयोगकर्ता के लिए कोई प्रतिक्रिया प्रकार पूर्व चयनित नहीं। हृदयचिह्न घुण्डी पर दीर्घतः दबाएँ परिवर्तन करने के लिए ज्सापोपार्जन योजना @@ -543,6 +559,8 @@ निर्गमनांकन करने पर आपकी सारी स्थानीय जानकारी मिट जाएगी। सुनिश्चित करें कि आपके निजी कुंचिकाएँ सुरक्षित रखें हैं अपनी लेखा नहीं खोना चाहते हैं तो। क्या आप आगे बढना चाहते हैं? अनुचरित विषयसूचक पुनःप्रसारक + अनुचरण पोटलियाँ + पठितव्य लेख आविष्करण पण्यक्षेत्र तत्क्षणप्रसार @@ -606,6 +624,7 @@ सक्रिय करें सार्वजनिक नया निजी अथवा सार्वजनिक झुण्ड + पुनःप्रसारक निजी के लिए विषय @@ -798,6 +817,7 @@ नया पत्र प्रकाशन नये छोटे : चित्र अथवा चलचित्र नया सामुदायिक टीका + नया उत्पाद इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को खोलें इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को अवरोधित करें उत्तर @@ -947,4 +967,6 @@ सूचनावली छानने के लिए सूची चुनें यन्त्र ताला लगने पर निर्गमनांकन करें निजी सन्देश + चर्चा पुनःप्रसारक + वह पुनःप्रसारक जिससे इस चर्चा के सभी उपयोगकर्ता जुडते हैं