mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
Merge branch 'main' into fix/desktop-log-noise
This commit is contained in:
@@ -34,7 +34,7 @@ jobs:
|
||||
# :amethyst alone). Replaces the narrower `cache: gradle` previously on
|
||||
# actions/setup-java, which only cached `modules-2`.
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
# we fall back to fetching, which is what the in-build retry budget
|
||||
# exists for.
|
||||
- name: Cache vlc-setup downloads
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.gradle/vlcSetup
|
||||
key: vlcsetup-${{ runner.os }}-${{ hashFiles('desktopApp/build.gradle.kts') }}
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
@@ -64,12 +64,12 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Cache vlc-setup downloads
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.gradle/vlcSetup
|
||||
key: vlcsetup-Linux-${{ hashFiles('desktopApp/build.gradle.kts') }}
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.service.retryIfException
|
||||
import com.vitorpamplona.amethyst.commons.util.retryIfException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
|
||||
@@ -1888,8 +1888,8 @@ class Account(
|
||||
suspend fun <T : Event> signAnonymouslyAndBroadcast(
|
||||
template: EventTemplate<T>,
|
||||
broadcast: List<Event> = emptyList(),
|
||||
anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()),
|
||||
): T {
|
||||
val anonymousSigner = NostrSignerInternal(KeyPair())
|
||||
val event = anonymousSigner.sign(template)
|
||||
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
|
||||
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlPreview
|
||||
import com.vitorpamplona.amethyst.commons.preview.UrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
|
||||
+11
-9
@@ -879,7 +879,7 @@ class CashuWalletState(
|
||||
* is loaded.
|
||||
*/
|
||||
suspend fun restoreFromMint(mintUrl: String): RestoreOutcome? {
|
||||
check(started) { "CashuWalletState.start() not called" }
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
val seed = ensureSeed() ?: return null
|
||||
// Heal any prior-Resync duplicates BEFORE running the new
|
||||
// restore. Without this the existingSecrets set below would
|
||||
@@ -941,7 +941,7 @@ class CashuWalletState(
|
||||
* excludes the ghosts.
|
||||
*/
|
||||
suspend fun cleanupDuplicateProofs() {
|
||||
check(started) { "CashuWalletState.start() not called" }
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
val entries = _tokenEntries.value
|
||||
if (entries.size < 2) return
|
||||
|
||||
@@ -1019,7 +1019,7 @@ class CashuWalletState(
|
||||
* caller would still pick the ghosts.
|
||||
*/
|
||||
suspend fun scrubLocallyStaleProofs(mintUrlFilter: String? = null) {
|
||||
check(started) { "CashuWalletState.start() not called" }
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
val byMint =
|
||||
_tokenEntries.value
|
||||
.groupBy { it.content.mint }
|
||||
@@ -1075,7 +1075,7 @@ class CashuWalletState(
|
||||
* migration (e.g. a future "compact wallet" action).
|
||||
*/
|
||||
suspend fun migrateStaleKeysets() {
|
||||
check(started) { "CashuWalletState.start() not called" }
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
// Group held tokens by mint URL — each mint has its own keysets.
|
||||
val byMint = _tokenEntries.value.groupBy { it.content.mint }
|
||||
for ((mintUrl, entries) in byMint) {
|
||||
@@ -1107,7 +1107,7 @@ class CashuWalletState(
|
||||
preferredMintUrl: String? = null,
|
||||
onProgress: ((Float) -> Unit)? = null,
|
||||
): NutzapSent {
|
||||
check(started) { "CashuWalletState.start() not called" }
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
val resolved =
|
||||
peekNutzapTarget(recipientPubKey)
|
||||
?: throw IllegalStateException("Recipient does not accept nutzaps from any of our mints")
|
||||
@@ -1160,7 +1160,7 @@ class CashuWalletState(
|
||||
amountSats: Long,
|
||||
memo: String? = null,
|
||||
): SendTokenCompleted {
|
||||
check(started) { "CashuWalletState.start() not called" }
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
if (amountSats <= 0) throw IllegalArgumentException("Amount must be positive")
|
||||
if (mintUrl.isBlank()) throw IllegalArgumentException("Pick a mint")
|
||||
|
||||
@@ -1184,7 +1184,7 @@ class CashuWalletState(
|
||||
quote: MeltQuoteBolt11ResponseDto,
|
||||
skipScrub: Boolean = false,
|
||||
): MeltCompleted {
|
||||
check(started) { "CashuWalletState.start() not called" }
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
if (mintUrl.isBlank()) throw IllegalArgumentException("Pick a mint")
|
||||
|
||||
// [rebalance] already scrubbed this mint to compute its coverage check, so
|
||||
@@ -1220,7 +1220,7 @@ class CashuWalletState(
|
||||
onProgress: ((Float) -> Unit)? = null,
|
||||
onFundsMoved: () -> Unit = {},
|
||||
): RebalanceCompleted {
|
||||
check(started) { "CashuWalletState.start() not called" }
|
||||
check(started) { NOT_STARTED_MESSAGE }
|
||||
require(sats > 0) { "Amount must be positive" }
|
||||
require(sourceMintUrl != targetMintUrl) { "Source and target mints must differ" }
|
||||
|
||||
@@ -1297,7 +1297,7 @@ class CashuWalletState(
|
||||
* call [publishEvent] is gated behind `started` so the no-op default is
|
||||
* never observed by produced events.
|
||||
*/
|
||||
private var publish: suspend (Event) -> Unit = { error("CashuWalletState.start() not called") }
|
||||
private var publish: suspend (Event) -> Unit = { error(NOT_STARTED_MESSAGE) }
|
||||
|
||||
private suspend fun publishEvent(event: Event) {
|
||||
publish(event)
|
||||
@@ -1311,6 +1311,8 @@ class CashuWalletState(
|
||||
* wallet-less users don't stare at a spinner.
|
||||
*/
|
||||
const val DISCOVERY_TIMEOUT_MS = 8_000L
|
||||
|
||||
private const val NOT_STARTED_MESSAGE = "CashuWalletState.start() not called"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* 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.previews
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.preview.HtmlCharsetParser
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTag
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.BufferedSource
|
||||
import okio.ByteString.Companion.decodeHex
|
||||
import okio.Options
|
||||
import java.nio.charset.Charset
|
||||
|
||||
class HtmlParser {
|
||||
companion object {
|
||||
// taken from okhttp
|
||||
private val UNICODE_BOMS =
|
||||
Options.of(
|
||||
// UTF-8
|
||||
"efbbbf".decodeHex(),
|
||||
// UTF-16BE
|
||||
"feff".decodeHex(),
|
||||
// UTF-16LE
|
||||
"fffe".decodeHex(),
|
||||
// UTF-32BE
|
||||
"0000ffff".decodeHex(),
|
||||
// UTF-32LE
|
||||
"ffff0000".decodeHex(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun parseHtml(
|
||||
source: BufferedSource,
|
||||
type: Charset?,
|
||||
): Sequence<MetaTag> =
|
||||
parseHtml(
|
||||
source.readByteArray(),
|
||||
type ?: source.readBomAsCharset(),
|
||||
)
|
||||
|
||||
suspend fun parseHtml(
|
||||
bodyBytes: ByteArray,
|
||||
type: Charset?,
|
||||
): Sequence<MetaTag> =
|
||||
withContext(Dispatchers.IO) {
|
||||
// sniff charset from Content-Type header or BOM
|
||||
if (type != null) {
|
||||
val content = bodyBytes.toString(type)
|
||||
return@withContext MetaTagsParser.parse(content)
|
||||
}
|
||||
|
||||
// if sniffing was failed, detect charset from content
|
||||
val charset = HtmlCharsetParser.detectCharset(bodyBytes)
|
||||
val content = bodyBytes.toString(charset)
|
||||
return@withContext MetaTagsParser.parse(content)
|
||||
}
|
||||
|
||||
private fun BufferedSource.readBomAsCharset(): Charset? =
|
||||
when (select(UNICODE_BOMS)) {
|
||||
0 -> Charsets.UTF_8
|
||||
1 -> Charsets.UTF_16BE
|
||||
2 -> Charsets.UTF_16LE
|
||||
3 -> Charsets.UTF_32BE
|
||||
4 -> Charsets.UTF_32LE
|
||||
-1 -> null
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
@@ -66,6 +67,7 @@ class MultiOrchestrator(
|
||||
stripMetadata: Boolean = true,
|
||||
onStrippingFailed: suspend () -> Boolean = { true },
|
||||
convertGifToMp4: Boolean = false,
|
||||
forcedSigner: NostrSigner? = null,
|
||||
): Result {
|
||||
coroutineScope {
|
||||
val jobs =
|
||||
@@ -84,6 +86,7 @@ class MultiOrchestrator(
|
||||
stripMetadata,
|
||||
onStrippingFailed,
|
||||
convertGifToMp4 = convertGifToMp4,
|
||||
forcedSigner = forcedSigner,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -106,6 +109,7 @@ class MultiOrchestrator(
|
||||
stripMetadata: Boolean = true,
|
||||
onStrippingFailed: suspend () -> Boolean = { true },
|
||||
convertGifToMp4: Boolean = false,
|
||||
forcedSigner: NostrSigner? = null,
|
||||
): Result {
|
||||
coroutineScope {
|
||||
val jobs =
|
||||
@@ -125,6 +129,7 @@ class MultiOrchestrator(
|
||||
stripMetadata,
|
||||
onStrippingFailed,
|
||||
convertGifToMp4 = convertGifToMp4,
|
||||
forcedSigner = forcedSigner,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+23
-6
@@ -30,7 +30,10 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -142,6 +145,7 @@ class UploadOrchestrator {
|
||||
contentTypeForResult: String?,
|
||||
originalHash: String?,
|
||||
account: Account,
|
||||
forcedSigner: NostrSigner?,
|
||||
context: Context,
|
||||
): UploadingFinalState {
|
||||
updateState(0.2, UploadingState.Uploading)
|
||||
@@ -158,7 +162,12 @@ class UploadOrchestrator {
|
||||
onProgress = { percent: Float ->
|
||||
updateState(0.2 + (0.2 * percent), UploadingState.Uploading)
|
||||
},
|
||||
httpAuth = account::createHTTPAuthorization,
|
||||
httpAuth =
|
||||
if (forcedSigner != null) {
|
||||
{ url, method, body -> forcedSigner.sign(HTTPAuthorizationEvent.build(url, method, body)) }
|
||||
} else {
|
||||
account::createHTTPAuthorization
|
||||
},
|
||||
context = context,
|
||||
)
|
||||
|
||||
@@ -187,6 +196,7 @@ class UploadOrchestrator {
|
||||
contentTypeForResult: String?,
|
||||
originalHash: String?,
|
||||
account: Account,
|
||||
forcedSigner: NostrSigner?,
|
||||
context: Context,
|
||||
): UploadingFinalState {
|
||||
updateState(0.2, UploadingState.Uploading)
|
||||
@@ -201,7 +211,12 @@ class UploadOrchestrator {
|
||||
sensitiveContent = contentWarningReason,
|
||||
serverBaseUrl = serverBaseUrl,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
|
||||
httpAuth = account::createBlossomUploadAuth,
|
||||
httpAuth =
|
||||
if (forcedSigner != null) {
|
||||
{ hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) }
|
||||
} else {
|
||||
account::createBlossomUploadAuth
|
||||
},
|
||||
context = context,
|
||||
)
|
||||
|
||||
@@ -360,6 +375,7 @@ class UploadOrchestrator {
|
||||
stripMetadata: Boolean = true,
|
||||
onStrippingFailed: suspend () -> Boolean = { true },
|
||||
convertGifToMp4: Boolean = false,
|
||||
forcedSigner: NostrSigner? = null,
|
||||
): UploadingFinalState {
|
||||
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
|
||||
|
||||
@@ -379,8 +395,8 @@ class UploadOrchestrator {
|
||||
try {
|
||||
return when (server.type) {
|
||||
ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context)
|
||||
ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context)
|
||||
ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context)
|
||||
ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context)
|
||||
ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context)
|
||||
}
|
||||
} finally {
|
||||
deleteTempUri(finalUri, uri)
|
||||
@@ -401,6 +417,7 @@ class UploadOrchestrator {
|
||||
stripMetadata: Boolean = true,
|
||||
onStrippingFailed: suspend () -> Boolean = { true },
|
||||
convertGifToMp4: Boolean = false,
|
||||
forcedSigner: NostrSigner? = null,
|
||||
): UploadingFinalState {
|
||||
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
|
||||
|
||||
@@ -423,8 +440,8 @@ class UploadOrchestrator {
|
||||
try {
|
||||
return when (server.type) {
|
||||
ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context)
|
||||
ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context)
|
||||
ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context)
|
||||
ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context)
|
||||
ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context)
|
||||
}
|
||||
} finally {
|
||||
deleteTempUri(encrypted.uri, uri)
|
||||
|
||||
@@ -56,9 +56,9 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
|
||||
import com.vitorpamplona.amethyst.service.broadcast.RelayResult
|
||||
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent
|
||||
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastStatus
|
||||
import com.vitorpamplona.amethyst.commons.service.broadcast.RelayResult
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
+3
-3
@@ -72,9 +72,9 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
|
||||
import com.vitorpamplona.amethyst.service.broadcast.RelayResult
|
||||
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent
|
||||
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastStatus
|
||||
import com.vitorpamplona.amethyst.commons.service.broadcast.RelayResult
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
+1
-1
@@ -37,8 +37,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent
|
||||
import com.vitorpamplona.amethyst.model.BooleanType
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
+14
-1
@@ -71,8 +71,11 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes
|
||||
@@ -215,6 +218,14 @@ open class CommentPostViewModel :
|
||||
|
||||
var wantsAnonymousPost by mutableStateOf(false)
|
||||
|
||||
// A single ephemeral signer reused for the whole compose session so that media
|
||||
// uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed
|
||||
// by the same throwaway key, instead of leaking the real account's pubkey into the
|
||||
// upload authorization (and therefore into the returned media URL).
|
||||
private var anonymousSignerCache: NostrSigner? = null
|
||||
|
||||
fun anonymousSigner(): NostrSigner = anonymousSignerCache ?: NostrSignerInternal(KeyPair()).also { anonymousSignerCache = it }
|
||||
|
||||
fun lnAddress(): String? = account.userProfile().lnAddress()
|
||||
|
||||
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
|
||||
@@ -452,7 +463,7 @@ open class CommentPostViewModel :
|
||||
cancel()
|
||||
|
||||
if (anonymous) {
|
||||
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
|
||||
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner())
|
||||
} else {
|
||||
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
|
||||
}
|
||||
@@ -619,6 +630,7 @@ open class CommentPostViewModel :
|
||||
context,
|
||||
stripMetadata = stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
@@ -711,6 +723,7 @@ open class CommentPostViewModel :
|
||||
wantsToAddGeoHash = false
|
||||
wantsSecretEmoji = false
|
||||
wantsAnonymousPost = false
|
||||
anonymousSignerCache = null
|
||||
|
||||
forwardZapTo.value = SplitBuilder()
|
||||
forwardZapToEditting.clearText()
|
||||
|
||||
+1
-1
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActiviti
|
||||
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
|
||||
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
|
||||
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState
|
||||
@@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuild
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
|
||||
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
|
||||
+1
-1
@@ -20,10 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.util.replace
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.replace
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
|
||||
+1
-1
@@ -20,9 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.util.replace
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.replace
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
|
||||
+15
-1
@@ -91,7 +91,10 @@ import com.vitorpamplona.quartz.experimental.zapPolls.minAmount
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
|
||||
@@ -306,6 +309,14 @@ open class ShortNotePostViewModel :
|
||||
// Anonymous Reply
|
||||
var wantsAnonymousPost by mutableStateOf(false)
|
||||
|
||||
// A single ephemeral signer reused for the whole compose session so that media
|
||||
// uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed
|
||||
// by the same throwaway key, instead of leaking the real account's pubkey into the
|
||||
// upload authorization (and therefore into the returned media URL).
|
||||
private var anonymousSignerCache: NostrSigner? = null
|
||||
|
||||
fun anonymousSigner(): NostrSigner = anonymousSignerCache ?: NostrSignerInternal(KeyPair()).also { anonymousSignerCache = it }
|
||||
|
||||
// Scheduled posting: epoch seconds (UTC) when the post should be published.
|
||||
// Null = post immediately on Send (existing behavior).
|
||||
var scheduledForSec by mutableStateOf<Long?>(null)
|
||||
@@ -870,7 +881,7 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
if (anonymous) {
|
||||
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
|
||||
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner())
|
||||
} else if (accountViewModel.settings.useTrackedBroadcasts()) {
|
||||
// Tracked broadcasting with progress feedback (non-blocking)
|
||||
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
|
||||
@@ -1138,6 +1149,7 @@ open class ShortNotePostViewModel :
|
||||
stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
convertGifToMp4 = convertGifToMp4,
|
||||
forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
@@ -1235,6 +1247,7 @@ open class ShortNotePostViewModel :
|
||||
wantsExclusiveGeoPost = false
|
||||
wantsSecretEmoji = false
|
||||
wantsAnonymousPost = false
|
||||
anonymousSignerCache = null
|
||||
scheduledForSec = null
|
||||
|
||||
forwardZapTo.value = SplitBuilder()
|
||||
@@ -1467,6 +1480,7 @@ open class ShortNotePostViewModel :
|
||||
account = account,
|
||||
context = appContext,
|
||||
useH265 = false,
|
||||
forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null,
|
||||
)
|
||||
|
||||
when (result) {
|
||||
|
||||
+1
-1
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.util.replace
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.replace
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
+1
-1
@@ -25,8 +25,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.util.replace
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.replace
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.service.retryIfException
|
||||
import com.vitorpamplona.amethyst.commons.util.retryIfException
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.tasks.await
|
||||
|
||||
@@ -114,6 +114,12 @@ kotlin {
|
||||
// coil-ktor when the iOS Compose UI ships.
|
||||
implementation(libs.coil.okhttp)
|
||||
|
||||
// OkHttp (+ coroutines bridge) for the link-preview fetcher
|
||||
// (service/preview/UrlPreview). JVM-only; iOS will swap to
|
||||
// Ktor when its UI ships.
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttpCoroutines)
|
||||
|
||||
// Markdown rendering (richtext-commonmark). The single
|
||||
// consumer (RenderMarkdown.kt) already lives in jvmAndroid.
|
||||
// iOS support pending Phase 3 markdown decision.
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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.commons.preview
|
||||
|
||||
/**
|
||||
* Decodes [bytes] into a String using the charset named [charsetName].
|
||||
*
|
||||
* [charsetName] is an IANA charset name (e.g. "UTF-8", "ISO-8859-1",
|
||||
* "windows-1252"). When it is null or cannot be resolved on the current
|
||||
* platform, the implementation falls back to UTF-8.
|
||||
*
|
||||
* The decode is the only platform-specific step of link-preview HTML parsing:
|
||||
* the JVM actual delegates to `java.nio.charset`, which supports every charset
|
||||
* the JRE ships; the iOS actual maps the common web charsets to
|
||||
* `NSStringEncoding` and falls back to UTF-8 for anything exotic.
|
||||
*/
|
||||
expect fun decodeBytes(
|
||||
bytes: ByteArray,
|
||||
charsetName: String?,
|
||||
): String
|
||||
+11
-11
@@ -20,37 +20,37 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.preview
|
||||
|
||||
import java.nio.charset.Charset
|
||||
|
||||
object HtmlCharsetParser {
|
||||
val ATTRIBUTE_VALUE_CHARSET = "charset"
|
||||
val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv"
|
||||
val CONTENT = "content"
|
||||
|
||||
private const val DEFAULT_CHARSET = "UTF-8"
|
||||
|
||||
private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""")
|
||||
|
||||
fun detectCharset(bodyBytes: ByteArray): Charset {
|
||||
/**
|
||||
* Sniffs the charset declared in the document's `<meta>` tags, returning its
|
||||
* IANA name. Returns [DEFAULT_CHARSET] when no usable declaration is found.
|
||||
*/
|
||||
fun detectCharset(bodyBytes: ByteArray): String {
|
||||
// try to detect charset from meta tags parsed from first 1024 bytes of body
|
||||
val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8"))
|
||||
val firstPart = bodyBytes.decodeToString(0, minOf(1024, bodyBytes.size))
|
||||
val metaTags = MetaTagsParser.parse(firstPart)
|
||||
metaTags.forEach { meta ->
|
||||
val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET)
|
||||
if (charsetAttr.isNotEmpty()) {
|
||||
runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let {
|
||||
return it
|
||||
}
|
||||
return charsetAttr
|
||||
}
|
||||
if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") {
|
||||
RE_CONTENT_TYPE_CHARSET
|
||||
.find(meta.attr(CONTENT))
|
||||
?.let {
|
||||
runCatching { Charset.forName(it.groupValues[1]) }.getOrNull()
|
||||
}?.let {
|
||||
return it
|
||||
return it.groupValues[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
// defaults to UTF-8
|
||||
return Charset.forName("utf-8")
|
||||
return DEFAULT_CHARSET
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.commons.preview
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class HtmlParser {
|
||||
companion object {
|
||||
// Byte-order marks mapped to their IANA charset names, longest first so
|
||||
// a 4-byte BOM is matched before a 2-byte one. (Patterns taken from okhttp.)
|
||||
private val UNICODE_BOMS =
|
||||
listOf(
|
||||
byteArrayOf(0x00, 0x00, 0xFF.toByte(), 0xFF.toByte()) to "UTF-32BE",
|
||||
byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0x00, 0x00) to "UTF-32LE",
|
||||
byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) to "UTF-8",
|
||||
byteArrayOf(0xFE.toByte(), 0xFF.toByte()) to "UTF-16BE",
|
||||
byteArrayOf(0xFF.toByte(), 0xFE.toByte()) to "UTF-16LE",
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun parseHtml(
|
||||
bodyBytes: ByteArray,
|
||||
charsetName: String?,
|
||||
): Sequence<MetaTag> =
|
||||
withContext(Dispatchers.Default) {
|
||||
// Precedence: explicit charset (e.g. from Content-Type) > BOM >
|
||||
// charset sniffed from <meta> tags (defaults to UTF-8).
|
||||
val name =
|
||||
charsetName
|
||||
?: bodyBytes.bomCharsetName()
|
||||
?: HtmlCharsetParser.detectCharset(bodyBytes)
|
||||
val content = decodeBytes(bodyBytes, name)
|
||||
MetaTagsParser.parse(content)
|
||||
}
|
||||
|
||||
private fun ByteArray.bomCharsetName(): String? {
|
||||
for ((bom, name) in UNICODE_BOMS) {
|
||||
if (startsWith(bom)) return name
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun ByteArray.startsWith(prefix: ByteArray): Boolean {
|
||||
if (size < prefix.size) return false
|
||||
for (i in prefix.indices) {
|
||||
if (this[i] != prefix[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -18,11 +18,12 @@
|
||||
* 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.broadcast
|
||||
package com.vitorpamplona.amethyst.commons.service.broadcast
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Result of a relay's response to an event publish.
|
||||
@@ -72,7 +73,7 @@ data class BroadcastEvent(
|
||||
val id: String,
|
||||
val event: Event,
|
||||
val targetRelays: List<NormalizedRelayUrl>,
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
val startedAt: Long = TimeUtils.now(),
|
||||
val results: Map<NormalizedRelayUrl, RelayResult> = emptyMap(),
|
||||
val status: BroadcastStatus = BroadcastStatus.IN_PROGRESS,
|
||||
) {
|
||||
+3
-3
@@ -18,7 +18,7 @@
|
||||
* 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.broadcast
|
||||
package com.vitorpamplona.amethyst.commons.service.broadcast
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -41,7 +42,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Tracks event broadcasts to relays with live progress updates.
|
||||
@@ -73,7 +73,7 @@ class BroadcastTracker {
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
client: INostrClient,
|
||||
) {
|
||||
val trackingId = UUID.randomUUID().toString()
|
||||
val trackingId = RandomInstance.randomChars(16)
|
||||
|
||||
val broadcast =
|
||||
BroadcastEvent(
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
* 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
|
||||
package com.vitorpamplona.amethyst.commons.util
|
||||
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
+1
-1
@@ -18,6 +18,6 @@
|
||||
* 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
|
||||
package com.vitorpamplona.amethyst.commons.util
|
||||
|
||||
fun <T> Set<T>.togglePresenceInSet(item: T): Set<T> = if (contains(item)) minus(item) else plus(item)
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.commons.preview
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.NSASCIIStringEncoding
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSISOLatin1StringEncoding
|
||||
import platform.Foundation.NSString
|
||||
import platform.Foundation.NSStringEncoding
|
||||
import platform.Foundation.NSUTF16BigEndianStringEncoding
|
||||
import platform.Foundation.NSUTF16LittleEndianStringEncoding
|
||||
import platform.Foundation.NSUTF32BigEndianStringEncoding
|
||||
import platform.Foundation.NSUTF32LittleEndianStringEncoding
|
||||
import platform.Foundation.NSUTF8StringEncoding
|
||||
import platform.Foundation.NSWindowsCP1252StringEncoding
|
||||
import platform.Foundation.create
|
||||
|
||||
/**
|
||||
* iOS decode of HTML bytes by charset name. The common web charsets are mapped
|
||||
* to their `NSStringEncoding`; anything else falls back to UTF-8 (matching the
|
||||
* "defaults to UTF-8" behaviour of the charset sniffer).
|
||||
*/
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun decodeBytes(
|
||||
bytes: ByteArray,
|
||||
charsetName: String?,
|
||||
): String {
|
||||
if (bytes.isEmpty()) return ""
|
||||
|
||||
val encoding = encodingFor(charsetName)
|
||||
|
||||
val data =
|
||||
bytes.usePinned { pinned ->
|
||||
NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
|
||||
}
|
||||
|
||||
return (NSString.create(data, encoding) as String?)
|
||||
?: bytes.decodeToString()
|
||||
}
|
||||
|
||||
private fun encodingFor(charsetName: String?): NSStringEncoding =
|
||||
when (charsetName?.trim()?.uppercase()) {
|
||||
"UTF-16", "UTF-16BE", "UTF16" -> NSUTF16BigEndianStringEncoding
|
||||
"UTF-16LE" -> NSUTF16LittleEndianStringEncoding
|
||||
"UTF-32", "UTF-32BE", "UTF32" -> NSUTF32BigEndianStringEncoding
|
||||
"UTF-32LE" -> NSUTF32LittleEndianStringEncoding
|
||||
"ISO-8859-1", "ISO8859-1", "ISO_8859-1", "LATIN1", "L1", "CP819" -> NSISOLatin1StringEncoding
|
||||
"WINDOWS-1252", "CP1252" -> NSWindowsCP1252StringEncoding
|
||||
"US-ASCII", "ASCII", "ANSI_X3.4-1968" -> NSASCIIStringEncoding
|
||||
else -> NSUTF8StringEncoding
|
||||
}
|
||||
+13
-5
@@ -18,9 +18,17 @@
|
||||
* 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
|
||||
package com.vitorpamplona.amethyst.commons.preview
|
||||
|
||||
fun <T> Iterable<T>.replace(
|
||||
old: T,
|
||||
new: T,
|
||||
): List<T> = map { if (it == old) new else it }
|
||||
import java.nio.charset.Charset
|
||||
|
||||
actual fun decodeBytes(
|
||||
bytes: ByteArray,
|
||||
charsetName: String?,
|
||||
): String {
|
||||
val charset =
|
||||
charsetName
|
||||
?.let { runCatching { Charset.forName(it) }.getOrNull() }
|
||||
?: Charsets.UTF_8
|
||||
return bytes.toString(charset)
|
||||
}
|
||||
+2
-4
@@ -18,10 +18,8 @@
|
||||
* 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.previews
|
||||
package com.vitorpamplona.amethyst.commons.preview
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.preview.OpenGraphParser
|
||||
import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -65,7 +63,7 @@ class UrlPreview {
|
||||
?: throw IllegalArgumentException("Website returned unknown mimetype: ${response.headers["Content-Type"]}")
|
||||
when {
|
||||
mimeType.type == "text" && mimeType.subtype == "html" -> {
|
||||
val metaTags = HtmlParser().parseHtml(response.body.source(), mimeType.charset())
|
||||
val metaTags = HtmlParser().parseHtml(response.body.bytes(), mimeType.charset()?.name())
|
||||
val data = OpenGraphParser().extractUrlInfo(metaTags)
|
||||
UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString())
|
||||
}
|
||||
+1
-1
@@ -73,7 +73,7 @@ class TagArrayBuilder<T : IEvent> {
|
||||
}
|
||||
|
||||
fun addUniqueValueIfNew(tag: Array<String>): TagArrayBuilder<T> {
|
||||
if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this
|
||||
if (!tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this
|
||||
val list = tagList.getOrPut(tag[0], ::mutableListOf)
|
||||
if (list.none { it.valueOrNull() == tag[1] }) {
|
||||
list.add(tag)
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.nip18Reposts
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* Regression test for the inverted guard in TagArrayBuilder.addUniqueValueIfNew that
|
||||
* silently dropped every `q` (quote) tag from events built with the TagArrayBuilder DSL.
|
||||
*/
|
||||
class QuoteTagBuilderTest {
|
||||
@Test
|
||||
fun addressableQuoteAddsQTag() {
|
||||
// naddr for a kind-36787 music track (see investigated event 759e543b...).
|
||||
val content =
|
||||
"nostr:naddr1qq8hgunpvd4j6at0v5mkcv35x9hqzxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctczyrt5unwa5r40e3uv8z2h3e6rvkpfj8cfxrsv9fcj93w2hl9d06lz5qcyqqqglvc3w67s7"
|
||||
|
||||
val entities = findNostrUris(content)
|
||||
assertEquals(1, entities.size, "the naddr should be parsed into a single entity")
|
||||
|
||||
val template =
|
||||
TextNoteEvent.build(content) {
|
||||
quotes(findNostrUris(content))
|
||||
}
|
||||
|
||||
val qTag = template.tags.firstOrNull { it.isNotEmpty() && it[0] == "q" }
|
||||
assertNotNull(qTag, "a `q` tag must be added for the quoted naddr")
|
||||
assertEquals(
|
||||
"36787:d74e4ddda0eafcc78c389578e7436582991f0930e0c2a7122c5cabfcad7ebe2a:track-uoe7l241n",
|
||||
qTag[1],
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addUniqueValueIfNewGuardSemantics() {
|
||||
val builder = TagArrayBuilder<TextNoteEvent>()
|
||||
|
||||
// well-formed tag is kept
|
||||
builder.addUniqueValueIfNew(arrayOf("q", "value1"))
|
||||
// a name-only tag is ignored (no value at index 1)
|
||||
builder.addUniqueValueIfNew(arrayOf("q"))
|
||||
// a duplicate value is not added twice
|
||||
builder.addUniqueValueIfNew(arrayOf("q", "value1"))
|
||||
// a distinct value is kept
|
||||
builder.addUniqueValueIfNew(arrayOf("q", "value2"))
|
||||
|
||||
val qTags = builder.build().filter { it.isNotEmpty() && it[0] == "q" }
|
||||
assertEquals(listOf("value1", "value2"), qTags.map { it[1] })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user