Merge branch 'main' into optimise-imports

This commit is contained in:
davotoula
2025-11-03 19:45:12 +01:00
29 changed files with 729 additions and 252 deletions
+1 -1
View File
@@ -136,7 +136,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [ ] Key Migration (NIP-xx/Draft)
- [ ] Time-based Sync (NIP-xx/Draft)
- [x] Image Capture in the app
- [ ] Video Capture in the app
- [x] Video Capture in the app
- [ ] Local Database
- [ ] Workspaces
- [ ] Infinity Scroll
@@ -0,0 +1,133 @@
/**
* 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
* 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.service.uploads
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.Log
object BlurhashMetadataCalculator {
private fun isImage(mimeType: String?) = mimeType?.startsWith("image/", ignoreCase = true) == true
private fun isVideo(mimeType: String?) = mimeType?.startsWith("video/", ignoreCase = true) == true
fun shouldAttempt(mimeType: String?): Boolean = isImage(mimeType) || isVideo(mimeType)
private fun createBitmapOptions() =
BitmapFactory.Options().apply {
inPreferredConfig = Bitmap.Config.ARGB_8888
}
private fun processImage(
bitmap: Bitmap?,
dimPrecomputed: DimensionTag?,
): Pair<BlurhashWrapper?, DimensionTag?> {
val (blur, dim) = processBitmap(bitmap)
return blur to (dim ?: dimPrecomputed)
}
fun computeFromBytes(
data: ByteArray,
mimeType: String?,
dimPrecomputed: DimensionTag?,
): Pair<BlurhashWrapper?, DimensionTag?> =
when {
isImage(mimeType) -> {
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, createBitmapOptions())
processImage(bitmap, dimPrecomputed)
}
isVideo(mimeType) -> {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(ByteArrayMediaDataSource(data))
processRetriever(retriever, dimPrecomputed)
} finally {
retriever.release()
}
}
else -> null to dimPrecomputed
}
fun computeFromUri(
context: Context,
uri: Uri,
mimeType: String?,
dimPrecomputed: DimensionTag? = null,
): Pair<BlurhashWrapper?, DimensionTag?>? {
if (!shouldAttempt(mimeType)) return null
return try {
when {
isImage(mimeType) ->
context.contentResolver.openInputStream(uri)?.use { stream ->
val bitmap = BitmapFactory.decodeStream(stream, null, createBitmapOptions())
processImage(bitmap, dimPrecomputed)
} ?: (null to dimPrecomputed)
isVideo(mimeType) -> {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(context, uri)
processRetriever(retriever, dimPrecomputed)
} finally {
retriever.release()
}
}
else -> null
}
} catch (e: Exception) {
Log.w("BlurhashMetadataCalc", "Failed to compute metadata from uri", e)
null
}
}
private fun processBitmap(bitmap: Bitmap?): Pair<BlurhashWrapper?, DimensionTag?> =
if (bitmap != null) {
try {
val blurhash = BlurhashWrapper(bitmap.toBlurhash())
blurhash to DimensionTag(bitmap.width, bitmap.height)
} finally {
bitmap.recycle()
}
} else {
null to null
}
private fun processRetriever(
retriever: MediaMetadataRetriever,
dimPrecomputed: DimensionTag?,
): Pair<BlurhashWrapper?, DimensionTag?> {
val dim = retriever.prepareDimFromVideo() ?: dimPrecomputed
val blurhash = retriever.getThumbnail()?.toBlurhash()?.let { BlurhashWrapper(it) }
return if (dim?.hasSize() == true) {
blurhash to dim
} else {
blurhash to null
}
}
}
@@ -20,11 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.uploads
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaDataSource
import android.media.MediaMetadataRetriever
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -75,27 +71,7 @@ class FileHeader(
val hash = sha256(data).toHexKey()
val size = data.size
val (blurHash, dim) =
if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
Pair(BlurhashWrapper(mBitmap.toBlurhash()), DimensionTag(mBitmap.width, mBitmap.height))
} else if (mimeType?.startsWith("video/") == true) {
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
val blurhash = mediaMetadataRetriever.getThumbnail()?.toBlurhash()?.let { BlurhashWrapper(it) }
if (newDim?.hasSize() == true) {
Pair(blurhash, newDim)
} else {
Pair(blurhash, null)
}
} else {
Pair(null, null)
}
val (blurHash, dim) = BlurhashMetadataCalculator.computeFromBytes(data, mimeType, dimPrecomputed)
Result.success(FileHeader(mimeType, hash, size, dim, blurHash))
} catch (e: Exception) {
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.amethyst.service.uploads
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256StreamWithCount
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -34,37 +37,67 @@ class ImageDownloader {
val contentType: String?,
)
suspend fun waitAndGetImage(
imageUrl: String,
okHttpClient: (url: String) -> OkHttpClient,
): Blob? =
/**
* Result of streaming verification - hash and metadata without storing full file
*/
class StreamVerification(
val hash: HexKey,
val size: Long,
val contentType: String?,
)
/**
* Stream download and calculate hash for verification without loading entire file into memory.
* This is memory-efficient for large files (videos, high-res images, etc.)
*/
private suspend fun <T> retryWithDelay(
maxAttempts: Int = 15,
delayMs: Long = 1000,
operation: suspend () -> T?,
): T? =
withContext(Dispatchers.IO) {
var imageData: Blob? = null
var result: T? = null
var tentatives = 0
// Servers are usually not ready, so tries to download it for 15 times/seconds.
while (imageData == null && tentatives < 15) {
imageData =
while (result == null && tentatives < maxAttempts) {
result =
try {
tryGetTheImage(imageUrl, okHttpClient)
operation()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
if (imageData == null) {
if (result == null) {
tentatives++
delay(1000)
delay(delayMs)
}
}
return@withContext imageData
return@withContext result
}
private suspend fun tryGetTheImage(
suspend fun waitAndVerifyStream(
imageUrl: String,
okHttpClient: (url: String) -> OkHttpClient,
): Blob? =
): StreamVerification? = retryWithDelay { tryStreamAndVerify(imageUrl, okHttpClient) }
suspend fun waitAndGetImage(
imageUrl: String,
okHttpClient: (url: String) -> OkHttpClient,
): Blob? = retryWithDelay { tryGetTheImage(imageUrl, okHttpClient) }
private data class HttpConnection(
val connection: HttpURLConnection,
val responseCode: Int,
val contentType: String?,
)
private suspend fun openHttpConnection(
imageUrl: String,
okHttpClient: (url: String) -> OkHttpClient,
): HttpConnection =
withContext(Dispatchers.IO) {
// TODO: Migrate to OkHttp
HttpURLConnection.setFollowRedirects(true)
@@ -79,10 +112,11 @@ class ImageDownloader {
huc.instanceFollowRedirects = true
var responseCode = huc.responseCode
// Handle redirects
if (responseCode in 300..400) {
val newUrl: String = huc.getHeaderField("Location")
// open the new connnection again
// open the new connection again
url = URL(newUrl)
clientProxy = okHttpClient(newUrl).proxy
huc =
@@ -94,13 +128,59 @@ class ImageDownloader {
responseCode = huc.responseCode
}
return@withContext if (responseCode in 200..300) {
Blob(
huc.inputStream.use { it.readBytes() },
huc.headerFields.get("Content-Type")?.firstOrNull(),
)
} else {
null
return@withContext HttpConnection(
connection = huc,
responseCode = responseCode,
contentType = huc.headerFields.get("Content-Type")?.firstOrNull(),
)
}
private suspend fun tryStreamAndVerify(
imageUrl: String,
okHttpClient: (url: String) -> OkHttpClient,
): StreamVerification? =
withContext(Dispatchers.IO) {
val httpConn = openHttpConnection(imageUrl, okHttpClient)
return@withContext try {
if (httpConn.responseCode in 200..300) {
val (hash, totalBytes) =
httpConn.connection.inputStream.use {
sha256StreamWithCount(it)
}
StreamVerification(
hash = hash.toHexKey(),
size = totalBytes,
contentType = httpConn.contentType,
)
} else {
null
}
} finally {
// Always disconnect to release connection resources
httpConn.connection.disconnect()
}
}
private suspend fun tryGetTheImage(
imageUrl: String,
okHttpClient: (url: String) -> OkHttpClient,
): Blob? =
withContext(Dispatchers.IO) {
val httpConn = openHttpConnection(imageUrl, okHttpClient)
return@withContext try {
if (httpConn.responseCode in 200..300) {
Blob(
httpConn.connection.inputStream.use { it.readBytes() },
httpConn.contentType,
)
} else {
null
}
} finally {
httpConn.connection.disconnect()
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.uploads
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -42,4 +43,14 @@ data class MediaUploadResult(
val infohash: String? = null,
// ipfs link
val ipfs: String? = null,
)
// blurhash value for previews
val blurHash: BlurhashWrapper? = null,
) {
fun mergeLocalMetadata(localMetadata: Pair<BlurhashWrapper?, DimensionTag?>?): MediaUploadResult =
localMetadata?.let { (blur, dim) ->
copy(
dimension = dim ?: dimension,
blurHash = blur ?: blurHash,
)
} ?: this
}
@@ -231,38 +231,34 @@ class UploadOrchestrator {
updateState(0.6, UploadingState.Downloading)
val imageData: ImageDownloader.Blob? = ImageDownloader().waitAndGetImage(uploadResult.url, okHttpClient)
// Use streaming verification for memory efficiency with large files
val verification =
ImageDownloader().waitAndVerifyStream(uploadResult.url, okHttpClient)
?: return error(R.string.could_not_download_from_the_server)
if (imageData != null) {
updateState(0.8, UploadingState.Hashing)
updateState(0.8, UploadingState.Hashing)
val result =
FileHeader.prepare(
imageData.bytes,
uploadResult.type ?: localContentType ?: imageData.contentType,
uploadResult.dimension,
)
result.fold(
onSuccess = {
return finish(
OrchestratorResult.ServerResult(
it,
uploadResult.url,
uploadResult.magnet,
uploadResult.sha256,
originalContentType,
originalHash,
),
)
},
onFailure = {
return error(R.string.could_not_prepare_local_file_to_upload, it.message ?: it.javaClass.simpleName)
},
// Create FileHeader with hash from streaming verification
// Note: We skip blurhash/dimensions since we already have them from upload
val fileHeader =
FileHeader(
mimeType = uploadResult.type ?: localContentType ?: verification.contentType,
hash = verification.hash,
size = verification.size.toInt(),
dim = uploadResult.dimension,
blurHash = uploadResult.blurHash,
)
} else {
return error(R.string.could_not_download_from_the_server)
}
return finish(
OrchestratorResult.ServerResult(
fileHeader,
uploadResult.url,
uploadResult.magnet,
uploadResult.sha256,
originalContentType,
originalHash,
),
)
}
sealed class OrchestratorResult {
@@ -28,6 +28,7 @@ import android.webkit.MimeTypeMap
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.BlurhashMetadataCalculator
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -36,7 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.utils.sha256.sha256StreamWithCount
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@@ -83,34 +84,35 @@ class BlossomUploader {
val fileName = context.getFileName(uri)
val imageInputStreamForHash = contentResolver.openInputStream(uri)
val payload =
imageInputStreamForHash?.use {
it.readBytes()
checkNotNull(imageInputStreamForHash) { "Can't open the image input stream" }
val (hash, size) =
imageInputStreamForHash.use { stream ->
val (hashBytes, totalBytes) = sha256StreamWithCount(stream)
hashBytes.toHexKey() to totalBytes
}
checkNotNull(payload) { "Can't open the image input stream" }
val hash = sha256(payload).toHexKey()
val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType)
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) { "Can't open the image input stream" }
return imageInputStream.use { stream ->
upload(
stream,
hash,
payload.size,
fileName,
myContentType,
alt,
sensitiveContent,
serverBaseUrl,
okHttpClient,
httpAuth,
context,
)
}
return imageInputStream
.use { stream ->
upload(
stream,
hash,
size,
fileName,
myContentType,
alt,
sensitiveContent,
serverBaseUrl,
okHttpClient,
httpAuth,
context,
)
}.mergeLocalMetadata(localMetadata)
}
fun encodeAuth(event: BlossomAuthorizationEvent): String {
@@ -121,7 +123,7 @@ class BlossomUploader {
suspend fun upload(
inputStream: InputStream,
hash: HexKey,
length: Int,
length: Long,
baseFileName: String?,
contentType: String?,
alt: String?,
@@ -146,14 +148,14 @@ class BlossomUploader {
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length.toLong()
override fun contentLength() = length
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
}
httpAuth(hash, length.toLong(), alt?.let { "Uploading $it" } ?: "Uploading $fileName")?.let {
httpAuth(hash, length, alt?.let { "Uploading $it" } ?: "Uploading $fileName")?.let {
requestBuilder.addHeader("Authorization", encodeAuth(it))
}
@@ -30,6 +30,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.BlurhashMetadataCalculator
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
@@ -106,24 +107,26 @@ class Nip96Uploader {
val myContentType = contentType ?: contentResolver.getType(uri)
val length = size ?: contentResolver.querySize(uri) ?: fileSize(uri) ?: 0
val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType)
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) { "Can't open the image input stream" }
return imageInputStream.use { stream ->
upload(
stream,
length,
myContentType,
alt,
sensitiveContent,
server,
okHttpClient,
onProgress,
httpAuth,
context,
)
}
return imageInputStream
.use { stream ->
upload(
stream,
length,
myContentType,
alt,
sensitiveContent,
server,
okHttpClient,
onProgress,
httpAuth,
context,
)
}.mergeLocalMetadata(localMetadata)
}
suspend fun upload(
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji
import com.vitorpamplona.amethyst.commons.richtext.Segment
import com.vitorpamplona.amethyst.commons.richtext.VideoSegment
import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
import com.vitorpamplona.amethyst.model.HashtagIcon
import com.vitorpamplona.amethyst.model.LocalCache
@@ -414,6 +415,8 @@ private fun RenderWordWithoutPreview(
when (word) {
// Don't preview Images
is ImageSegment -> ClickableUrl(word.segmentText, word.segmentText)
// Don't preview Videos
is VideoSegment -> ClickableUrl(word.segmentText, word.segmentText)
is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
// Don't offer to pay invoices
@@ -445,6 +448,7 @@ private fun RenderWordWithPreview(
) {
when (word) {
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel)
@@ -441,7 +441,7 @@ class ChatNewMessageViewModel :
val urls = findURLs(message.text)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value)
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
val message = message.text
val contentWarningReason = if (wantsToMarkAsSensitive) "" else null
@@ -385,7 +385,7 @@ open class ChannelNewMessageViewModel :
val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value)
val channelRelays = channel.relays()
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
return if (channel is PublicChatChannel) {
val replyingToEvent = replyTo.value?.toEventHint<ChannelMessageEvent>()
@@ -327,7 +327,7 @@ open class NewProductViewModel :
val urls = findURLs(tagger.message)
val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() }
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null
@@ -515,7 +515,7 @@ open class ShortNotePostViewModel :
val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null
val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
+22 -27
View File
@@ -459,6 +459,8 @@
<string name="follow_set_private_presence_indicator">%1$s je soukromý člen</string>
<string name="follow_set_public_member_add_label">Přidat jako veřejného člena</string>
<string name="follow_set_private_member_add_label">Přidat jako soukromého člena</string>
<string name="follow_set_public_members_header_label2">Veřejní členové</string>
<string name="follow_set_private_members_header_label2">Soukromí členové</string>
<string name="follow_set_public_members_header_label">Veřejné profily</string>
<string name="follow_set_private_members_header_label">Soukromé profily</string>
<string name="follow_set_single_member_label">člen</string>
@@ -703,6 +705,16 @@
<string name="paste_from_clipboard">Vložit ze schránky</string>
<string name="invalid_nip47_uri_title">Neplatné NIP-47 URI</string>
<string name="invalid_nip47_uri_description">URI %1$s není platné přihlašovací URI NIP-47.</string>
<string name="connect_to_new_nwc_wallet">Připojit novou NWC peněženku</string>
<string name="couldnt_find_nwc_wallets">Peněženka nebyla nalezená</string>
<string name="couldnt_find_nwc_wallets_description">Amethyst nenašel žádné aplikace peněženek, které podporují Nostr Wallet Connect (NWC).
\n\nCo můžete udělat:
\n\t1. Ověřte podporu NWC: Zkontrolujte, že vaše oblíbená peněženka podporuje Nostr Wallet Connect.
\n\t2. Vytvořte připojení: Otevřete svou peněženku a vytvořte nové NWC připojení.
\n\t3. Propojte s Amethyst: Vaše peněženka obvykle nabídne tři možnosti:
\n\t\t3.1 Automatické propojení: Tlačítko pro automatické otevření a připojení k Amethyst. Klikněte na něj, otevřete Amethyst a uložte na nové obrazovce.
\n\t\t3.2 Ruční propojení: URI připojení, které můžete zkopírovat a vložit přes ikonu vložení na této obrazovce.
\n\t\t3.3 QR kód: Peněženka vytvoří QR kód, který můžete naskenovat pomocí tlačítka QR kódu na této obrazovce.</string>
<string name="language_description">Pro rozhraní aplikace</string>
<string name="theme_description">Tmavé, světlé nebo systémové téma</string>
<string name="automatically_load_images_gifs_description">Automaticky načítat obrázky a GIFy</string>
@@ -934,6 +946,16 @@
<string name="search_relays_not_found_description">Vytvoření seznamu relé speciálně navržených pro vyhledávání a označování uživatelů zlepší tyto výsledky.</string>
<string name="search_relays_not_found_editing">Vložte mezi 13 relé, která chcete použít při vyhledávání obsahu nebo označování uživatelů. Ujistěte se, že vaše vybraná relé implementují NIP-50</string>
<string name="search_relays_not_found_examples">Dobré možnosti jsou:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="outbox_relays_title">Relaye pro odesílání</string>
<string name="outbox_relays_not_found">Nastavte své veřejné relaye pro odesílání příspěvků</string>
<string name="outbox_relays_not_found_description">Vytvoření seznamu relayů určených pro přijímání vašeho obsahu je zásadní pro váš zážitek z Nostr a jediný způsob, jak vás mohou vaši sledující najít.</string>
<string name="outbox_relays_not_found_editing">Vložte 13 relaye, které přijímají vaše příspěvky. Ujistěte se, že nevyžadují platbu, pokud neplatíte za vložení.</string>
<string name="outbox_relays_not_found_examples">Dobré možnosti jsou:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Relaye pro přijímání</string>
<string name="inbox_relays_not_found">Nastavte své veřejné relaye pro přijímání oznámení</string>
<string name="inbox_relays_not_found_description">Vytvoření seznamu relayů určených pro přijímání oznámení je zásadní pro váš zážitek z Nostr.</string>
<string name="inbox_relays_not_found_editing">Vložte 13 relaye, které přijímají oznámení pro vás. Ujistěte se, že nevyžadují platbu ani web of trust od odesílatele, jinak budete dostávat oznámení pouze od plátců nebo dlouhodobých uživatelů.</string>
<string name="inbox_relays_not_found_examples">Dobré možnosti jsou:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="dm_upload">Nahrát v DM</string>
<string name="relay_settings">Nastavení relé</string>
<string name="public_home_section">Veřejná domácí relé</string>
@@ -1082,31 +1104,4 @@
<string name="crashreport_found_send">Odeslat</string>
<string name="this_message_will_disappear_in_days">Tato zpráva zmizí za %1$d dní</string>
<string name="select_signer">Vybrat podepisovatele</string>
<string name="outbox_relays_title">Relaye pro odesílání</string>
<string name="outbox_relays_not_found">Nastavte své veřejné relaye pro odesílání příspěvků</string>
<string name="outbox_relays_not_found_description">Vytvoření seznamu relayů určených pro přijímání vašeho obsahu je zásadní pro váš zážitek z Nostr a jediný způsob, jak vás mohou vaši sledující najít.</string>
<string name="outbox_relays_not_found_editing">Vložte 13 relaye, které přijímají vaše příspěvky. Ujistěte se, že nevyžadují platbu, pokud neplatíte za vložení.</string>
<string name="outbox_relays_not_found_examples">Dobré možnosti jsou:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Relaye pro přijímání</string>
<string name="inbox_relays_not_found">Nastavte své veřejné relaye pro přijímání oznámení</string>
<string name="inbox_relays_not_found_description">Vytvoření seznamu relayů určených pro přijímání oznámení je zásadní pro váš zážitek z Nostr.</string>
<string name="inbox_relays_not_found_editing">Vložte 13 relaye, které přijímají oznámení pro vás. Ujistěte se, že nevyžadují platbu ani web of trust od odesílatele, jinak budete dostávat oznámení pouze od plátců nebo dlouhodobých uživatelů.</string>
<string name="inbox_relays_not_found_examples">Dobré možnosti jsou:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="follow_set_public_members_header_label2">Veřejní členové</string>
<string name="follow_set_private_members_header_label2">Soukromí členové</string>
<string name="connect_to_new_nwc_wallet">Připojit novou NWC peněženku</string>
<string name="couldnt_find_nwc_wallets">Peněženka nebyla nalezená</string>
<string name="couldnt_find_nwc_wallets_description">Amethyst nenašel žádné aplikace peněženek, které podporují Nostr Wallet Connect (NWC).
\n\nCo můžete udělat:
\n\t1. Ověřte podporu NWC: Zkontrolujte, že vaše oblíbená peněženka podporuje Nostr Wallet Connect.
\n\t2. Vytvořte připojení: Otevřete svou peněženku a vytvořte nové NWC připojení.
\n\t3. Propojte s Amethyst: Vaše peněženka obvykle nabídne tři možnosti:
\n\t\t3.1 Automatické propojení: Tlačítko pro automatické otevření a připojení k Amethyst. Klikněte na něj, otevřete Amethyst a uložte na nové obrazovce.
\n\t\t3.2 Ruční propojení: URI připojení, které můžete zkopírovat a vložit přes ikonu vložení na této obrazovce.
\n\t\t3.3 QR kód: Peněženka vytvoří QR kód, který můžete naskenovat pomocí tlačítka QR kódu na této obrazovce.
</string>
</resources>
+22 -28
View File
@@ -465,6 +465,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="follow_set_private_presence_indicator">%1$s ist ein privates Mitglied</string>
<string name="follow_set_public_member_add_label">Als öffentliches Mitglied hinzufügen</string>
<string name="follow_set_private_member_add_label">Als privates Mitglied hinzufügen</string>
<string name="follow_set_public_members_header_label2">Öffentliche Mitglieder</string>
<string name="follow_set_private_members_header_label2">Private Mitglieder</string>
<string name="follow_set_public_members_header_label">Öffentliche Profile</string>
<string name="follow_set_private_members_header_label">Private Profile</string>
<string name="follow_set_single_member_label">Mitglied</string>
@@ -708,6 +710,16 @@ anz der Bedingungen ist erforderlich</string>
<string name="paste_from_clipboard">Aus Zwischenablage einfügen</string>
<string name="invalid_nip47_uri_title">Ungültige NIP-47 URI</string>
<string name="invalid_nip47_uri_description">Die URI %1$s ist keine gültige NIP-47 Anmelde-URI.</string>
<string name="connect_to_new_nwc_wallet">Mit neuer NWC-Wallet verbinden</string>
<string name="couldnt_find_nwc_wallets">Wallet nicht gefunden</string>
<string name="couldnt_find_nwc_wallets_description">Amethyst konnte keine Wallet-Apps finden, die Nostr Wallet Connect (NWC) unterstützen.
\n\nWas du tun kannst:
\n\t1. NWC-Unterstützung überprüfen: Stelle sicher, dass deine bevorzugte Wallet-App mit Nostr Wallet Connect kompatibel ist.
\n\t2. Verbindung erstellen: Öffne deine Wallet-App und erstelle eine neue NWC-Verbindung.
\n\t3. Mit Amethyst verknüpfen: Deine Wallet bietet normalerweise drei Optionen:
\n\t\t3.1 Automatische Verknüpfung: Eine Schaltfläche zum automatischen Öffnen und Verbinden mit Amethyst. Klicke darauf, öffne Amethyst und speichere auf dem neuen Bildschirm.
\n\t\t3.2 Manuelle Verknüpfung: Eine Verbindungs-URI, die du kopieren und über das Einfügesymbol auf diesem Bildschirm einfügen kannst.
\n\t\t3.3 QR-Code: Die Wallet erstellt einen QR-Code, den du über die QR-Schaltfläche auf diesem Bildschirm scannen kannst.</string>
<string name="language_description">Für die App-Benutzeroberfläche</string>
<string name="theme_description">Dunkles, helles oder Systemdesign</string>
<string name="automatically_load_images_gifs_description">Bilder und GIFs automatisch laden</string>
@@ -939,6 +951,16 @@ anz der Bedingungen ist erforderlich</string>
<string name="search_relays_not_found_description">Das Erstellen einer Relaisliste, die speziell für die Suche und Benutzerkennzeichnung entwickelt wurde, wird diese Ergebnisse verbessern.</string>
<string name="search_relays_not_found_editing">Fügen Sie 13 Relais ein, die beim Suchen nach Inhalten oder beim Taggen von Benutzern verwendet werden sollen. Stellen Sie sicher, dass Ihre ausgewählten Relais NIP-50 implementieren</string>
<string name="search_relays_not_found_examples">Gute Optionen sind:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="outbox_relays_title">Ausgehende Relays</string>
<string name="outbox_relays_not_found">Richte deine öffentlichen Ausgehenden Relays zum Posten ein</string>
<string name="outbox_relays_not_found_description">Das Erstellen einer Relay-Liste, die speziell zum Empfangen deiner Inhalte gedacht ist, ist entscheidend für dein Nostr-Erlebnis und der einzige Weg, wie dich deine Follower finden können.</string>
<string name="outbox_relays_not_found_editing">Füge 13 Relays hinzu, die deine Beiträge empfangen. Stelle sicher, dass sie keine Zahlung verlangen, wenn du nicht für das Einfügen zahlst.</string>
<string name="outbox_relays_not_found_examples">Gute Optionen sind:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Eingehende Relays</string>
<string name="inbox_relays_not_found">Richte deine öffentlichen Eingehenden Relays zum Empfangen von Benachrichtigungen ein</string>
<string name="inbox_relays_not_found_description">Das Erstellen einer Relay-Liste, die speziell zum Empfangen von Benachrichtigungen gedacht ist, ist entscheidend für dein Nostr-Erlebnis.</string>
<string name="inbox_relays_not_found_editing">Füge 13 Relays hinzu, die Benachrichtigungen für dich empfangen. Stelle sicher, dass sie keine Zahlung oder Web of Trust vom Absender verlangen, sonst erhältst du nur Benachrichtigungen von zahlenden oder langjährigen Nutzern.</string>
<string name="inbox_relays_not_found_examples">Gute Optionen sind:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="dm_upload">DM-Upload</string>
<string name="relay_settings">Relaiseinstellungen</string>
<string name="public_home_section">Öffentliche Heimrelais</string>
@@ -1087,32 +1109,4 @@ anz der Bedingungen ist erforderlich</string>
<string name="crashreport_found_send">Senden</string>
<string name="this_message_will_disappear_in_days">Diese Nachricht verschwindet in %1$d Tagen</string>
<string name="select_signer">Signierer auswählen</string>
<string name="outbox_relays_title">Ausgehende Relays</string>
<string name="outbox_relays_not_found">Richte deine öffentlichen Ausgehenden Relays zum Posten ein</string>
<string name="outbox_relays_not_found_description">Das Erstellen einer Relay-Liste, die speziell zum Empfangen deiner Inhalte gedacht ist, ist entscheidend für dein Nostr-Erlebnis und der einzige Weg, wie dich deine Follower finden können.</string>
<string name="outbox_relays_not_found_editing">Füge 13 Relays hinzu, die deine Beiträge empfangen. Stelle sicher, dass sie keine Zahlung verlangen, wenn du nicht für das Einfügen zahlst.</string>
<string name="outbox_relays_not_found_examples">Gute Optionen sind:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Eingehende Relays</string>
<string name="inbox_relays_not_found">Richte deine öffentlichen Eingehenden Relays zum Empfangen von Benachrichtigungen ein</string>
<string name="inbox_relays_not_found_description">Das Erstellen einer Relay-Liste, die speziell zum Empfangen von Benachrichtigungen gedacht ist, ist entscheidend für dein Nostr-Erlebnis.</string>
<string name="inbox_relays_not_found_editing">Füge 13 Relays hinzu, die Benachrichtigungen für dich empfangen. Stelle sicher, dass sie keine Zahlung oder Web of Trust vom Absender verlangen, sonst erhältst du nur Benachrichtigungen von zahlenden oder langjährigen Nutzern.</string>
<string name="inbox_relays_not_found_examples">Gute Optionen sind:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="follow_set_public_members_header_label2">Öffentliche Mitglieder</string>
<string name="follow_set_private_members_header_label2">Private Mitglieder</string>
<string name="connect_to_new_nwc_wallet">Mit neuer NWC-Wallet verbinden</string>
<string name="couldnt_find_nwc_wallets">Wallet nicht gefunden</string>
<string name="couldnt_find_nwc_wallets_description">Amethyst konnte keine Wallet-Apps finden, die Nostr Wallet Connect (NWC) unterstützen.
\n\nWas du tun kannst:
\n\t1. NWC-Unterstützung überprüfen: Stelle sicher, dass deine bevorzugte Wallet-App mit Nostr Wallet Connect kompatibel ist.
\n\t2. Verbindung erstellen: Öffne deine Wallet-App und erstelle eine neue NWC-Verbindung.
\n\t3. Mit Amethyst verknüpfen: Deine Wallet bietet normalerweise drei Optionen:
\n\t\t3.1 Automatische Verknüpfung: Eine Schaltfläche zum automatischen Öffnen und Verbinden mit Amethyst. Klicke darauf, öffne Amethyst und speichere auf dem neuen Bildschirm.
\n\t\t3.2 Manuelle Verknüpfung: Eine Verbindungs-URI, die du kopieren und über das Einfügesymbol auf diesem Bildschirm einfügen kannst.
\n\t\t3.3 QR-Code: Die Wallet erstellt einen QR-Code, den du über die QR-Schaltfläche auf diesem Bildschirm scannen kannst.
</string>
</resources>
@@ -951,6 +951,16 @@
<string name="search_relays_not_found_description">खोज तथा उपयोगकर्ता सूचक जोडने के लिए स्पष्टतः रूपांकित पुनःप्रसारक सूची बनाने से इन परिणामों में शोधन होगा।</string>
<string name="search_relays_not_found_editing">विषयवस्तु खोजने के लिए अथवा उपयोगकर्ता सूचक जोडने में उपयोग करनें के लिए १ - ३ पुनःप्रसारकों को जोडें। सुनिश्चित करें कि आपके चयनित पुनःप्रसारक निप॰-५० को कार्यान्वित किये हैं।</string>
<string name="search_relays_not_found_examples">ये अच्छे विकल्प हैं :\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="outbox_relays_title">निर्गतपेटिका पुनःप्रसारक सूची</string>
<string name="outbox_relays_not_found">अपने सार्वजनिक निर्गतपेटिका पुनःप्रसारकों की स्थापना करें पत्र प्रकाशन के लिए</string>
<string name="outbox_relays_not_found_description">आपके विषयवस्तु प्राप्त करने के लिए पुनःप्रसारक सूची की विशिष्ट रचना आवश्यक है नोस्टर में। तथा आपके अनुचरों के लिए आपको ढूँढने की एकमात्र विधि। </string>
<string name="outbox_relays_not_found_editing">१ - ३ पुनःप्रसारकों को जोडें जो आपके पत्र स्वीकारते हैं। सुनिश्चित करें कि इनके लिए भुगतान अनावश्यक हैं यदि आप भुगतान नहीं कर रहे हैं अभिलेखन के लिए</string>
<string name="outbox_relays_not_found_examples">ये अच्छे विकल्प हैं :\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">आगतपेटिका पुनःप्रसारक सूची</string>
<string name="inbox_relays_not_found">आपके सार्वजनिक आगतपेटिका पुनःप्रसारकों की स्थापना करें सूचनाएँ प्राप्त करने के लिए</string>
<string name="inbox_relays_not_found_description">सूचनाएँ प्राप्त करने के लिए पुनःप्रसारक सूची की विशिष्ट रचना आवश्यक है नोस्टर में। </string>
<string name="inbox_relays_not_found_editing">१ - ३ पुनःप्रसारकों को जोडें जो आपके लिए सूचनाएँ स्वीकारते हैं। सुनिश्चित करें कि इनके लिए भुगतान अथवा विश्वासजाल अनुमति अनावश्यक हैं अन्यथा आप केवल भुगतान करनेवालों से अथवा दीर्घकालिक प्रयोक्ताओं से सूचनाएँ प्राप्त करेंगे</string>
<string name="inbox_relays_not_found_examples">ये अच्छे विकल्प हैं :\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="dm_upload">सीधा संदेश आरोहण</string>
<string name="relay_settings">पुनःप्रसारक स्थापना विकल्प</string>
<string name="public_home_section">सार्वजनिक मुख्य पुनःप्रसारक</string>
@@ -951,6 +951,16 @@
<string name="search_relays_not_found_description">A kifejezetten a kereséshez és a felhasználói címkézéshez tervezett átjátszólista létrehozása javítani fogja ezeket az eredményeket.</string>
<string name="search_relays_not_found_editing">Adjon hozzá 13 átjátszót a tartalom kereséshez vagy a felhasználók címkézéséhez. Győződjön meg arról, hogy a kiválasztott átjátszók alkalmazzák a NIP-50-et</string>
<string name="search_relays_not_found_examples">Jó választási lehetőségek:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="outbox_relays_title">Átjátszók a kimenő üzenetkhez</string>
<string name="outbox_relays_not_found">Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez</string>
<string name="outbox_relays_not_found_description">A tartalom fogadására kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez, és ez az egyetlen módja annak, hogy a követői megtalálják Önt. </string>
<string name="outbox_relays_not_found_editing">Adjon meg 1-3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért</string>
<string name="outbox_relays_not_found_examples">Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Átjátszók a bejövő üzenetkhez</string>
<string name="inbox_relays_not_found">Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához</string>
<string name="inbox_relays_not_found_description">Az értesítések fogadásához kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez. </string>
<string name="inbox_relays_not_found_editing">Adjon meg 1-3 átjátszót, amelyeken keresztül fogadni tudja az értesítéseket. Győződjön meg arról, hogy azok nem igényelnek fizetést vagy bizalmi hálózatot a feladótól, különben csak a fizetősektől vagy a hosszú távú felhasználóktól fog értesítéseket kapni</string>
<string name="inbox_relays_not_found_examples">Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="dm_upload">Feltöltés közvetlen üzenetbe</string>
<string name="relay_settings">Átjátszók beállításai</string>
<string name="public_home_section">Nyilvános kimenő és saját átjátszók</string>
@@ -948,6 +948,16 @@
<string name="search_relays_not_found_description">Stworzenie listy transmiterów specjalnie przeznaczonych do wyszukiwania i tagowania użytkowników poprawi te wyniki.</string>
<string name="search_relays_not_found_editing">Wstaw od 1 do 3 transmiterów, które będą używane podczas wyszukiwania treści lub tagowania użytkowników. Upewnij się, że zaimplementowano NIP-50</string>
<string name="search_relays_not_found_examples">Dobre opcje to:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="outbox_relays_title">Transmitery wysyłające</string>
<string name="outbox_relays_not_found">Skonfiguruj swoje publiczne transmitery wysyłające, aby publikować</string>
<string name="outbox_relays_not_found_description">Stworzenie listy transmiterów specjalnie zaprojektowanej do odbierania Twoich wpisów ma kluczowe znaczenie dla korzystania z sieci Nostr i jest jedynym sposobem, w jaki Twoi obserwujący mogą Cię znaleźć. </string>
<string name="outbox_relays_not_found_editing">Wstaw od 1 do 3 transmiterów, które odbierają Twoje wpisy. Upewnij się, że nie wymagają one płatności, jeśli nie opłacasz wstawiania wpisów</string>
<string name="outbox_relays_not_found_examples">Dobre opcje to:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Transmitery odbiorcze</string>
<string name="inbox_relays_not_found">Skonfiguruj swoje publiczne transmitery skrzynki odbiorczej, aby otrzymywać powiadomienia</string>
<string name="inbox_relays_not_found_description">Stworzenie listy transmiterów specjalnie zaprojektowanej do odbierania powiadomień ma kluczowe znaczenie dla korzystania z protokołu Nostr. </string>
<string name="inbox_relays_not_found_editing">Wstaw od 1 do 3 transmiterów, które będą odbierać powiadomienia. Upewnij się, że nie wymagają one płatności ani statusu \"web of trust\" od nadawcy, w przeciwnym razie będziesz otrzymywać powiadomienia tylko od płatników lub stałych użytkowników</string>
<string name="inbox_relays_not_found_examples">Dobre opcje to:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="dm_upload">Wgrywanie DM</string>
<string name="relay_settings">Ustawienia Transmiterów</string>
<string name="public_home_section">Publiczne transmitery domowe</string>
+22 -28
View File
@@ -459,6 +459,8 @@
<string name="follow_set_private_presence_indicator">%1$s é um membro privado</string>
<string name="follow_set_public_member_add_label">Adicionar como membro público</string>
<string name="follow_set_private_member_add_label">Adicionar como membro privado</string>
<string name="follow_set_public_members_header_label2">Membros Públicos</string>
<string name="follow_set_private_members_header_label2">Membros Privados</string>
<string name="follow_set_public_members_header_label">Perfis públicos</string>
<string name="follow_set_private_members_header_label">Perfis privados</string>
<string name="follow_set_single_member_label">membro</string>
@@ -703,6 +705,16 @@
<string name="paste_from_clipboard">Colar da área de transferência</string>
<string name="invalid_nip47_uri_title">URI NIP-47 inválido</string>
<string name="invalid_nip47_uri_description">O URI %1$s não é um URI de login NIP-47 válido.</string>
<string name="connect_to_new_nwc_wallet">Conectar a nova carteira NWC</string>
<string name="couldnt_find_nwc_wallets">Carteira não encontrada</string>
<string name="couldnt_find_nwc_wallets_description">O Amethyst não conseguiu encontrar nenhum aplicativo de carteira que suporte Nostr Wallet Connect (NWC).
\n\nO que você pode fazer:
\n\t1. Verifique o suporte NWC: Confirme se o aplicativo da sua carteira é compatível com o Nostr Wallet Connect.
\n\t2. Criar conexão: Vá até o aplicativo da sua carteira e crie uma nova conexão NWC.
\n\t3. Vincular ao Amethyst: Sua carteira geralmente oferece três opções:
\n\t\t3.1 Link Automático: Um botão para abrir e conectar automaticamente ao Amethyst. Clique nele, abra o Amethyst e salve na nova tela.
\n\t\t3.2 Link Manual: Um URI de conexão que você pode copiar e colar usando o ícone de colar nesta tela.
\n\t\t3.3 Código QR: A carteira gera um código QR que você pode ler usando o botão de código QR nesta tela.</string>
<string name="language_description">Para a interface do aplicativo</string>
<string name="theme_description">Tema Escuro, Claro ou Padrão</string>
<string name="automatically_load_images_gifs_description">Carregar automaticamente imagens e GIFs</string>
@@ -934,6 +946,16 @@
<string name="search_relays_not_found_description">Criar uma lista de relés especificamente projetada para pesquisa e marcação de usuários melhorará esses resultados.</string>
<string name="search_relays_not_found_editing">Insira entre 13 relés para usar ao pesquisar conteúdo ou marcar usuários. Certifique-se de que seus relés escolhidos implementem o NIP-50</string>
<string name="search_relays_not_found_examples">Boas opções são:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="outbox_relays_title">Relays de Saída</string>
<string name="outbox_relays_not_found">Configure seus relays públicos de saída para postar</string>
<string name="outbox_relays_not_found_description">Criar uma lista de relays especificamente projetada para receber seu conteúdo é essencial para sua experiência no Nostr e a única forma de seus seguidores encontrarem você.</string>
<string name="outbox_relays_not_found_editing">Insira entre 13 relays que recebam suas postagens. Certifique-se de que eles não exijam pagamento se você não estiver pagando para inserir.</string>
<string name="outbox_relays_not_found_examples">Boas opções são:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Relays de Entrada</string>
<string name="inbox_relays_not_found">Configure seus relays públicos de entrada para receber notificações</string>
<string name="inbox_relays_not_found_description">Criar uma lista de relays especificamente projetada para receber notificações é essencial para sua experiência no Nostr.</string>
<string name="inbox_relays_not_found_editing">Insira entre 13 relays que recebam notificações para você. Certifique-se de que eles não exijam pagamento ou rede de confiança do remetente, caso contrário você receberá notificações apenas de pagadores ou usuários de longo prazo.</string>
<string name="inbox_relays_not_found_examples">Boas opções são:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="dm_upload">Envio de DM</string>
<string name="relay_settings">Configurações de Relay</string>
<string name="public_home_section">Relés Públicos de Casa</string>
@@ -1082,32 +1104,4 @@
<string name="crashreport_found_send">Enviar</string>
<string name="this_message_will_disappear_in_days">Esta mensagem desaparecerá em %1$d dias</string>
<string name="select_signer">Selecionar assinador</string>
<string name="outbox_relays_title">Relays de Saída</string>
<string name="outbox_relays_not_found">Configure seus relays públicos de saída para postar</string>
<string name="outbox_relays_not_found_description">Criar uma lista de relays especificamente projetada para receber seu conteúdo é essencial para sua experiência no Nostr e a única forma de seus seguidores encontrarem você.</string>
<string name="outbox_relays_not_found_editing">Insira entre 13 relays que recebam suas postagens. Certifique-se de que eles não exijam pagamento se você não estiver pagando para inserir.</string>
<string name="outbox_relays_not_found_examples">Boas opções são:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Relays de Entrada</string>
<string name="inbox_relays_not_found">Configure seus relays públicos de entrada para receber notificações</string>
<string name="inbox_relays_not_found_description">Criar uma lista de relays especificamente projetada para receber notificações é essencial para sua experiência no Nostr.</string>
<string name="inbox_relays_not_found_editing">Insira entre 13 relays que recebam notificações para você. Certifique-se de que eles não exijam pagamento ou rede de confiança do remetente, caso contrário você receberá notificações apenas de pagadores ou usuários de longo prazo.</string>
<string name="inbox_relays_not_found_examples">Boas opções são:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="follow_set_public_members_header_label2">Membros Públicos</string>
<string name="follow_set_private_members_header_label2">Membros Privados</string>
<string name="connect_to_new_nwc_wallet">Conectar a nova carteira NWC</string>
<string name="couldnt_find_nwc_wallets">Carteira não encontrada</string>
<string name="couldnt_find_nwc_wallets_description">O Amethyst não conseguiu encontrar nenhum aplicativo de carteira que suporte Nostr Wallet Connect (NWC).
\n\nO que você pode fazer:
\n\t1. Verifique o suporte NWC: Confirme se o aplicativo da sua carteira é compatível com o Nostr Wallet Connect.
\n\t2. Criar conexão: Vá até o aplicativo da sua carteira e crie uma nova conexão NWC.
\n\t3. Vincular ao Amethyst: Sua carteira geralmente oferece três opções:
\n\t\t3.1 Link Automático: Um botão para abrir e conectar automaticamente ao Amethyst. Clique nele, abra o Amethyst e salve na nova tela.
\n\t\t3.2 Link Manual: Um URI de conexão que você pode copiar e colar usando o ícone de colar nesta tela.
\n\t\t3.3 Código QR: A carteira gera um código QR que você pode ler usando o botão de código QR nesta tela.
</string>
</resources>
+22 -28
View File
@@ -459,6 +459,8 @@
<string name="follow_set_private_presence_indicator">%1$s är en privat medlem</string>
<string name="follow_set_public_member_add_label">Lägg till som offentlig medlem</string>
<string name="follow_set_private_member_add_label">Lägg till som privat medlem</string>
<string name="follow_set_public_members_header_label2">Offentliga medlemmar</string>
<string name="follow_set_private_members_header_label2">Privata medlemmar</string>
<string name="follow_set_public_members_header_label">Offentliga profiler</string>
<string name="follow_set_private_members_header_label">Privata profiler</string>
<string name="follow_set_single_member_label">medlem</string>
@@ -702,6 +704,16 @@
<string name="paste_from_clipboard">Klistra in från urklipp</string>
<string name="invalid_nip47_uri_title">Ogiltig NIP-47 URI</string>
<string name="invalid_nip47_uri_description">URI %1$s är inte en giltig NIP-47 inloggnings-URI.</string>
<string name="connect_to_new_nwc_wallet">Anslut till ny NWC-plånbok</string>
<string name="couldnt_find_nwc_wallets">Plånbok hittades inte</string>
<string name="couldnt_find_nwc_wallets_description">Amethyst kunde inte hitta några plånboksappar som stöder Nostr Wallet Connect (NWC).
\n\nVad du kan göra:
\n\t1. Verifiera NWC-stöd: Kontrollera att din föredragna plånbok stöder Nostr Wallet Connect.
\n\t2. Skapa anslutning: Gå till din plånbok och skapa en ny NWC-anslutning.
\n\t3. Länka till Amethyst: Din plånbok erbjuder vanligtvis tre alternativ:
\n\t\t3.1 Automatisk länk: En knapp för att automatiskt öppna och ansluta till Amethyst. Klicka på den, öppna Amethyst och spara på den nya skärmen.
\n\t\t3.2 Manuell länk: En anslutnings-URI som du kan kopiera och klistra in via klistra-ikonen på den här skärmen.
\n\t\t3.3 QR-kod: Plånboken skapar en QR-kod som du kan skanna med QR-knappens funktion på den här skärmen.</string>
<string name="language_description">För appens gränssnitt</string>
<string name="theme_description">Mörkt, Ljust eller Systemtema</string>
<string name="automatically_load_images_gifs_description">Ladda automatiskt bilder och GIFs</string>
@@ -933,6 +945,16 @@
<string name="search_relays_not_found_description">Att skapa en relälista speciellt utformad för sökning och användartaggning kommer att förbättra dessa resultat.</string>
<string name="search_relays_not_found_editing">Sätt in mellan 13 reläer att använda när du söker efter innehåll eller taggar användare. Se till att dina valda reläer implementerar NIP-50</string>
<string name="search_relays_not_found_examples">Bra alternativ är:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="outbox_relays_title">Utgående reläer</string>
<string name="outbox_relays_not_found">Ställ in dina offentliga utgående reläer för att kunna posta</string>
<string name="outbox_relays_not_found_description">Att skapa en relälista specifikt för att ta emot ditt innehåll är avgörande för din Nostr-upplevelse och det enda sättet dina följare kan hitta dig.</string>
<string name="outbox_relays_not_found_editing">Lägg till mellan 13 reläer som tar emot dina inlägg. Se till att de inte kräver betalning om du inte betalar för att lägga till dem.</string>
<string name="outbox_relays_not_found_examples">Bra alternativ är:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Inkommande reläer</string>
<string name="inbox_relays_not_found">Ställ in dina offentliga inkommande reläer för att ta emot aviseringar</string>
<string name="inbox_relays_not_found_description">Att skapa en relälista specifikt för att ta emot aviseringar är avgörande för din Nostr-upplevelse.</string>
<string name="inbox_relays_not_found_editing">Lägg till mellan 13 reläer som tar emot aviseringar åt dig. Se till att de inte kräver betalning eller web of trust från avsändaren, annars får du bara aviseringar från betalande eller långsiktiga användare.</string>
<string name="inbox_relays_not_found_examples">Bra alternativ är:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="dm_upload">DM uppladdning</string>
<string name="relay_settings">Relä inställningar</string>
<string name="public_home_section">Offentliga hemreläer</string>
@@ -1081,32 +1103,4 @@
<string name="crashreport_found_send">Skicka</string>
<string name="this_message_will_disappear_in_days">Detta meddelande försvinner om %1$d dagar</string>
<string name="select_signer">Välj signatör</string>
<string name="outbox_relays_title">Utgående reläer</string>
<string name="outbox_relays_not_found">Ställ in dina offentliga utgående reläer för att kunna posta</string>
<string name="outbox_relays_not_found_description">Att skapa en relälista specifikt för att ta emot ditt innehåll är avgörande för din Nostr-upplevelse och det enda sättet dina följare kan hitta dig.</string>
<string name="outbox_relays_not_found_editing">Lägg till mellan 13 reläer som tar emot dina inlägg. Se till att de inte kräver betalning om du inte betalar för att lägga till dem.</string>
<string name="outbox_relays_not_found_examples">Bra alternativ är:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Inkommande reläer</string>
<string name="inbox_relays_not_found">Ställ in dina offentliga inkommande reläer för att ta emot aviseringar</string>
<string name="inbox_relays_not_found_description">Att skapa en relälista specifikt för att ta emot aviseringar är avgörande för din Nostr-upplevelse.</string>
<string name="inbox_relays_not_found_editing">Lägg till mellan 13 reläer som tar emot aviseringar åt dig. Se till att de inte kräver betalning eller web of trust från avsändaren, annars får du bara aviseringar från betalande eller långsiktiga användare.</string>
<string name="inbox_relays_not_found_examples">Bra alternativ är:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="follow_set_public_members_header_label2">Offentliga medlemmar</string>
<string name="follow_set_private_members_header_label2">Privata medlemmar</string>
<string name="connect_to_new_nwc_wallet">Anslut till ny NWC-plånbok</string>
<string name="couldnt_find_nwc_wallets">Plånbok hittades inte</string>
<string name="couldnt_find_nwc_wallets_description">Amethyst kunde inte hitta några plånboksappar som stöder Nostr Wallet Connect (NWC).
\n\nVad du kan göra:
\n\t1. Verifiera NWC-stöd: Kontrollera att din föredragna plånbok stöder Nostr Wallet Connect.
\n\t2. Skapa anslutning: Gå till din plånbok och skapa en ny NWC-anslutning.
\n\t3. Länka till Amethyst: Din plånbok erbjuder vanligtvis tre alternativ:
\n\t\t3.1 Automatisk länk: En knapp för att automatiskt öppna och ansluta till Amethyst. Klicka på den, öppna Amethyst och spara på den nya skärmen.
\n\t\t3.2 Manuell länk: En anslutnings-URI som du kan kopiera och klistra in via klistra-ikonen på den här skärmen.
\n\t\t3.3 QR-kod: Plånboken skapar en QR-kod som du kan skanna med QR-knappens funktion på den här skärmen.
</string>
</resources>
@@ -455,9 +455,21 @@
<string name="follow_set_empty_feed_msg"> 似乎你还没有任何关注集。
\n轻按下方刷新,或者轻按“+”按钮新建一个。
</string>
<string name="follow_set_create_btn_label">新建</string>
<string name="follow_set_add_author_from_note_action">添加作者到关注集</string>
<string name="follow_set_profile_actions_menu_description">从列表中添加或删除用户,或用此用户创建一个新列表。</string>
<string name="follow_set_icon_description">%1$s 列表的图标</string>
<string name="follow_set_icon_description">关注集的图标</string>
<string name="follow_set_public_presence_indicator">%1$s 是公开成员</string>
<string name="follow_set_private_presence_indicator">%1$s 是私密成员</string>
<string name="follow_set_public_member_add_label">添加为公开成员</string>
<string name="follow_set_private_member_add_label">添加为私密成员</string>
<string name="follow_set_public_members_header_label2">公开成员</string>
<string name="follow_set_private_members_header_label2">私密成员</string>
<string name="follow_set_public_members_header_label">公开的个人资料</string>
<string name="follow_set_private_members_header_label">私密配置文件</string>
<string name="follow_set_single_member_label">成员</string>
<string name="follow_set_multiple_member_label">成员</string>
<string name="follow_set_empty_label">无成员</string>
<string name="follow_set_absence_indicator">此列表中没有 %1$s</string>
<string name="follow_set_man_dialog_title">您的关注集</string>
<string name="follow_set_empty_dialog_msg">未找到关注集,或者你还没有任何关注集。轻按下方刷新,或使用按钮新建。</string>
@@ -466,10 +478,19 @@
<string name="follow_set_creation_item_label">创建用户新的 %1$s 列表</string>
<string name="follow_set_creation_item_description">创建 %1$s 关注集,并添加 %2$s 到其中。</string>
<string name="follow_set_creation_dialog_title">新的 %1$s 列表</string>
<string name="follow_set_copy_dialog_title">复制/克隆关注集</string>
<string name="follow_set_desc_modify_label">修改描述</string>
<string name="follow_set_empty_desc_label">此列表没有描述</string>
<string name="follow_set_current_desc_label">当前描述:</string>
<string name="follow_set_copy_indicator_description">你可以为下方的克隆集合设置自定义名称、描述。</string>
<string name="follow_set_creation_name_label">集合名</string>
<string name="follow_set_copy_name_label">(原始集合名称)</string>
<string name="follow_set_creation_desc_label">集合描述(可选)</string>
<string name="follow_set_copy_desc_label">(原始集合描述)</string>
<string name="follow_set_creation_action_btn_label">新建集</string>
<string name="follow_set_copy_action_btn_label">复制/克隆集</string>
<string name="follow_set_rename_btn_label">重命名集</string>
<string name="follow_set_desc_modify_btn_label">修改</string>
<string name="follow_set_rename_dialog_indicator_first_part">正将集合名称从 </string>
<string name="follow_set_rename_dialog_indicator_second_part"> 改为</string>
<string name="connect_through_your_orbot_setup_short">默认端口为 9050</string>
@@ -688,6 +709,17 @@
<string name="paste_from_clipboard">粘贴自剪贴板</string>
<string name="invalid_nip47_uri_title">无效的 NIP-47 URI</string>
<string name="invalid_nip47_uri_description">URI %1$s 不是有效的 NIP-47 登录 URI。</string>
<string name="connect_to_new_nwc_wallet">连接到新的 NWC 钱包</string>
<string name="couldnt_find_nwc_wallets">未找到钱包</string>
<string name="couldnt_find_nwc_wallets_description">Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用.
\n\n你可以做的事情:
\n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。
\n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。
\n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项:
\n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。
\n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。
\n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。
</string>
<string name="language_description">用于应用程序界面</string>
<string name="theme_description">暗色、亮色或系统主题</string>
<string name="automatically_load_images_gifs_description">自动加载图像和 GIF</string>
@@ -919,6 +951,16 @@
<string name="search_relays_not_found_description">通过创建专用于关键词和标签检索的中继列表能够改善搜索结果。</string>
<string name="search_relays_not_found_editing">设置 1 ~ 3 个支持 NIP-50 的中继,用于关键词和标签检索。</string>
<string name="search_relays_not_found_examples">示例:\n - nostr.wine\n - reiny.nostr.band\n - reliy.noswhere.com</string>
<string name="outbox_relays_title">发件箱中继</string>
<string name="outbox_relays_not_found">设置您的公共发件箱中继来发布内容</string>
<string name="outbox_relays_not_found_description">创建专为接收您的内容而设计的中继列表对于您的Nostr体验至关重要,也是您的关注者找到您的唯一途径。 </string>
<string name="outbox_relays_not_found_editing">插入 1-3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入</string>
<string name="outbox_relays_not_found_examples">好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">收件箱中继</string>
<string name="inbox_relays_not_found">设置您的公共收件箱中继来接收通知</string>
<string name="inbox_relays_not_found_description">创建专为接收通知而设计的中继列表对于您的 Nostr 体验至关重要。 </string>
<string name="inbox_relays_not_found_editing">插入 1-3 个为你接受通知的中继。确保它们不需要发送者的付款或 web of trust,否则你只能接收付款人或长期用户的通知</string>
<string name="inbox_relays_not_found_examples">好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="dm_upload">DM 上传</string>
<string name="relay_settings">中继设置</string>
<string name="public_home_section">公共时间线中继</string>
@@ -30,7 +30,7 @@ import org.junit.runner.RunWith
class GalleryParserTest {
@Test
fun testMixedImageAndVideoRenderedIndividually() {
// Test the bug: when mixed image + video, both should be rendered individually
// Test that when image + video are in consecutive paragraphs, both should be rendered individually (not as gallery)
val text =
"Renfield (2023)\n" +
"https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg\n" +
@@ -38,19 +38,29 @@ class GalleryParserTest {
val state = RichTextParser().parseText(text, EmptyTagList, null)
// Should have 2 image segments (image + video URLs)
Assert.assertEquals(2, state.paragraphs.size)
// Should have 3 paragraphs (text, image, video)
Assert.assertEquals(3, state.paragraphs.size)
// First paragraph is text
Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph)
Assert.assertEquals(1, state.paragraphs[0].words.size)
Assert.assertTrue(state.paragraphs[1] is ImageGalleryParagraph)
// Second paragraph is image (rendered individually, not as gallery)
Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph)
Assert.assertEquals(1, state.paragraphs[1].words.size)
Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment)
Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[1].words[0].segmentText)
Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[1].words[1].segmentText)
// Third paragraph is video (rendered individually)
Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph)
Assert.assertEquals(1, state.paragraphs[2].words.size)
Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment)
Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[2].words[0].segmentText)
}
@Test
fun testMixedImageAndVideoRenderedIndividuallyDoubled() {
// Test the bug: when mixed image + video, both should be rendered individually
// Test that when image + video are repeated, both should be rendered individually (not as gallery)
val text =
"Renfield (2023)\n" +
"https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg\n" +
@@ -61,19 +71,28 @@ class GalleryParserTest {
val state = RichTextParser().parseText(text, EmptyTagList, null)
// Should have 2 image segments (image + video URLs)
Assert.assertEquals(4, state.paragraphs.size)
// Should have 6 paragraphs (text, image, video, text, image, video)
Assert.assertEquals(6, state.paragraphs.size)
// First set: text, image, video
Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph)
Assert.assertEquals(1, state.paragraphs[0].words.size)
Assert.assertTrue(state.paragraphs[1] is ImageGalleryParagraph)
Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph)
Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment)
Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[1].words[0].segmentText)
Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[1].words[1].segmentText)
Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph)
Assert.assertEquals(1, state.paragraphs[2].words.size)
Assert.assertTrue(state.paragraphs[3] is ImageGalleryParagraph)
Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[3].words[0].segmentText)
Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[3].words[1].segmentText)
Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment)
Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[2].words[0].segmentText)
// Second set: text, image, video
Assert.assertTrue(state.paragraphs[3] !is ImageGalleryParagraph)
Assert.assertEquals(1, state.paragraphs[3].words.size)
Assert.assertTrue(state.paragraphs[4] !is ImageGalleryParagraph)
Assert.assertTrue(state.paragraphs[4].words[0] is ImageSegment)
Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[4].words[0].segmentText)
Assert.assertTrue(state.paragraphs[5] !is ImageGalleryParagraph)
Assert.assertTrue(state.paragraphs[5].words[0] is VideoSegment)
Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[5].words[0].segmentText)
}
@Test
@@ -140,4 +159,67 @@ class GalleryParserTest {
Assert.assertEquals("https://relay.dergigi.com/d6a3e33b101fe219ef251ac6261c10392c2af9918c3c252d4c202016b0b4ec83.jpg", state.paragraphs[1].words[0].segmentText)
Assert.assertEquals("https://relay.dergigi.com/d60c9c562912573f214c2b1958cc20bf8913cd718d2ed9e020621e3e3120634b.jpg", state.paragraphs[1].words[1].segmentText)
}
@Test
fun testMixedImageAndVideoInSameParagraph() {
// Test the user's specific example: mixed image and video in same paragraph should render individually
val text =
"The Crow (1994)\n" +
"https://image.tmdb.org/t/p/original/1r7iOhXSbbuUTORNEUOvCUkQ86K.jpg\n" +
"https://archive.org/download/the-crow-1994_20231024/The%20Crow%201994.rar/The%20Crow%201994.1080p.BluRay.x264%20.%20NVEE%2FThe%20Crow%201994.1080p.BluRay.x264%20.%20NVEE.mp4\n" +
"#fantasy #action #thriller #film #movies #kinostr"
val state = RichTextParser().parseText(text, EmptyTagList, null)
// Should have 4 paragraphs (text, image, video, hashtags)
Assert.assertEquals(4, state.paragraphs.size)
// First paragraph is text
Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph)
// Second paragraph is image (rendered individually, not as gallery)
Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph)
Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment)
Assert.assertEquals("https://image.tmdb.org/t/p/original/1r7iOhXSbbuUTORNEUOvCUkQ86K.jpg", state.paragraphs[1].words[0].segmentText)
// Third paragraph is video (rendered individually)
Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph)
Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment)
Assert.assertEquals("https://archive.org/download/the-crow-1994_20231024/The%20Crow%201994.rar/The%20Crow%201994.1080p.BluRay.x264%20.%20NVEE%2FThe%20Crow%201994.1080p.BluRay.x264%20.%20NVEE.mp4", state.paragraphs[2].words[0].segmentText)
// Fourth paragraph is hashtags
Assert.assertTrue(state.paragraphs[3] !is ImageGalleryParagraph)
}
@Test
fun testDesperadoExample() {
// Test the exact example provided by the user
val text =
"Desperado (1995)\n" +
"https://media.themoviedb.org/t/p/w440_and_h660_face/e3gwpBeXpvGZsxUya9zNym5QXrw.jpg\n" +
"https://archive.org/download/desperado.-1995.1080p.-blu-ray.x-264.-yify/Desperado.1995.1080p.BluRay.x264.YIFY.mp4\n" +
"#action #crime #film #movies #kinostr"
val state = RichTextParser().parseText(text, EmptyTagList, null)
// Should have 4 paragraphs (text, image, video, hashtags)
Assert.assertEquals(4, state.paragraphs.size)
// First paragraph is text
Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph)
Assert.assertEquals("Desperado (1995)", state.paragraphs[0].words[0].segmentText)
// Second paragraph is image (rendered individually, not as gallery)
Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph)
Assert.assertEquals(1, state.paragraphs[1].words.size)
Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment)
Assert.assertEquals("https://media.themoviedb.org/t/p/w440_and_h660_face/e3gwpBeXpvGZsxUya9zNym5QXrw.jpg", state.paragraphs[1].words[0].segmentText)
// Third paragraph is video (rendered individually)
Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph)
Assert.assertEquals(1, state.paragraphs[2].words.size)
Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment)
Assert.assertEquals("https://archive.org/download/desperado.-1995.1080p.-blu-ray.x-264.-yify/Desperado.1995.1080p.BluRay.x264.YIFY.mp4", state.paragraphs[2].words[0].segmentText)
// Fourth paragraph is hashtags
Assert.assertTrue(state.paragraphs[3] !is ImageGalleryParagraph)
}
}
@@ -37,6 +37,7 @@ class GalleryParser {
paragraph.words.forEach { word ->
when (word) {
is ImageSegment, is Base64Segment -> imageCount++
is VideoSegment -> hasNonWhitespaceNonImageContent = true // Videos are not images
is RegularTextSegment -> {
if (word.segmentText.isNotBlank()) {
hasNonWhitespaceNonImageContent = true
@@ -146,13 +147,18 @@ class GalleryParser {
val word = paragraph.words[i]
if (word is ImageSegment || word is Base64Segment) {
// Collect consecutive image/whitespace segments without extra list allocations
// Collect consecutive image/whitespace segments (but not videos)
val imageSegments = mutableListOf<Segment>()
var j = i
var hasVideo = false
while (j < n) {
val seg = paragraph.words[j]
when {
seg is VideoSegment -> {
hasVideo = true
break
}
seg is ImageSegment || seg is Base64Segment -> imageSegments.add(seg)
seg is RegularTextSegment && seg.segmentText.isBlank() -> { /* skip whitespace */ }
else -> break
@@ -160,8 +166,9 @@ class GalleryParser {
j++
}
if (imageSegments.size <= 1) {
currentParagraphSegments.add(word)
// If we found a video, don't create a gallery - render images individually
if (hasVideo || imageSegments.size <= 1) {
currentParagraphSegments.addAll(imageSegments)
} else {
if (currentParagraphSegments.isNotEmpty()) {
resultingParagraphs.add(ParagraphState(currentParagraphSegments.toImmutableList(), paragraph.isRTL))
@@ -139,9 +139,12 @@ class RichTextParser {
val imagesForPager =
urlSet.mapNotNull { fullUrl -> createMediaContent(fullUrl, imetas, content, callbackUri) }.associateBy { it.url }
val imageUrls = imagesForPager.filterValues { it is MediaUrlImage }.keys
val videoUrls = imagesForPager.filterValues { it is MediaUrlVideo }.keys
val emojiMap = CustomEmoji.createEmojiMap(tags)
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
val segments = findTextSegments(content, imageUrls, videoUrls, urlSet, emojiMap, tags)
val base64Images = segments.map { it.words.filterIsInstance<Base64Segment>() }.flatten()
@@ -164,6 +167,7 @@ class RichTextParser {
private fun findTextSegments(
content: String,
images: Set<String>,
videos: Set<String>,
urls: Set<String>,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
@@ -177,7 +181,7 @@ class RichTextParser {
val wordList = paragraph.trimEnd().split(' ')
val segments = ArrayList<Segment>(wordList.size)
wordList.forEach { word ->
segments.add(wordIdentifier(word, images, urls, emojis, tags))
segments.add(wordIdentifier(word, images, videos, urls, emojis, tags))
}
paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL))
@@ -229,6 +233,7 @@ class RichTextParser {
private fun wordIdentifier(
word: String,
images: Set<String>,
videos: Set<String>,
urls: Set<String>,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
@@ -239,6 +244,8 @@ class RichTextParser {
if (images.contains(word)) return ImageSegment(word)
if (videos.contains(word)) return VideoSegment(word)
if (urls.contains(word)) return LinkSegment(word)
if (CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word)
@@ -58,6 +58,11 @@ class ImageSegment(
segment: String,
) : Segment(segment)
@Immutable
class VideoSegment(
segment: String,
) : Segment(segment)
@Immutable
class LinkSegment(
segment: String,
@@ -0,0 +1,53 @@
/**
* 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
* 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.quartz.utils.sha256
import java.io.FilterInputStream
import java.io.InputStream
class CountingInputStream(
inputStream: InputStream,
) : FilterInputStream(inputStream) {
var bytesRead: Long = 0
private set
override fun read(): Int {
val byte = super.read()
if (byte != -1) bytesRead++
return byte
}
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int {
val count = super.read(b, off, len)
if (count > 0) bytesRead += count
return count
}
override fun skip(n: Long): Long {
val skipped = super.skip(n)
bytesRead += skipped
return skipped
}
}
@@ -20,6 +20,26 @@
*/
package com.vitorpamplona.quartz.utils.sha256
import java.io.InputStream
val pool = Sha256Pool(5) // max parallel operations
actual fun sha256(data: ByteArray) = pool.hash(data)
/**
* Calculate SHA256 hash while counting bytes read from the stream.
* Returns both the hash and the number of bytes processed.
* This is more efficient than reading the stream twice.
*
* @param inputStream The input stream to hash
* @param bufferSize Size of chunks to read (default 8KB)
* @return Pair of (hash bytes, bytes read count)
*/
fun sha256StreamWithCount(
inputStream: InputStream,
bufferSize: Int = 8192,
): Pair<ByteArray, Long> {
val countingStream = CountingInputStream(inputStream)
val hash = pool.hashStream(countingStream, bufferSize)
return Pair(hash, countingStream.bytesRead)
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.utils.sha256
import java.io.InputStream
import java.security.MessageDigest
class Sha256Hasher {
@@ -30,4 +31,31 @@ class Sha256Hasher {
fun digest(byteArray: ByteArray) = digest.digest(byteArray)
fun reset() = digest.reset()
/**
* Calculate SHA256 hash by streaming the input in chunks.
* This avoids loading the entire input into memory at once.
*
* @param inputStream The input stream to hash
* @param bufferSize Size of chunks to read (default 8KB)
* @return SHA256 hash bytes
*/
fun hashStream(
inputStream: InputStream,
bufferSize: Int = 8192,
): ByteArray {
val buffer = ByteArray(bufferSize)
try {
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead)
}
return digest.digest()
} finally {
// Always reset digest to prevent contaminating the pool on exceptions
digest.reset()
}
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.utils.sha256
import com.vitorpamplona.quartz.utils.Log
import java.io.InputStream
import java.util.concurrent.ArrayBlockingQueue
class Sha256Pool(
@@ -54,4 +55,24 @@ class Sha256Pool(
release(hasher)
}
}
/**
* Calculate SHA256 hash by streaming the input in chunks.
* This avoids loading the entire input into memory at once.
*
* @param inputStream The input stream to hash
* @param bufferSize Size of chunks to read (default 8KB)
* @return SHA256 hash bytes
*/
fun hashStream(
inputStream: InputStream,
bufferSize: Int = 8192,
): ByteArray {
val hasher = acquire()
try {
return hasher.hashStream(inputStream, bufferSize)
} finally {
release(hasher)
}
}
}