Merge pull request #4278 from vitorpamplona/claude/intelligent-babbage-23komv

refactor: one-UI step 6 waves 3–7 — upload port, 140 shared screens, audit fixes
This commit is contained in:
Vitor Pamplona
2026-09-29 17:48:43 -04:00
committed by GitHub
603 changed files with 7039 additions and 4205 deletions
@@ -47,7 +47,6 @@ Under `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/`:
- **`commons/src/commonMain/.../threading/Threading.kt`** — shared dispatchers and `CoroutineScope` helpers for commonMain code.
- **`amethyst/src/main/java/.../service/CoroutinesExt.kt`** — Android-only helpers: `launchIO(block)`, `launchMain(block)` built on top of `Dispatchers.IO` / `Dispatchers.Main`. Use these in ViewModels and services to stop re-spelling the dispatcher every time.
- **`amethyst/src/main/java/.../service/MainThreadChecker.kt`** — debug assertion helper for catching main-thread misuse during dev.
## Quartz iterables & JSON
@@ -24,6 +24,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.contentUriFor
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.copyAssetToCache
import com.vitorpamplona.amethyst.commons.service.uploads.AVIF_MIME
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Test
@@ -26,6 +26,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.contentUriFor
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.copyAssetToCache
import com.vitorpamplona.amethyst.commons.service.uploads.AVIF_MIME
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -71,9 +71,9 @@ import com.vitorpamplona.amethyst.commons.resources.select_push_server
import com.vitorpamplona.amethyst.commons.ui.components.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsBlockTile
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.FileSystemNip95BlobStore
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.LocalCacheHost
import com.vitorpamplona.amethyst.commons.model.cache.Nip95BlobStore
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.commons.service.UsageCounter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -55,6 +55,8 @@ class AmethystLocalCacheHost(
override val relayStats: RelayStats get() = modules.relayStats
override val usageCounter: UsageCounter = UsageCounter { key, amount -> modules.resourceUsage.add(key, amount) }
override fun relaySelfPubKey(relay: NormalizedRelayUrl): HexKey? = modules.nip11Cache.getFromCache(relay).self
override fun relayInfo(relay: NormalizedRelayUrl): Nip11RelayInformation = modules.nip11Cache.getFromCache(relay)
@@ -63,6 +65,4 @@ class AmethystLocalCacheHost(
// The zap path's LNURL cache is a quartz-side singleton the outbound-zap resolver fills.
override fun lnurlEndpoint(lnurlpUrl: String): LnurlEndpointInfo? = LnurlEndpointCache.get(lnurlpUrl)
override fun assertNotMainThread() = checkNotInMainThread()
}
@@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResol
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobStore
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.commons.state.UiSettingsState
import com.vitorpamplona.amethyst.commons.tor.TorRelayState
import com.vitorpamplona.amethyst.commons.tor.TorSettings
@@ -136,7 +137,6 @@ import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore
import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.SessionTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
@@ -64,7 +63,6 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.backups.BackupConflictStorage
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
@@ -675,17 +673,14 @@ object LocalPreferences {
}
}
private fun encryptedPreferences(npub: String? = null): SharedPreferences {
checkNotInMainThread()
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
private fun encryptedPreferences(npub: String? = null): SharedPreferences =
if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
val preferenceFile =
if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
Amethyst.instance.appContext.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
} else {
Amethyst.instance.encryptedStorage(npub)
}
}
/**
* Clears the preferences for a given npub, deletes the preferences xml file, and switches the
@@ -695,7 +690,7 @@ object LocalPreferences {
* deleted. If we use `apply()` there is a race condition and the file will probably not be
* deleted
*/
@SuppressLint("ApplySharedPref")
@Suppress("ApplySharedPref")
suspend fun deleteAccount(accountInfo: AccountInfo) {
Log.d("LocalPreferences") { "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}" }
withContext(Dispatchers.IO) {
@@ -27,6 +27,7 @@ import android.os.Bundle
import android.widget.Toast
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.favoriteCoordinateOf
import com.vitorpamplona.amethyst.commons.model.ThemeType
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.napplet.NappletLauncher
@@ -212,17 +213,6 @@ object FavoriteAppLauncher {
}
}
/**
* The addressable coordinate `kind:pubkey:dtag` used to key an nsite/napplet favorite. Stored
* instead of the content hash so the favorite survives routine code/manifest updates.
*/
fun coordinateOf(event: Event): String {
val dTag =
when (event) {
is NamedNappletEvent -> event.identifier()
is NamedSiteEvent -> event.identifier()
else -> ""
}
return "${event.kind}:${event.pubKey}:$dTag"
}
/** See [favoriteCoordinateOf]. */
fun coordinateOf(event: Event): String = favoriteCoordinateOf(event)
}
@@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.model.privacyOptions
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager
import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.commons.service.http.ProxiedSocketFactory
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
@@ -487,7 +487,7 @@ class NappletBrokerService : Service() {
/**
* Brings the main activity (a `singleInstance`) forward at the Connected Apps detail for [coordinate],
* via the in-process `connectedapp?coordinate=` deep link that [com.vitorpamplona.amethyst.ui.uriToRoute]
* via the in-process `connectedapp?coordinate=` deep link that [com.vitorpamplona.amethyst.commons.ui.uriToRoute]
* resolves. Used when a full-screen sandbox surface taps "Manage permissions".
*/
private fun openConnectedAppDetail(coordinate: String) {
@@ -20,11 +20,10 @@
*/
package com.vitorpamplona.amethyst.service
import android.annotation.SuppressLint
import android.content.Context
object PackageUtils {
@SuppressLint("QueryPermissionsNeeded")
@Suppress("QueryPermissionsNeeded")
private fun isPackageInstalled(
context: Context,
target: String,
@@ -32,9 +32,9 @@ import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_body
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_default_title
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.service.cashu
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenB64Parser
import kotlinx.collections.immutable.ImmutableList
@@ -34,8 +33,6 @@ import kotlinx.collections.immutable.toImmutableList
*/
class CashuParser {
fun parse(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
val parsed =
CashuTokenB64Parser.parse(cashuToken)
?: return GenericLoadable.Error("Could not parse this cashu token")
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.location
import android.annotation.SuppressLint
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
@@ -67,7 +66,7 @@ class LocationFlow(
private val locationManager: LocationManager,
private val sdkInt: Int = Build.VERSION.SDK_INT,
) {
@SuppressLint("MissingPermission")
@Suppress("MissingPermission")
fun get(
minTimeMs: Long,
minDistanceM: Float,
@@ -114,7 +113,7 @@ class LocationFlow(
}
/** True when the registration was accepted; false when the provider refused it. */
@SuppressLint("MissingPermission")
@Suppress("MissingPermission")
private fun requestUpdates(
provider: String,
minTimeMs: Long,
@@ -143,7 +142,7 @@ class LocationFlow(
* the update request is — on a device where one refuses us, the others
* should still seed.
*/
@SuppressLint("MissingPermission")
@Suppress("MissingPermission")
private fun freshestLastKnownLocation(providers: List<String>): Location? =
providers
.mapNotNull { provider ->
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.logging
import android.annotation.SuppressLint
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
@@ -166,7 +165,7 @@ class StackSampler(
companion object {
const val SEPARATOR: String = "\r\n"
@SuppressLint("SimpleDateFormat")
@Suppress("SimpleDateFormat")
val TIME_FORMATTER: SimpleDateFormat = SimpleDateFormat("MM-dd HH:mm:ss.SSS")
}
}
@@ -31,7 +31,7 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.payments
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@@ -196,7 +195,7 @@ object PayToAppAvailability {
}.getOrNull()
}
@SuppressLint("QueryPermissionsNeeded")
@Suppress("QueryPermissionsNeeded")
private fun queryActivities(
pm: PackageManager,
intent: Intent,
@@ -208,7 +207,7 @@ object PayToAppAvailability {
}.getOrDefault(emptyList())
/** Packages that answer a URL nobody can own — i.e. general-purpose browsers. */
@SuppressLint("QueryPermissionsNeeded")
@Suppress("QueryPermissionsNeeded")
private fun browserPackages(pm: PackageManager): Set<String> = queryActivities(pm, viewIntent(CONTROL_URL)).mapNotNull { it.packageName() }.toSet()
/**
@@ -20,29 +20,15 @@
*/
package com.vitorpamplona.amethyst.service.playback.composable.wavefront
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.components.wavefront.FakeWaveformAnimation
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -50,7 +36,6 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlin.math.sin
@Composable
fun FakeWaveformAnimation(
@@ -97,60 +82,3 @@ fun pollCurrentPosition(controller: Player) =
emit(controller.currentPosition)
}.flowOn(Dispatchers.IO)
.conflate()
@Preview
@Composable
fun FakeWaveformAnimationPreview() {
val state =
remember {
mutableFloatStateOf((500 % 1000.0f) / 1000.0f)
}
ThemeComparisonColumn {
FakeWaveformAnimation(state, 50, Modifier.width(200.dp))
}
}
@Composable
fun FakeWaveformAnimation(
waveformProgress: MutableFloatState,
barCount: Int = 50,
modifier: Modifier = Modifier,
) {
val barBrush =
Brush.linearGradient(
colors = listOf(Color(0xff2598cf), Color(0xff652d80)),
start = Offset(0.2f * 128f, 0f),
end = Offset(0.2f * 128f + 128f, 0f),
)
Canvas(
modifier =
modifier
.fillMaxWidth()
.requiredHeight(48.dp)
.padding(horizontal = 10.dp),
) {
val barWidth = 3.dp.toPx()
val barPadding = 2.dp.toPx()
val barRadius = 2.dp.toPx()
val totalBarWidth = barWidth + barPadding
val startX = (size.width - (barCount * totalBarWidth - barPadding)) / 2f
for (i in 0 until barCount) {
val phase = (i.toFloat() / barCount) * 2f * Math.PI.toFloat()
val wave = sin(waveformProgress.floatValue * 2f * Math.PI.toFloat() + phase)
val barFraction = ((wave + 1f) / 2f)
val barHeight = size.height * barFraction
val x = startX + i * totalBarWidth
val y = (size.height - barHeight) / 2f
drawRoundRect(
brush = barBrush,
topLeft = Offset(x, y),
size = Size(barWidth, barHeight),
cornerRadius = CornerRadius(barRadius, barRadius),
style = Fill,
)
}
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.playback.diskCache
import android.annotation.SuppressLint
import android.content.Context
import android.os.StatFs
import androidx.media3.database.StandaloneDatabaseProvider
@@ -34,7 +33,7 @@ import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache.Companio
import com.vitorpamplona.quartz.utils.Log
import java.io.File
@SuppressLint("UnsafeOptInUsageError")
@Suppress("UnsafeOptInUsageError")
class VideoCache {
companion object {
// Target fraction of currently-available disk space.
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service.playback.playerPool
import android.os.SystemClock
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
/**
* Accounts actual media playback time into the resource-usage ledger.
@@ -21,10 +21,10 @@
package com.vitorpamplona.amethyst.service.relayClient
import com.vitorpamplona.amethyst.commons.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.commons.tor.RelayClassification
import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.utils.Log
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthPrompt
import com.vitorpamplona.amethyst.commons.relayClient.auth.UserAuthChoice
import com.vitorpamplona.amethyst.commons.relayClient.authCommand.compose.LoadRelayAuthUser
import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.Note
@@ -30,7 +29,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourc
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent
@SuppressLint("StateFlowValueCalledInComposition")
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun NWCFinderFilterAssemblerSubscription(
note: Note,
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.resourceusage
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
/**
* Samples the device battery level at each ledger flush and accumulates the
* drops that happen while discharging into [UsageKeys.BATTERY_DRAIN_FG] /
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.resourceusage
import android.os.SystemClock
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.resourceusage
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.resourceusage
import android.os.Process
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
/**
* Samples whole-process CPU time (user+system, [Process.getElapsedCpuTime])
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.resourceusage
import android.os.SystemClock
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.resourceusage
import android.os.SystemClock
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposeOrNull
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.resourceusage
import android.os.SystemClock
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.resourceusage
import android.os.SystemClock
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -20,7 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.resourceusage
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys.sumMatching
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys.sumMatching
/**
* Headline metrics derived from one or more daily counter buckets. Shared by
@@ -33,7 +33,7 @@ import androidx.work.WorkerParameters
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostPublisher
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.commons.service.resourceusage.UsageKeys
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -0,0 +1,595 @@
/*
* 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.net.Uri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.model.mediaServers.blossomUploadOrder
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.commons.service.upload.FileHeader
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaCompressorResult
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri
import com.vitorpamplona.amethyst.commons.service.uploads.UploadError
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState.UploadingFinalState
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
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.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
/**
* Android's [MediaUploader]: compresses with LightCompressor/Compressor, strips metadata, encrypts,
* and uploads over the app's upload-role OkHttp clients. Reads files through the application
* [appContext]'s content resolver, so it needs no Activity. Every entry point moves to
* [Dispatchers.IO] itself: hashing, bitmap decoding and stripping block, and shared callers may
* launch from a Main-bound scope.
*/
class AndroidMediaUploader(
private val appContext: Context,
) : MediaUploader {
override suspend fun upload(
progress: UploadOrchestrator,
uri: MediaUri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
server: ServerName,
account: Account,
useH265: Boolean,
stripMetadata: Boolean,
onStrippingFailed: suspend () -> Boolean,
convertGifToMp4: Boolean,
forcedSigner: NostrSigner?,
): UploadingFinalState = withContext(Dispatchers.IO) { Job(progress).upload(uri, mimeType, alt, contentWarningReason, compressionQuality, server, account, appContext, useH265, stripMetadata, onStrippingFailed, convertGifToMp4, forcedSigner) }
override suspend fun uploadEncrypted(
progress: UploadOrchestrator,
uri: MediaUri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
encrypt: NostrCipher,
server: ServerName,
account: Account,
useH265: Boolean,
stripMetadata: Boolean,
onStrippingFailed: suspend () -> Boolean,
convertGifToMp4: Boolean,
forcedSigner: NostrSigner?,
): UploadingFinalState = withContext(Dispatchers.IO) { Job(progress).uploadEncrypted(uri, mimeType, alt, contentWarningReason, compressionQuality, encrypt, server, account, appContext, useH265, stripMetadata, onStrippingFailed, convertGifToMp4, forcedSigner) }
override suspend fun compressIfNeeded(
progress: UploadOrchestrator,
uri: MediaUri,
mimeType: String?,
compressionQuality: CompressorQuality,
useH265: Boolean,
convertGifToMp4: Boolean,
): MediaCompressorResult = withContext(Dispatchers.IO) { Job(progress).compressIfNeeded(uri, mimeType, compressionQuality, appContext, useH265, convertGifToMp4) }
/** One upload's run: the pipeline, reporting into [progress]. */
private class Job(
val progress: UploadOrchestrator,
) {
fun error(
error: UploadError,
vararg params: String,
) = progress.error(error, *params)
fun finish(result: UploadOrchestrator.OrchestratorResult) = progress.finish(result)
fun updateState(
newProgress: Double,
newState: UploadingState,
) = progress.updateState(newProgress, newState)
private fun uploadNIP95(
fileUri: Uri,
contentType: String?,
originalContentType: String?,
originalHash: String?,
context: Context,
): UploadingFinalState {
updateState(0.4, UploadingState.Uploading)
val bytes =
context.contentResolver.openInputStream(fileUri)?.use {
it.readBytes()
}
if (bytes != null) {
if (bytes.size > 80000) {
return error(UploadError.MEDIA_TOO_BIG_FOR_NIP95)
}
updateState(0.8, UploadingState.Hashing)
val result =
FileHeader.prepare(
bytes,
contentType,
null,
)
result.fold(
onSuccess = {
return finish(UploadOrchestrator.OrchestratorResult.NIP95Result(it, bytes, originalContentType, originalHash))
},
onFailure = {
return error(UploadError.COULD_NOT_CHECK_DOWNLOADED_FILE, it.message ?: it.javaClass.simpleName)
},
)
} else {
return error(UploadError.COULD_NOT_OPEN_COMPRESSED_FILE)
}
}
private suspend fun uploadNIP96(
fileUri: Uri,
contentType: String?,
size: Long?,
alt: String?,
contentWarningReason: String?,
serverBaseUrl: String,
contentTypeForResult: String?,
originalHash: String?,
account: Account,
forcedSigner: NostrSigner?,
context: Context,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
return try {
val result =
Nip96Uploader().upload(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = { percent: Float ->
updateState(0.2 + (0.2 * percent), UploadingState.Uploading)
},
httpAuth =
if (forcedSigner != null) {
{ url, method, body -> forcedSigner.sign(HTTPAuthorizationEvent.build(url, method, body)) }
} else {
account::createHTTPAuthorization
},
context = context,
)
verifyHeader(
uploadResult = result,
localContentType = contentType,
originalContentType = contentTypeForResult,
originalHash = originalHash,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
)
} catch (_: SignerExceptions.ReadOnlyException) {
error(UploadError.LOGIN_WITH_PRIVATE_KEY)
} catch (e: Exception) {
if (e is CancellationException) throw e
error(UploadError.FAILED_TO_UPLOAD_MEDIA, e.message ?: e.javaClass.simpleName)
}
}
/**
* Tries [selected], then the rest of the servers the picker offered (see
* [blossomUploadOrder]) until one stores the blob. Every server gets the same blob, so they
* share one signed upload token: the signer is asked once, and a signer that refused, timed
* out or is read-only stops the fallback instead of prompting again per server. Attempts
* don't publish their errors; when every server fails, the error shown is the selected
* server's, the one the user chose and will recognize.
*/
private suspend fun uploadBlossomWithFallback(
selected: ServerName,
account: Account,
uploadTo: suspend (serverBaseUrl: String, auth: SharedUploadAuth) -> UploadingFinalState,
): UploadingFinalState {
val order = blossomUploadOrder(selected, account.blossomServers.hostNameFlow.value)
val auth = SharedUploadAuth()
var firstError: UploadingState.Error? = null
for (server in order) {
when (val result = uploadTo(server.baseUrl, auth)) {
is UploadingState.Finished -> return result
is UploadingState.Error -> {
if (firstError == null) firstError = result
if (auth.signerFailed) return result.also { updateState(0.0, it) }
Log.w("UploadOrchestrator", "Upload to ${server.baseUrl} failed, trying the next server")
}
}
}
return firstError!!.also { updateState(0.0, it) }
}
/**
* The upload token for one blob, signed at most once and reused by every server tried.
* A signer that returned no token is asked again on the next server; only a token or a
* signer failure is kept.
*/
private class SharedUploadAuth {
private var outcome: Result<BlossomAuthorizationEvent>? = null
var signerFailed = false
private set
suspend fun get(sign: suspend () -> BlossomAuthorizationEvent?): BlossomAuthorizationEvent? {
outcome?.let { return it.getOrThrow() }
return try {
sign()?.also { outcome = Result.success(it) }
} catch (e: SignerExceptions) {
signerFailed = true
outcome = Result.failure(e)
throw e
}
}
}
private suspend fun uploadBlossom(
fileUri: Uri,
contentType: String?,
size: Long?,
alt: String?,
contentWarningReason: String?,
serverBaseUrl: String,
contentTypeForResult: String?,
originalHash: String?,
account: Account,
forcedSigner: NostrSigner?,
context: Context,
sharedAuth: SharedUploadAuth,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
// BUD-05: route through /media (optimize) when the user opted in. The forced-signer
// path (e.g. NIP-46 draft signing) always uses the bit-exact /upload.
val useMedia = forcedSigner == null && account.settings.optimizeMediaOnUpload.value
return try {
val result =
BlossomUploader()
.upload(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
// Use a t=media token when optimizing via /media, otherwise a plain
// t=upload token. Tokens are intentionally NOT server-scoped on the
// upload path: some servers reject an upload whose auth carries a
// `server` tag, and upload-token replay is not the threat scoping
// guards against (delete tokens are — those stay scoped).
httpAuth = { hash, size, alt ->
sharedAuth.get {
when {
forcedSigner != null -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner)
useMedia -> account.createBlossomMediaAuth(hash, size, alt)
else -> account.createBlossomUploadAuth(hash, size, alt)
}
}
},
context = context,
useMediaEndpoint = useMedia,
)
val finalState =
verifyHeader(
uploadResult = result,
localContentType = contentType,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
originalHash = originalHash,
originalContentType = contentTypeForResult,
)
// BUD-04: replicate the blob to the user's other Blossom servers for redundancy.
// Fire-and-forget on the account scope AFTER the upload is finished: mirroring is
// pure background redundancy, so it must never delay, alter, or fail the upload the
// user already completed, and must not touch the on-screen progress state.
if (finalState is UploadingState.Finished && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) {
account.scope.launch(Dispatchers.IO) {
try {
mirrorToOtherServers(result, serverBaseUrl, account)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Background mirror failed", e)
}
}
}
finalState
} catch (_: SignerExceptions.ReadOnlyException) {
UploadingState.Error(UploadError.LOGIN_WITH_PRIVATE_KEY, emptyArray())
} catch (e: BlossomPaymentException) {
// BUD-07: the server wants payment before it will store the blob.
UploadingState.Error(UploadError.BLOSSOM_PAYMENT_REQUIRED, arrayOf(e.payment.reason ?: serverBaseUrl))
} catch (e: Exception) {
if (e is CancellationException) throw e
UploadingState.Error(UploadError.FAILED_TO_UPLOAD_MEDIA, arrayOf(e.message?.ifBlank { null } ?: e.javaClass.simpleName))
}
}
/**
* BUD-04 mirror fan-out: asks every *other* Blossom server in the account's
* kind-10063 list to pull the freshly-uploaded blob from [result]'s URL. Runs
* in the background after the upload is finished (see caller), so it never
* delays the user's post or touches the upload progress UI; per-server failures
* are swallowed. Requires the blob's sha256 so server B can verify the download.
*/
private suspend fun mirrorToOtherServers(
result: MediaUploadResult,
primaryServerBaseUrl: String,
account: Account,
) {
val sourceUrl = result.url ?: return
val hash = result.sha256 ?: sourceUrl.substringAfterLast('/').substringBefore('.')
if (hash.length != 64) return
// Only the user's *explicitly configured* kind-10063 servers (flow), NOT the
// DEFAULT_MEDIA_SERVERS fallback that hostNameFlow injects — we must never fan
// uploads out to public defaults the user never opted into.
val primaryDomain = BlossomServerUrl.domain(primaryServerBaseUrl)
val targets =
account.blossomServers.flow.value
.filter { BlossomServerUrl.domain(it) != primaryDomain }
.distinct()
if (targets.isEmpty()) return
val contentType = result.type ?: "application/octet-stream"
targets.forEach { target ->
try {
val auth = account.createBlossomUploadAuth(hash, result.size ?: 0L, "Mirror $hash").toAuthorizationHeader()
BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target))
.mirrorOrUpload(sourceUrl, hash, contentType, target, auth)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Failed to mirror $hash to $target", e)
}
}
}
private suspend fun verifyHeader(
uploadResult: MediaUploadResult,
localContentType: String?,
originalContentType: String?,
originalHash: String?,
okHttpClient: (String) -> OkHttpClient,
): UploadingFinalState {
if (uploadResult.url.isNullOrBlank()) {
return error(UploadError.SERVER_DID_NOT_PROVIDE_URL)
}
updateState(0.6, UploadingState.Downloading)
// Use streaming verification for memory efficiency with large files
val verification =
ImageDownloader().waitAndVerifyStream(uploadResult.url, okHttpClient)
?: return error(UploadError.COULD_NOT_DOWNLOAD_FROM_SERVER)
updateState(0.8, UploadingState.Hashing)
// 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,
)
return finish(
UploadOrchestrator.OrchestratorResult.ServerResult(
fileHeader,
uploadResult.url,
uploadResult.magnet,
uploadResult.sha256,
originalContentType,
originalHash,
),
)
}
suspend fun compressIfNeeded(
uri: Uri,
mimeType: String?,
compressionQuality: CompressorQuality,
context: Context,
useH265: Boolean = false,
convertGifToMp4: Boolean = false,
) = if (compressionQuality != CompressorQuality.UNCOMPRESSED || convertGifToMp4) {
updateState(0.02, UploadingState.Compressing)
MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext, useH265, convertGifToMp4)
} else {
MediaCompressorResult(uri, mimeType, null)
}
private suspend fun stripAfterCompression(
originalUri: Uri,
compressed: MediaCompressorResult,
mimeType: String?,
compressionQuality: CompressorQuality,
stripMetadata: Boolean,
onStrippingFailed: suspend () -> Boolean,
context: Context,
): Uri? {
if (!stripMetadata) return compressed.uri
val effectiveMimeType = compressed.contentType ?: mimeType
val isVideo = effectiveMimeType?.startsWith("video/", ignoreCase = true) == true
val compressionRequested = compressionQuality != CompressorQuality.UNCOMPRESSED
val compressionApplied = compressionRequested && compressed.uri != originalUri
val strippingResult =
if (isVideo && compressionApplied) {
// Compression was requested and actually applied to a video;
// assume it stripped metadata successfully.
StrippingResult(compressed.uri, true)
} else {
// AvifMetadataNotVerifiableException is allowed to propagate so the caller
// can emit a specific error rather than the generic "Upload cancelled".
MetadataStripper.strip(compressed.uri, effectiveMimeType, context.applicationContext)
}
if (!strippingResult.stripped && !onStrippingFailed()) return null
return strippingResult.uri
}
/**
* Deletes a temporary file created during the upload pipeline if its URI
* differs from the original (meaning it's an intermediate temp file, not the user's content).
*/
internal fun deleteTempUri(
tempUri: Uri,
originalUri: Uri,
) {
if (tempUri == originalUri) return
try {
val path = tempUri.path ?: return
val file = File(path)
if (file.delete()) {
Log.d("UploadOrchestrator") { "Deleted temp file: $path" }
}
} catch (e: Exception) {
Log.w("UploadOrchestrator", "Failed to delete temp file: ${tempUri.path}", e)
}
}
suspend fun upload(
uri: Uri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
server: ServerName,
account: Account,
context: Context,
useH265: Boolean = false,
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
convertGifToMp4: Boolean = false,
forcedSigner: NostrSigner? = null,
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
val finalUri =
try {
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
} catch (e: AvifMetadataNotVerifiableException) {
return error(UploadError.AVIF_METADATA_STRIP_FAILED, e.message ?: e.javaClass.simpleName).also {
deleteTempUri(compressed.uri, uri)
}
} ?: return error(UploadError.UPLOAD_CANCELLED).also {
deleteTempUri(compressed.uri, uri)
}
if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri)
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, forcedSigner, context)
ServerType.Blossom ->
uploadBlossomWithFallback(server, account) { baseUrl, auth ->
uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, baseUrl, null, null, account, forcedSigner, context, auth)
}
}
} finally {
deleteTempUri(finalUri, uri)
}
}
suspend fun uploadEncrypted(
uri: Uri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
encrypt: NostrCipher,
server: ServerName,
account: Account,
context: Context,
useH265: Boolean = false,
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
convertGifToMp4: Boolean = false,
forcedSigner: NostrSigner? = null,
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
val finalUri =
try {
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
} catch (e: AvifMetadataNotVerifiableException) {
return error(UploadError.AVIF_METADATA_STRIP_FAILED, e.message ?: e.javaClass.simpleName).also {
deleteTempUri(compressed.uri, uri)
}
} ?: return error(UploadError.UPLOAD_CANCELLED).also {
deleteTempUri(compressed.uri, uri)
}
if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri)
val encrypted = EncryptFiles().encryptFile(context, finalUri, encrypt)
deleteTempUri(finalUri, uri)
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, forcedSigner, context)
// The same encrypted file goes to every server tried, so its key, nonce and
// hash stay valid whichever one ends up holding it.
ServerType.Blossom ->
uploadBlossomWithFallback(server, account) { baseUrl, auth ->
uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context, auth)
}
}
} finally {
deleteTempUri(encrypted.uri, uri)
}
}
}
}
@@ -26,7 +26,9 @@ import android.net.Uri
import androidx.core.net.toUri
import androidx.media3.common.MimeTypes
import com.davotoula.lightcompressor.video.GifToMp4Converter
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaCompressorResult
import com.vitorpamplona.amethyst.commons.service.uploads.isAvif
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
import com.vitorpamplona.quartz.utils.Log
import id.zelory.compressor.Compressor
@@ -34,12 +36,6 @@ import id.zelory.compressor.constraint.default
import kotlinx.coroutines.CancellationException
import java.io.File
class MediaCompressorResult(
val uri: Uri,
val contentType: String?,
val size: Long?,
)
class MediaCompressor {
// ALL ERRORS ARE IGNORED. The original file is returned.
suspend fun compress(
@@ -50,8 +46,6 @@ class MediaCompressor {
useH265: Boolean = false,
convertGifToMp4: Boolean = false,
): MediaCompressorResult {
checkNotInMainThread()
// Convert GIF to MP4 if requested. The GIF converter already produces a well-compressed
// H.264 MP4 so no additional video compression step is needed.
if (convertGifToMp4 && contentType?.contains("gif", ignoreCase = true) == true) {
@@ -127,24 +121,4 @@ class MediaCompressor {
MediaCompressorResult(uri, contentType, null)
}
}
companion object {
fun intToCompressorQuality(mediaQualityFloat: Int): CompressorQuality =
when (mediaQualityFloat) {
0 -> CompressorQuality.LOW
1 -> CompressorQuality.MEDIUM
2 -> CompressorQuality.HIGH
3 -> CompressorQuality.UNCOMPRESSED
else -> CompressorQuality.MEDIUM
}
}
}
enum class CompressorQuality {
VERY_LOW,
LOW,
MEDIUM,
HIGH,
VERY_HIGH,
UNCOMPRESSED,
}
@@ -29,6 +29,7 @@ import android.media.MediaMuxer
import android.net.Uri
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
import com.vitorpamplona.amethyst.commons.service.uploads.isAvif
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import java.io.File
@@ -31,6 +31,7 @@ import androidx.annotation.RequiresApi
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import com.vitorpamplona.amethyst.commons.service.image.BlurhashWrapper
import com.vitorpamplona.amethyst.commons.service.image.ThumbhashWrapper
import com.vitorpamplona.amethyst.commons.service.uploads.isAvif
import com.vitorpamplona.amethyst.commons.thumbhash.toThumbhash
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.Log
@@ -1,602 +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.uploads
import android.content.Context
import android.net.Uri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.model.mediaServers.blossomUploadOrder
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed
import com.vitorpamplona.amethyst.commons.resources.blossom_payment_required
import com.vitorpamplona.amethyst.commons.resources.could_not_check_downloaded_file
import com.vitorpamplona.amethyst.commons.resources.could_not_download_from_the_server
import com.vitorpamplona.amethyst.commons.resources.could_not_open_the_compressed_file
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_upload
import com.vitorpamplona.amethyst.commons.resources.media_too_big_for_nip95
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.resources.upload_cancelled
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.commons.service.upload.FileHeader
import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
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.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.jetbrains.compose.resources.StringResource
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
sealed class UploadingState {
data object Ready : UploadingState()
data object Compressing : UploadingState()
data object Uploading : UploadingState()
data object ServerProcessing : UploadingState()
data object Downloading : UploadingState()
data object Hashing : UploadingState()
sealed class UploadingFinalState : UploadingState()
class Finished(
val result: UploadOrchestrator.OrchestratorResult,
) : UploadingFinalState()
class Error(
val errorResource: StringResource,
val params: Array<out String>,
) : UploadingFinalState()
}
class UploadOrchestrator {
val progress = MutableStateFlow(0.0)
val progressState = MutableStateFlow<UploadingState>(UploadingState.Ready)
val isUploading =
progressState.map {
progressState.value !is UploadingState.Ready && progressState.value !is UploadingState.Error && progressState.value !is UploadingState.Finished
}
fun error(
resId: StringResource,
vararg params: String,
) = UploadingState.Error(resId, params).also { updateState(0.0, it) }
fun finish(result: OrchestratorResult) =
UploadingState
.Finished(result)
.also { updateState(1.0, it) }
fun updateState(
newProgress: Double,
newState: UploadingState,
) {
progress.value = newProgress
progressState.value = newState
}
private fun uploadNIP95(
fileUri: Uri,
contentType: String?,
originalContentType: String?,
originalHash: String?,
context: Context,
): UploadingFinalState {
updateState(0.4, UploadingState.Uploading)
val bytes =
context.contentResolver.openInputStream(fileUri)?.use {
it.readBytes()
}
if (bytes != null) {
if (bytes.size > 80000) {
return error(Res.string.media_too_big_for_nip95)
}
updateState(0.8, UploadingState.Hashing)
val result =
FileHeader.prepare(
bytes,
contentType,
null,
)
result.fold(
onSuccess = {
return finish(OrchestratorResult.NIP95Result(it, bytes, originalContentType, originalHash))
},
onFailure = {
return error(Res.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName)
},
)
} else {
return error(Res.string.could_not_open_the_compressed_file)
}
}
private suspend fun uploadNIP96(
fileUri: Uri,
contentType: String?,
size: Long?,
alt: String?,
contentWarningReason: String?,
serverBaseUrl: String,
contentTypeForResult: String?,
originalHash: String?,
account: Account,
forcedSigner: NostrSigner?,
context: Context,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
return try {
val result =
Nip96Uploader().upload(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = { percent: Float ->
updateState(0.2 + (0.2 * percent), UploadingState.Uploading)
},
httpAuth =
if (forcedSigner != null) {
{ url, method, body -> forcedSigner.sign(HTTPAuthorizationEvent.build(url, method, body)) }
} else {
account::createHTTPAuthorization
},
context = context,
)
verifyHeader(
uploadResult = result,
localContentType = contentType,
originalContentType = contentTypeForResult,
originalHash = originalHash,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
)
} catch (_: SignerExceptions.ReadOnlyException) {
error(Res.string.login_with_a_private_key_to_be_able_to_upload)
} catch (e: Exception) {
if (e is CancellationException) throw e
error(Res.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
}
}
/**
* Tries [selected], then the rest of the servers the picker offered (see
* [blossomUploadOrder]) until one stores the blob. Every server gets the same blob, so they
* share one signed upload token: the signer is asked once, and a signer that refused, timed
* out or is read-only stops the fallback instead of prompting again per server. Attempts
* don't publish their errors; when every server fails, the error shown is the selected
* server's, the one the user chose and will recognize.
*/
private suspend fun uploadBlossomWithFallback(
selected: ServerName,
account: Account,
uploadTo: suspend (serverBaseUrl: String, auth: SharedUploadAuth) -> UploadingFinalState,
): UploadingFinalState {
val order = blossomUploadOrder(selected, account.blossomServers.hostNameFlow.value)
val auth = SharedUploadAuth()
var firstError: UploadingState.Error? = null
for (server in order) {
when (val result = uploadTo(server.baseUrl, auth)) {
is UploadingState.Finished -> return result
is UploadingState.Error -> {
if (firstError == null) firstError = result
if (auth.signerFailed) return result.also { updateState(0.0, it) }
Log.w("UploadOrchestrator", "Upload to ${server.baseUrl} failed, trying the next server")
}
}
}
return firstError!!.also { updateState(0.0, it) }
}
/**
* The upload token for one blob, signed at most once and reused by every server tried.
* A signer that returned no token is asked again on the next server; only a token or a
* signer failure is kept.
*/
private class SharedUploadAuth {
private var outcome: Result<BlossomAuthorizationEvent>? = null
var signerFailed = false
private set
suspend fun get(sign: suspend () -> BlossomAuthorizationEvent?): BlossomAuthorizationEvent? {
outcome?.let { return it.getOrThrow() }
return try {
sign()?.also { outcome = Result.success(it) }
} catch (e: SignerExceptions) {
signerFailed = true
outcome = Result.failure(e)
throw e
}
}
}
private suspend fun uploadBlossom(
fileUri: Uri,
contentType: String?,
size: Long?,
alt: String?,
contentWarningReason: String?,
serverBaseUrl: String,
contentTypeForResult: String?,
originalHash: String?,
account: Account,
forcedSigner: NostrSigner?,
context: Context,
sharedAuth: SharedUploadAuth,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
// BUD-05: route through /media (optimize) when the user opted in. The forced-signer
// path (e.g. NIP-46 draft signing) always uses the bit-exact /upload.
val useMedia = forcedSigner == null && account.settings.optimizeMediaOnUpload.value
return try {
val result =
BlossomUploader()
.upload(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
// Use a t=media token when optimizing via /media, otherwise a plain
// t=upload token. Tokens are intentionally NOT server-scoped on the
// upload path: some servers reject an upload whose auth carries a
// `server` tag, and upload-token replay is not the threat scoping
// guards against (delete tokens are — those stay scoped).
httpAuth = { hash, size, alt ->
sharedAuth.get {
when {
forcedSigner != null -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner)
useMedia -> account.createBlossomMediaAuth(hash, size, alt)
else -> account.createBlossomUploadAuth(hash, size, alt)
}
}
},
context = context,
useMediaEndpoint = useMedia,
)
val finalState =
verifyHeader(
uploadResult = result,
localContentType = contentType,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
originalHash = originalHash,
originalContentType = contentTypeForResult,
)
// BUD-04: replicate the blob to the user's other Blossom servers for redundancy.
// Fire-and-forget on the account scope AFTER the upload is finished: mirroring is
// pure background redundancy, so it must never delay, alter, or fail the upload the
// user already completed, and must not touch the on-screen progress state.
if (finalState is UploadingState.Finished && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) {
account.scope.launch(Dispatchers.IO) {
try {
mirrorToOtherServers(result, serverBaseUrl, account)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Background mirror failed", e)
}
}
}
finalState
} catch (_: SignerExceptions.ReadOnlyException) {
UploadingState.Error(Res.string.login_with_a_private_key_to_be_able_to_upload, emptyArray())
} catch (e: BlossomPaymentException) {
// BUD-07: the server wants payment before it will store the blob.
UploadingState.Error(Res.string.blossom_payment_required, arrayOf(e.payment.reason ?: serverBaseUrl))
} catch (e: Exception) {
if (e is CancellationException) throw e
UploadingState.Error(Res.string.failed_to_upload_media, arrayOf(e.message?.ifBlank { null } ?: e.javaClass.simpleName))
}
}
/**
* BUD-04 mirror fan-out: asks every *other* Blossom server in the account's
* kind-10063 list to pull the freshly-uploaded blob from [result]'s URL. Runs
* in the background after the upload is finished (see caller), so it never
* delays the user's post or touches the upload progress UI; per-server failures
* are swallowed. Requires the blob's sha256 so server B can verify the download.
*/
private suspend fun mirrorToOtherServers(
result: MediaUploadResult,
primaryServerBaseUrl: String,
account: Account,
) {
val sourceUrl = result.url ?: return
val hash = result.sha256 ?: sourceUrl.substringAfterLast('/').substringBefore('.')
if (hash.length != 64) return
// Only the user's *explicitly configured* kind-10063 servers (flow), NOT the
// DEFAULT_MEDIA_SERVERS fallback that hostNameFlow injects — we must never fan
// uploads out to public defaults the user never opted into.
val primaryDomain = BlossomServerUrl.domain(primaryServerBaseUrl)
val targets =
account.blossomServers.flow.value
.filter { BlossomServerUrl.domain(it) != primaryDomain }
.distinct()
if (targets.isEmpty()) return
val contentType = result.type ?: "application/octet-stream"
targets.forEach { target ->
try {
val auth = account.createBlossomUploadAuth(hash, result.size ?: 0L, "Mirror $hash").toAuthorizationHeader()
BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target))
.mirrorOrUpload(sourceUrl, hash, contentType, target, auth)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Failed to mirror $hash to $target", e)
}
}
}
private suspend fun verifyHeader(
uploadResult: MediaUploadResult,
localContentType: String?,
originalContentType: String?,
originalHash: String?,
okHttpClient: (String) -> OkHttpClient,
): UploadingFinalState {
if (uploadResult.url.isNullOrBlank()) {
return error(Res.string.server_did_not_provide_a_url_after_uploading)
}
updateState(0.6, UploadingState.Downloading)
// Use streaming verification for memory efficiency with large files
val verification =
ImageDownloader().waitAndVerifyStream(uploadResult.url, okHttpClient)
?: return error(Res.string.could_not_download_from_the_server)
updateState(0.8, UploadingState.Hashing)
// 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,
)
return finish(
OrchestratorResult.ServerResult(
fileHeader,
uploadResult.url,
uploadResult.magnet,
uploadResult.sha256,
originalContentType,
originalHash,
),
)
}
sealed class OrchestratorResult {
class NIP95Result(
val fileHeader: FileHeader,
val bytes: ByteArray,
val mimeTypeBeforeEncryption: String?,
val hashBeforeEncryption: String?,
) : OrchestratorResult()
class ServerResult(
val fileHeader: FileHeader,
val url: String,
val magnet: String?,
val uploadedHash: String?,
val mimeTypeBeforeEncryption: String?,
val hashBeforeEncryption: String?,
) : OrchestratorResult()
}
suspend fun compressIfNeeded(
uri: Uri,
mimeType: String?,
compressionQuality: CompressorQuality,
context: Context,
useH265: Boolean = false,
convertGifToMp4: Boolean = false,
) = if (compressionQuality != CompressorQuality.UNCOMPRESSED || convertGifToMp4) {
updateState(0.02, UploadingState.Compressing)
MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext, useH265, convertGifToMp4)
} else {
MediaCompressorResult(uri, mimeType, null)
}
private suspend fun stripAfterCompression(
originalUri: Uri,
compressed: MediaCompressorResult,
mimeType: String?,
compressionQuality: CompressorQuality,
stripMetadata: Boolean,
onStrippingFailed: suspend () -> Boolean,
context: Context,
): Uri? {
if (!stripMetadata) return compressed.uri
val effectiveMimeType = compressed.contentType ?: mimeType
val isVideo = effectiveMimeType?.startsWith("video/", ignoreCase = true) == true
val compressionRequested = compressionQuality != CompressorQuality.UNCOMPRESSED
val compressionApplied = compressionRequested && compressed.uri != originalUri
val strippingResult =
if (isVideo && compressionApplied) {
// Compression was requested and actually applied to a video;
// assume it stripped metadata successfully.
StrippingResult(compressed.uri, true)
} else {
// AvifMetadataNotVerifiableException is allowed to propagate so the caller
// can emit a specific error rather than the generic "Upload cancelled".
MetadataStripper.strip(compressed.uri, effectiveMimeType, context.applicationContext)
}
if (!strippingResult.stripped && !onStrippingFailed()) return null
return strippingResult.uri
}
/**
* Deletes a temporary file created during the upload pipeline if its URI
* differs from the original (meaning it's an intermediate temp file, not the user's content).
*/
internal fun deleteTempUri(
tempUri: Uri,
originalUri: Uri,
) {
if (tempUri == originalUri) return
try {
val path = tempUri.path ?: return
val file = File(path)
if (file.delete()) {
Log.d("UploadOrchestrator") { "Deleted temp file: $path" }
}
} catch (e: Exception) {
Log.w("UploadOrchestrator", "Failed to delete temp file: ${tempUri.path}", e)
}
}
suspend fun upload(
uri: Uri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
server: ServerName,
account: Account,
context: Context,
useH265: Boolean = false,
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
convertGifToMp4: Boolean = false,
forcedSigner: NostrSigner? = null,
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
val finalUri =
try {
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
} catch (e: AvifMetadataNotVerifiableException) {
return error(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName).also {
deleteTempUri(compressed.uri, uri)
}
} ?: return error(Res.string.upload_cancelled).also {
deleteTempUri(compressed.uri, uri)
}
if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri)
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, forcedSigner, context)
ServerType.Blossom ->
uploadBlossomWithFallback(server, account) { baseUrl, auth ->
uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, baseUrl, null, null, account, forcedSigner, context, auth)
}
}
} finally {
deleteTempUri(finalUri, uri)
}
}
suspend fun uploadEncrypted(
uri: Uri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
encrypt: NostrCipher,
server: ServerName,
account: Account,
context: Context,
useH265: Boolean = false,
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
convertGifToMp4: Boolean = false,
forcedSigner: NostrSigner? = null,
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
val finalUri =
try {
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
} catch (e: AvifMetadataNotVerifiableException) {
return error(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName).also {
deleteTempUri(compressed.uri, uri)
}
} ?: return error(Res.string.upload_cancelled).also {
deleteTempUri(compressed.uri, uri)
}
if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri)
val encrypted = EncryptFiles().encryptFile(context, finalUri, encrypt)
deleteTempUri(finalUri, uri)
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, forcedSigner, context)
// The same encrypted file goes to every server tried, so its key, nonce and
// hash stay valid whichever one ends up holding it.
ServerType.Blossom ->
uploadBlossomWithFallback(server, account) { baseUrl, auth ->
uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context, auth)
}
}
} finally {
deleteTempUri(encrypted.uri, uri)
}
}
}
@@ -33,6 +33,8 @@ import com.davotoula.lightcompressor.VideoCompressor
import com.davotoula.lightcompressor.config.AppSpecificStorageConfiguration
import com.davotoula.lightcompressor.config.Configuration
import com.davotoula.lightcompressor.config.VideoResizer
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaCompressorResult
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -30,11 +30,10 @@ import com.vitorpamplona.amethyst.commons.resources.failed_to_delete_with_messag
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_to_server_with_message
import com.vitorpamplona.amethyst.commons.service.HttpStatusMessages
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.commons.service.uploads.extensionFromMimeType
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
import com.vitorpamplona.amethyst.service.uploads.extensionFromMimeType
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -86,8 +85,6 @@ class BlossomUploader {
useMediaEndpoint: Boolean = false,
onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null,
): MediaUploadResult {
checkNotInMainThread()
val contentResolver = context.contentResolver
val myContentType = contentType ?: contentResolver.getType(uri)
val fileName = context.getFileName(uri)
@@ -141,8 +138,6 @@ class BlossomUploader {
useMediaEndpoint: Boolean = false,
onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null,
): MediaUploadResult {
checkNotInMainThread()
val fileName = baseFileName ?: RandomInstance.randomChars(16)
val extension =
contentType?.let {
@@ -31,10 +31,9 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_delete_with_message
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_to_server_with_message
import com.vitorpamplona.amethyst.commons.service.HttpStatusMessages
import com.vitorpamplona.amethyst.commons.service.uploads.AVIF_EXTENSION
import com.vitorpamplona.amethyst.commons.service.uploads.AVIF_MIME
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.AVIF_EXTENSION
import com.vitorpamplona.amethyst.service.uploads.AVIF_MIME
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
@@ -105,8 +104,6 @@ class Nip96Uploader {
httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?,
context: Context,
): MediaUploadResult {
checkNotInMainThread()
val contentResolver = context.contentResolver
val myContentType = contentType ?: contentResolver.getType(uri)
val length = size ?: contentResolver.querySize(uri) ?: fileSize(uri) ?: 0
@@ -145,8 +142,6 @@ class Nip96Uploader {
httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?,
context: Context,
): MediaUploadResult {
checkNotInMainThread()
val fileName = RandomInstance.randomChars(16)
val extension =
contentType?.let {
@@ -27,51 +27,19 @@ import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeForPointer
import com.vitorpamplona.amethyst.commons.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.NotificationRelayService
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.findQueryParameterValue
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.UriParser
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.net.URLDecoder
class MainActivity : AppCompatActivity() {
companion object {
@@ -155,339 +123,3 @@ class MainActivity : AppCompatActivity() {
super.onDestroy()
}
}
private const val NOSTR_URI_PREFIX = "nostr:"
fun isNotificationRoute(uri: String) = uri.startsWith("notifications", true) || uri.startsWith("nostr:notifications", true)
fun isHashtagRoute(uri: String) = uri.startsWith("hashtag?id=") || uri.startsWith("nostr:hashtag?id=")
/**
* A markdown link target that is a bare hashtag fragment, e.g. the `#NostrMultiplayerGames` in
* `[Games](#NostrMultiplayerGames)`. Returns the tag without the leading `#`, or null when the
* uri is anything else (full URLs with anchors don't start with `#`). Reuses the same character
* class that linkifies #hashtags in plain text, so validity matches how tags parse everywhere else.
*/
fun fragmentHashtagOrNull(uri: String): String? {
val match = RichTextParser.hashTagsPattern.matchEntire(uri) ?: return null
if (!match.groups[2]?.value.isNullOrEmpty()) return null
return match.groups[1]?.value
}
fun isUrlRoute(uri: String) = uri.startsWith("url?id=") || uri.startsWith("nostr:url?id=")
/**
* A private chatroom, addressed by its participants rather than by a message.
* Posted by DM notifications — see [NotificationRoutes.chatroomUri] for why a DM
* cannot deep-link through its own note.
*/
fun isChatroomRoute(uri: String) = uri.startsWith("chatroom?id=") || uri.startsWith("nostr:chatroom?id=")
/**
* Note what this deliberately does *not* do: register the room on the account.
*
* `routeToMessage` would, and that is right for the in-app callers, but wrong here twice over.
* `uriToRoute` runs against whichever account is current, *before* `?account=` is read and the
* switch happens (`AppNavigation.NavigateIfIntentRequested`), so a DM notification for account B
* tapped while A is on screen would insert an empty conversation into **A**'s message list. And
* `nostr:` is an exported, browsable scheme, so any web page could post
* `nostr:chatroom?id=<hex>&account=…` and inject a room of its choosing.
*
* Nothing needs it: `ChatroomFeedFilter.chatroom()` calls `getOrCreatePrivateChatroom` when the
* screen actually opens, by which point the switch has happened and the room lands on the right
* account.
*
* The ids are still checked here — a room key is a set of pubkeys, so anything that isn't one is
* not a room, and a bad deep link should resolve to nothing rather than to an unopenable screen.
*/
fun chatroomRoute(uri: String): Route? {
val users =
uri
.findQueryParameterValue("id")
?.split(',')
?.map { it.trim() }
?.takeIf { it.isNotEmpty() && it.all(::isPubKeyHex) }
?.toSet() ?: return null
return Route.Room(ChatroomKey(users))
}
/** A bare 32-byte lowercase-hex pubkey, the only thing a chatroom key is made of. */
private fun isPubKeyHex(value: String) = value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }
/**
* A NIP-17 private note or reply, addressed by the rumor's own id because its `nevent`
* names the undeliverable gift wrap instead — see [NotificationRoutes.privateNoteUri].
*/
fun isPrivateNoteRoute(uri: String) = uri.startsWith("privatenote?id=") || uri.startsWith("nostr:privatenote?id=")
fun privateNoteRoute(uri: String): Route? {
val id = uri.findQueryParameterValue("id") ?: return null
return Route.EventRedirect(id, isPrivate = true)
}
fun isConnectedAppRoute(uri: String) = uri.startsWith("connectedapp?coordinate=") || uri.startsWith("nostr:connectedapp?coordinate=")
/**
* The Connected Apps permission-detail route for an app coordinate (`pubkey:dtag` for a napplet/nsite,
* `browser:<origin>` for a web client). Fired by the sandbox host so a running full-screen surface can
* jump straight to its editable permissions; the coordinate is URL-encoded into the [coordinate] param.
*/
fun connectedAppRoute(uri: String): Route.ConnectedAppDetail? {
val coordinate =
runCatching {
val raw = java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).findParameterValue("coordinate") ?: return null
URLDecoder.decode(raw, Charsets.UTF_8.name())
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
return Route.ConnectedAppDetail(coordinate)
}
fun urlRoute(uri: String): Route.Url? {
val url =
runCatching {
val rawUrl = java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).findParameterValue("id") ?: return null
URLDecoder.decode(rawUrl, Charsets.UTF_8.name())
}.getOrNull() ?: return null
return UrlId.toScopeOrNull(url)?.let { Route.Url(it) }
}
fun isWalletConnectRoute(uri: String) = uri.startsWith("dlnwc?value=") || uri.startsWith("amethyst+walletconnect:dlnwc?value=") || uri.startsWith("amethyst+walletconnect://dlnwc?value=")
fun isMarmotGroupRoute(uri: String) = uri.startsWith("marmot:")
/**
* Tapping the always-on service notification. That notification's whole subject is the relay
* connections it is holding open, so it lands on the screen that explains them rather than on
* whatever tab happened to be last open.
*/
fun isActiveSubscriptionsRoute(uri: String) = uri.startsWith("activesubs", true) || uri.startsWith("nostr:activesubs", true)
/**
* The launcher shortcut (res/xml/shortcuts.xml) fires `amethyst:scanqr`, caught by MainActivity's
* existing scheme filter. A pseudo-uri rather than a hardcoded component, because debug and
* benchmark builds carry an applicationId suffix a hardcoded targetPackage would miss, and
* manifest placeholders are not substituted into resource XML.
*
* `nostr:scanqr` is still accepted so an older pinned shortcut keeps working, but the shortcut
* itself uses our own scheme: an implicit `nostr:` VIEW intent can be answered by any Nostr client
* installed alongside us.
*/
fun isScanQrRoute(uri: String) =
uri.equals("scanqr", true) ||
uri.equals("amethyst:scanqr", true) ||
uri.equals("nostr:scanqr", true)
private val MARMOT_HEX = Regex("^[0-9a-fA-F]+$")
fun uriToRoute(
uri: String,
account: Account,
): Route? {
if (isNotificationRoute(uri)) {
val scrollTo = uri.findQueryParameterValue("scrollTo")
return Route.Notification(scrollToEventId = scrollTo)
}
if (isActiveSubscriptionsRoute(uri)) {
return Route.ActiveSubscriptions
}
if (isScanQrRoute(uri)) {
return Route.QRDisplay(account.signer.pubKey, startScanning = true)
}
if (isHashtagRoute(uri)) {
return Route.Hashtag(uri.removePrefix(NOSTR_URI_PREFIX).removePrefix("hashtag?id=").lowercase())
}
fragmentHashtagOrNull(uri)?.let {
return Route.Hashtag(it.lowercase())
}
if (isUrlRoute(uri)) {
return urlRoute(uri)
}
if (isChatroomRoute(uri)) {
return chatroomRoute(uri)
}
if (isPrivateNoteRoute(uri)) {
return privateNoteRoute(uri)
}
if (isConnectedAppRoute(uri)) {
return connectedAppRoute(uri)
}
// A scanned/opened `nostrconnect://` offer is an app asking to connect to our signer: open the
// NIP-46 signer screen and let it run the pairing (it enables the signer as part of connecting).
if (uri.startsWith(NostrConnectURI.NOSTRCONNECT_SCHEME)) {
return Route.Nip46Signer(connectUri = uri)
}
relayGroupInviteRoute(uri)?.let { return it }
concordInviteRoute(uri)?.let { return it }
buzzInviteRoute(uri)?.let { return it }
val parsedNip19 = Nip19Parser.uriToRoute(uri)
val nip19 = parsedNip19?.entity
if (nip19 != null) {
LocalCache.consume(nip19)
val route =
when (nip19) {
is NPub -> {
Route.Profile(nip19.hex)
}
is NProfile -> {
Route.Profile(nip19.hex)
}
is NNote -> {
Route.Note(nip19.hex)
}
is NEvent -> {
val note = LocalCache.getOrCreateNote(nip19.hex)
// Only fall back to the pointer's own kind while the body is missing: once the
// event is cached it may belong somewhere the kind alone can't name (a channel,
// a chatroom), and routeFor knows that.
if (note.event == null) {
routeForPointer(nip19.kind, nip19.hex) ?: Route.EventRedirect(nip19.hex)
} else {
routeFor(note = note, loggedIn = account) ?: Route.EventRedirect(nip19.hex)
}
}
is NAddress -> {
relayGroupDirectRoute(nip19, parsedNip19.additionalChars)
?: routeFor(
note = LocalCache.getOrCreateAddressableNote(nip19.address()),
loggedIn = account,
) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag())
}
is NEmbed -> {
val noteEvent = nip19.event
if (noteEvent is AddressableEvent) {
routeFor(
note = LocalCache.getOrCreateAddressableNote(noteEvent.address()),
loggedIn = account,
) ?: Route.EventRedirect(noteEvent.addressTag())
} else {
routeFor(
note = LocalCache.getOrCreateNote(nip19.event.id),
loggedIn = account,
) ?: Route.EventRedirect(nip19.event.id)
}
}
else -> {
null
}
}
if (route != null) {
return route
}
}
if (isMarmotGroupRoute(uri)) {
// marmot:<groupHex>?account=<npub>
val groupHex =
uri
.removePrefix("marmot:")
.substringBefore("?")
.substringBefore("&")
if (groupHex.matches(MARMOT_HEX)) {
return Route.MarmotGroupChat(groupHex)
}
}
if (isWalletConnectRoute(uri)) {
try {
val url = UriParser(uri)
val nip47Uri = url.getQueryParameter("value")?.firstOrNull()
if (nip47Uri != null) {
Nip47WalletConnect.parse(nip47Uri)
return Route.WalletAddNwc(nip47Uri)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
try {
Nip47WalletConnect.parse(uri)
return Route.WalletAddNwc(uri)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
return null
}
/**
* Direct route for an `naddr` whose event hasn't arrived in [LocalCache] yet. When a notification
* is tapped (or a `nostr:naddr…` deep link arrives) for a calendar appointment we know is kind
* 31922/31923, route straight to the dedicated detail screen instead of bouncing through
* [Route.EventRedirect]. The detail screen issues its own per-event subscription, so the user
* sees the calendar-specific loading placeholder while the event arrives, not the generic
* redirect screen.
*/
private fun calendarDirectRoute(nip19: NAddress): Route? =
when (nip19.kind) {
CalendarTimeSlotEvent.KIND,
CalendarDateSlotEvent.KIND,
-> Route.CalendarEventDetail(nip19.kind, nip19.author, nip19.dTag)
else -> null
}
/**
* Direct route for a NIP-29 group `naddr` (kind 39000) shared as a link. The group
* only exists on its host relay, so the naddr's relay hint is mandatory — the group
* id alone can't be resolved. With a hint we open the group chat straight away
* (cold start included); without one there's nowhere to look, so fall through.
*/
private fun relayGroupDirectRoute(
nip19: NAddress,
additionalChars: String?,
): Route? {
if (nip19.kind != GroupMetadataEvent.KIND) return null
val relay = nip19.relay.firstOrNull() ?: return null
// The spec allows an invite code appended as `naddr1…?invite=<code>`; it arrives
// in the trailing chars after the bech32 body. Carry it so the group auto-joins.
val inviteCode = GroupNAddrInvite.parse(additionalChars)
return Route.RelayGroup(nip19.dTag, relay.url, inviteCode = inviteCode)
}
/**
* Direct route for a NIP-29 group invite link in the de-facto `<relay>'<groupId>[?code=<code>]`
* form shared by Wisp and 0xchat (e.g. `wss://groups.0xchat.com'abc123`). The link carries its
* own host relay, so we can open the group straight away. Returns null for anything that isn't
* this exact shape, so it's safe to try before the nostr-entity parser.
*/
private fun relayGroupInviteRoute(uri: String): Route? {
val link = GroupInviteLink.parse(uri.removePrefix(NOSTR_URI_PREFIX)) ?: return null
return Route.RelayGroup(link.groupId, link.relayUrl.url, inviteCode = link.code)
}
/**
* A shared Concord invite URL (`…/invite/<naddr>#<fragment>`). Cheap substring gates
* keep the parse off the hot path; the whole URL (fragment included) is carried into
* the route so the redeem flow still has the unlock token.
*/
private fun concordInviteRoute(uri: String): Route? =
if (uri.contains("/invite/") && uri.contains('#') && ConcordActions.parseInviteLink(uri) != null) {
Route.ConcordInvite(uri)
} else {
null
}
/**
* A Buzz workspace invite (`https://<host>/invite/<token>`) — a plain `/invite/` https URL with
* no fragment, so disjoint from the Concord shape above. Opens the in-app join flow
* ([Route.BuzzInvite]) instead of the external browser so the claim signs with the user's key.
*/
private fun buzzInviteRoute(uri: String): Route? =
if (uri.contains("/invite/") && BuzzInviteLink.parse(uri) != null) {
Route.BuzzInvite(uri)
} else {
null
}
@@ -59,7 +59,6 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
@@ -80,6 +79,8 @@ import com.vitorpamplona.amethyst.commons.resources.lightning_invoice
import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.components.BechLink
import com.vitorpamplona.amethyst.commons.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
@@ -102,8 +103,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
@@ -122,8 +121,6 @@ fun EditPostView(
val postViewModel: EditPostViewModel = viewModel()
postViewModel.init(accountViewModel)
val context = LocalContext.current
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
@@ -280,7 +277,7 @@ fun EditPostView(
accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ ->
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context, stripMetadata)
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, accountViewModel.host.mediaUploader, stripMetadata)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
@@ -41,17 +40,19 @@ import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.service.upload.MediaUploadTracker
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
@@ -170,11 +171,11 @@ open class EditPostViewModel : ViewModel() {
isPrivate: Boolean = false,
server: ServerName,
onError: (String, String) -> Unit,
context: Context,
uploader: MediaUploader,
stripMetadata: Boolean = true,
) {
try {
uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context, stripMetadata)
uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, uploader, stripMetadata)
} catch (e: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
@@ -192,7 +193,7 @@ open class EditPostViewModel : ViewModel() {
isPrivate: Boolean = false,
server: ServerName,
onError: (String, String) -> Unit,
context: Context,
uploader: MediaUploader,
stripMetadata: Boolean = true,
) {
accountViewModel.launchSigner {
@@ -206,10 +207,10 @@ open class EditPostViewModel : ViewModel() {
myMultiOrchestrator.upload(
alt,
if (sensitiveContent) "" else null,
MediaCompressor.intToCompressorQuality(mediaQuality),
CompressorQuality.fromSlider(mediaQuality),
server,
myAccount,
context,
uploader,
useH265Codec,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
@@ -218,11 +219,12 @@ open class EditPostViewModel : ViewModel() {
if (results.allGood) {
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val uploaded = state.result
if (uploaded is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 =
myAccount.createNip95(
byteArray = state.result.bytes,
headerInfo = state.result.fileHeader,
byteArray = uploaded.bytes,
headerInfo = uploaded.fileHeader,
alt = alt,
contentWarningReason = if (sensitiveContent) "" else null,
)
@@ -232,29 +234,29 @@ open class EditPostViewModel : ViewModel() {
note?.let {
"nostr:" + it.toNEvent()
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
} else if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
IMetaTagBuilder(uploaded.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
hash(uploaded.fileHeader.hash)
size(uploaded.fileHeader.size)
uploaded.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
uploaded.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
uploaded.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
uploaded.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
uploaded.magnet?.let { magnet(it) }
uploaded.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
if (sensitiveContent) sensitiveContent("")
}.build()
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
state.result.url
uploaded.url
} else {
null
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -38,13 +37,15 @@ import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
@@ -100,13 +101,13 @@ open class NewMediaModel : ViewModel() {
): Boolean = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(url)
fun upload(
context: Context,
uploader: MediaUploader,
accountViewModel: AccountViewModel,
onSucess: () -> Unit,
onError: (String, String) -> Unit,
) {
try {
uploadUnsafe(context, accountViewModel, onSucess, onError)
uploadUnsafe(uploader, accountViewModel, onSucess, onError)
} catch (e: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
@@ -118,7 +119,7 @@ open class NewMediaModel : ViewModel() {
}
fun uploadUnsafe(
context: Context,
uploader: MediaUploader,
accountViewModel: AccountViewModel,
onSucess: () -> Unit,
onError: (String, String) -> Unit,
@@ -135,10 +136,10 @@ open class NewMediaModel : ViewModel() {
myMultiOrchestrator.upload(
caption,
if (sensitiveContent) "" else null,
MediaCompressor.intToCompressorQuality(mediaQualitySlider),
CompressorQuality.fromSlider(mediaQualitySlider),
serverToUse,
myAccount,
context,
uploader,
useH265Codec,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
@@ -46,7 +46,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -72,20 +71,20 @@ import com.vitorpamplona.amethyst.commons.resources.strip_metadata_label
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_description
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_label
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.commons.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.SettingSwitchItem
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -101,7 +100,6 @@ fun NewMediaView(
initialCaption: String = "",
) {
val account = accountViewModel.account
val context = LocalContext.current
val scrollState = rememberScrollState()
@@ -130,7 +128,7 @@ fun NewMediaView(
onClose()
},
onPost = {
postViewModel.upload(context, accountViewModel, onClose, accountViewModel.toastManager::toast)
postViewModel.upload(accountViewModel.host.mediaUploader, accountViewModel, onClose, accountViewModel.toastManager::toast)
postViewModel.selectedServer?.let {
account.settings.changeDefaultFileServer(it)
}
@@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.ui.text.MENTION_REGEX
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
import kotlin.coroutines.cancellation.CancellationException
class UrlUserTagOutputTransformation(
@@ -76,16 +77,17 @@ class UrlUserTagOutputTransformation(
}
}
// The same detector the note renderer links with, so the editor highlights what the posted
// note will make clickable.
private fun TextFieldBuffer.highlightUrls(color: Color) {
val text = asCharSequence().toString()
val urlPattern = android.util.Patterns.WEB_URL
val matcher = urlPattern.matcher(text)
while (matcher.find()) {
addStyle(
SpanStyle(color = color, textDecoration = TextDecoration.None),
matcher.start(),
matcher.end(),
)
var searchFrom = 0
UrlDetector(text).detect().forEach { url ->
val start = text.indexOf(url.originalUrl, searchFrom)
if (start >= 0) {
val end = start + url.originalUrl.length
addStyle(SpanStyle(color = color, textDecoration = TextDecoration.None), start, end)
searchFrom = end
}
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -53,7 +52,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@@ -83,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.resources.blossom_import_sources_secti
import com.vitorpamplona.amethyst.commons.resources.blossom_import_start_button
import com.vitorpamplona.amethyst.commons.resources.blossom_import_title
import com.vitorpamplona.amethyst.commons.resources.delete_media_server
import com.vitorpamplona.amethyst.commons.ui.components.rememberLongNotice
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
@@ -123,7 +122,7 @@ fun BlossomImportScreen(
// kind-10063 list arrives from a relay just after the screen opens (common on cold start).
val targetServers by accountViewModel.account.blossomServers.flow
.collectAsStateWithLifecycle()
val context = LocalContext.current
val longNotice = rememberLongNotice()
Scaffold(
topBar = {
@@ -254,9 +253,7 @@ fun BlossomImportScreen(
when (vm.importSelected()) {
is ImportStart.Started -> nav.popBack()
ImportStart.Busy ->
Toast
.makeText(context, blossomImportBusyStr, Toast.LENGTH_LONG)
.show()
longNotice.show(blossomImportBusyStr)
ImportStart.Empty -> {}
}
},
@@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.commons.resources.file_server
import com.vitorpamplona.amethyst.commons.resources.file_server_description
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsRow
import kotlinx.collections.immutable.toImmutableList
@Composable
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.actions.uploads
import android.content.Context
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.no_camera_app_found
import com.vitorpamplona.amethyst.commons.resources.record_a_video
import com.vitorpamplona.amethyst.commons.resources.take_a_picture
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
@@ -49,10 +49,12 @@ import com.vitorpamplona.amethyst.commons.resources.uploading_state_hashing
import com.vitorpamplona.amethyst.commons.resources.uploading_state_ready
import com.vitorpamplona.amethyst.commons.resources.uploading_state_server_processing
import com.vitorpamplona.amethyst.commons.resources.uploading_state_uploading
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
@Composable
fun UploadProgressIndicator(
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.call
import android.app.Activity
import android.media.projection.MediaProjectionManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@@ -71,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.resources.call_incoming
import com.vitorpamplona.amethyst.commons.resources.call_incoming_video
import com.vitorpamplona.amethyst.commons.resources.call_incoming_voice
import com.vitorpamplona.amethyst.commons.resources.call_reject
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.call.session.CallSession
@@ -98,7 +98,7 @@ fun CallScreen(
val emptyStringFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<String?>(null) }
val errorMessage by (callSession?.errorMessage ?: emptyStringFlow).collectAsState()
BackHandler(enabled = callState !is CallState.Idle && callState !is CallState.Ended) {
PlatformBackHandler(enabled = callState !is CallState.Idle && callState !is CallState.Ended) {
scope.launch { callManager.hangup() }
}
@@ -39,9 +39,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
@@ -58,21 +56,20 @@ import com.vitorpamplona.amethyst.commons.resources.no_zap_amount_setup_long_pre
import com.vitorpamplona.amethyst.commons.resources.thank_you
import com.vitorpamplona.amethyst.commons.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.commons.ui.components.ZapButtonCallbacks
import com.vitorpamplona.amethyst.commons.ui.components.ZapButtonConfig
import com.vitorpamplona.amethyst.commons.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.ZapIcon
import com.vitorpamplona.amethyst.commons.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.navigateToReloadMint
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
@@ -83,35 +80,14 @@ import kotlinx.coroutines.launch
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Configuration for zap button behavior and appearance
*/
data class ZapButtonConfig(
val grayTint: Color = Color.Gray,
val iconSize: Dp = Size35dp,
val iconSizeModifier: Modifier = Size20Modifier,
val animationModifier: Modifier = Size14Modifier,
val showUserFinderSubscription: Boolean = false,
val zapAmountChoices: List<Long>? = null,
val thankYouText: String? = null,
val buttonText: String? = null,
)
/**
* Callbacks for zap button events
*/
data class ZapButtonCallbacks(
val onZapComplete: ((Boolean) -> Unit)? = null,
)
@OptIn(ExperimentalUuidApi::class)
@Composable
fun ReusableZapButton(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
config: ZapButtonConfig = ZapButtonConfig(),
callbacks: ZapButtonCallbacks = ZapButtonCallbacks(),
config: ZapButtonConfig,
callbacks: ZapButtonCallbacks,
) {
val noWalletFoundStr = stringRes(Res.string.no_wallet_found)
var wantsToZap by remember { mutableStateOf<ImmutableList<Long>?>(null) }
@@ -297,8 +273,7 @@ fun ReusableZapButton(
val displayText =
when {
hasZapped -> config.thankYouText ?: stringRes(id = Res.string.thank_you)
config.buttonText != null -> config.buttonText
else -> stringRes(id = Res.string.donate_now)
else -> config.buttonText ?: stringRes(id = Res.string.donate_now)
}
Text(text = displayText)
@@ -20,14 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.content.MediaType
import androidx.compose.foundation.content.ReceiveContentListener
import androidx.compose.foundation.content.TransferableContent
import androidx.compose.foundation.content.consume
import androidx.compose.foundation.content.contentReceiver
import androidx.compose.foundation.content.hasMediaType
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.PaddingValues
@@ -60,6 +53,8 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri
import com.vitorpamplona.amethyst.commons.ui.components.imageContentReceiver
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
// COPIED FROM TEXT FIELD
@@ -72,7 +67,7 @@ fun ThinPaddingTextField(
state: TextFieldState,
modifier: Modifier = Modifier,
onTextChanged: (() -> Unit)? = null,
onContentReceived: ((Uri, String?) -> Unit)? = null,
onContentReceived: ((MediaUri, String?) -> Unit)? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
@@ -142,30 +137,7 @@ fun ThinPaddingTextField(
val contentModifier =
if (onContentReceived != null) {
modifier.contentReceiver(
object : ReceiveContentListener {
override fun onReceive(transferableContent: TransferableContent): TransferableContent? {
if (!transferableContent.hasMediaType(MediaType.Image)) {
return transferableContent
}
val remaining =
transferableContent.consume { item ->
val uri = item.uri
if (uri != null) {
onContentReceived(
uri,
transferableContent.clipEntry.clipData.description
.getMimeType(0),
)
true
} else {
false
}
}
return remaining
}
},
)
modifier.imageContentReceiver(onContentReceived)
} else {
modifier
}
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.components.DisplayFullNote
import com.vitorpamplona.amethyst.commons.ui.components.DisplayUser
import com.vitorpamplona.amethyst.commons.ui.fragmentHashtagOrNull
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.richtext.HashtagIcon
import com.vitorpamplona.amethyst.commons.ui.richtext.checkForHashtagWithIcon
@@ -48,7 +49,6 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.LoadedBechLink
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.fragmentHashtagOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
@@ -49,10 +49,10 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.MarkdownTextStyle
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.ui.theme.markdownStyle
import com.vitorpamplona.amethyst.commons.ui.uriToRoute
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent
import com.vitorpamplona.quartz.nip92IMeta.imetasByUrl
@@ -59,75 +59,153 @@ import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccoun
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.invalid_nip19_uri
import com.vitorpamplona.amethyst.commons.resources.invalid_nip19_uri_description
import com.vitorpamplona.amethyst.commons.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalScreenLayout
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.LocalTabReselectCoordinator
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.TabReselectCoordinator
import com.vitorpamplona.amethyst.commons.ui.navigation.findQueryParameterValue
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.apps.recommendations.ProfileAppRecommendationsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.articles.ArticlesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.backups.BackupConflictReviewScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.badges.BadgesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.badges.award.AwardBadgeScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.badges.profile.ProfileBadgesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.default.BookmarkListScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.podcasts.BookmarkedPodcastsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.browser.BrowserScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.AgentAttestationScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.AgentConsoleScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.AgentPersonaEditScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.AgentWorkBoardScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.BuzzCanvasScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.BuzzDmListScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.BuzzForumPostScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.BuzzInviteScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.BuzzNewDmScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.JobBoardScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.WorkflowRunBoardScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup.CordnGroupInfoScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup.CordnInvitationsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.geohashChat.GeohashTeleportScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelListScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteLinksScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupBrowseScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupDiscoveryScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.communities.CommunityScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.communities.list.CommunitiesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.communities.newCommunity.EditCommunityScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.communities.newCommunity.NewCommunityScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.contactList.ContactListUsersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.drafts.DraftListScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.emojipacks.browse.BrowseEmojiSetsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.emojipacks.display.EmojiPackScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.emojipacks.list.ListOfEmojiPacksScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.emojipacks.list.metadata.EmojiPackMetadataScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.emojipacks.membershipManagement.EmojiPackSelectionScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.emojipacks.membershipManagement.MyEmojiListScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.favorites.FavoriteAppsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.followPacks.list.FollowPacksScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.hunt.GeocacheHuntScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.hunt.NewGeocacheHuntScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.log.LogGeocacheFindScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geohash.GeoHashScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.gitRepositories.GitRepositoriesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.hashtag.HashtagScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.highlights.HighlightsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.interestSets.display.InterestSetScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.interestSets.list.ListOfInterestSetsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.interestSets.list.metadata.InterestSetMetadataScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.lists.display.lists.PeopleListScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.lists.display.packs.FollowPackScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.livestreams.LiveStreamsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.music.MusicPlaylistsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.music.MusicTracksScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.music.NewMusicPlaylistScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.napplets.ConnectedAppsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.newUser.ImportFollowListPickFollowsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nsites.NsitesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.pinnednotes.PinnedNotesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.PodcastEpisodesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.PodcastsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring.EditPodcastShowScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring.NewPodcastTrailerScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring.PodcastAuthoringScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.polls.PollsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.polls.results.PollResultsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.products.ProductsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.publicChats.PublicChatsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.ShowQRScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.redirect.LoadRedirectScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relay.RelayFeedScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relayauth.RelayAuthSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.RelayInformationScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.eventsync.EventSyncScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.nip43.RelayMembersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.vanish.VanishEventsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.AllSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.AudioVisualizerSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.BlockedUsersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.BottomBarSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.CallSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.DrawerSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.HiddenWordsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.HomeTabsSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.MessagesSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.MutedThreadsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.ProfileUiSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.ReactionsSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SecurityFiltersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SpammingUsersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.cordn.CordnCoordinatorsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.cordn.CordnHubScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.cordn.CordnKeyPackagesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.cordn.CordnLinkScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.nip46.Nip46SignerScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.softwareapps.SoftwareAppDetailScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.softwareapps.SoftwareAppsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.AddClinkDebitWalletScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.AddNwcWalletScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.AddWalletScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.CashuMintRecommendationsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.CashuMintsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.CashuWalletScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.CashuWalletSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.OnchainTransactionsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.ReloadMintScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.TopUpMintScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.WalletDetailScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.WalletReceiveScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.WalletScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.WalletSendScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.WalletTransactionsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.wizard.CashuWalletWizardScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.workouts.WorkoutsScreen
import com.vitorpamplona.amethyst.commons.ui.uriToRoute
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthPromptHost
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert
import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.bolt12Offers.Bolt12OffersScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomBlobManagerScreen
@@ -152,24 +230,10 @@ import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsQrScreen
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.backups.BackupConflictReviewScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadata.BookmarkGroupMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentAttestationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentConsoleScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentWorkBoardScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzCanvasScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzInviteScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.JobBoardScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.WorkflowRunBoardScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
@@ -179,7 +243,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail.CalendarEv
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnCreateGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnCreateMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupInfoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatsScreen
@@ -193,18 +256,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.minichat.MinichatScre
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteLinksScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupBrowseScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChannelListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupCreateScreen
@@ -213,55 +270,38 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.CommunitiesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.EditCommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.NewCommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabAccountWatcher
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabThemeWatcher
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FavoriteAppManifestPreloader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display.EmojiPackScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata.EmojiPackMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.FavoriteAppsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.NostrAppScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.FollowPacksScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.GeocachesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.create.NewGeocacheScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.detail.GeocacheDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.log.LogGeocacheFindScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitNewIssueScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryCodeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryIssuesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryPullsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.NewHighlightScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals.NewGoalScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.FollowPackMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.LiveStreamsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.LongsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.AddToMusicPlaylistSheet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicPlaylistScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicTrackScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.NestLobbyScreen
@@ -269,78 +309,39 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.PicturesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.EditPodcastShowScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodcastEpisodeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodcastTrailerScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.ProductsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.SendPaymentScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.PublicChatsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ScanQrImageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relayauth.RelayAuthSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions.ActiveSubscriptionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AudioVisualizerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MutedThreadsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ResourceUsageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnBackupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnCoordinatorsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnHubScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnKeyPackagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnLinkScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnMigrateScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ConnectedAppsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46SignerScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.UrlPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.UrlScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddClinkDebitWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddNwcWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainTransactionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.wizard.CashuWalletWizardScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.WebBookmarksScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.NewWorkoutScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.fitness.MyFitnessScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip84Highlights.parse.SharedHighlightParser
@@ -1337,34 +1338,3 @@ fun URI.findParameterValue(parameterName: String): String? =
Pair(name, value)
}?.firstOrNull { it.first == parameterName }
?.second
/**
* The value of [parameterName] in this URI's query string, without going through
* [java.net.URI].
*
* Needed because `java.net.URI` only exposes `rawQuery` for *hierarchical* URIs. A URI
* with a scheme and no `//` is **opaque** — everything after the colon is one
* scheme-specific part — so `URI("marmot:<hex>?account=npub1…").rawQuery` is null. That
* is the shape [com.vitorpamplona.amethyst.service.notifications.NotificationRoutes.marmotUri]
* produces, so every Marmot group notification silently lost its `?account=` and opened
* the group under whichever account happened to be current instead of switching first.
*
* Splitting on the first `?` gets the same answer for both shapes, and returns null for a
* bare `nevent1…` with no query at all.
*
* [com.vitorpamplona.quartz.utils.UriParser] reads an opaque query correctly too, and is the
* right tool when a URI is already known to be well-formed. It is not this one: it builds a
* [java.net.URI], which *throws* on anything that is not a legal URI. What arrives here comes
* from an exported, browsable scheme, so it can be any string at all, and every caller on the
* deep-link path treats an unreadable uri as "no route" rather than as a crash.
*/
fun String.findQueryParameterValue(parameterName: String): String? {
val query = substringAfter('?', "")
if (query.isEmpty()) return null
return query
.split('&')
.firstOrNull { it.substringBefore('=') == parameterName }
?.substringAfter('=', "")
?.ifEmpty { null }
}
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.LocalTabReselectCoordinator
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.rememberBottomBarSlot
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.LoggedInUserPictureDrawer
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
@@ -127,6 +127,11 @@ import com.vitorpamplona.amethyst.commons.resources.status_update
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.layouts.PermanentDrawerWidth
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.NavBarItemDef
import com.vitorpamplona.amethyst.commons.ui.navigation.drawer.DrawerSection
import com.vitorpamplona.amethyst.commons.ui.navigation.drawer.DrawerSectionVisibility
import com.vitorpamplona.amethyst.commons.ui.navigation.drawer.DrawerSections
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
@@ -157,8 +162,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.profileContentHeaderModifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.navigation.navs
import android.annotation.SuppressLint
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.runtime.Composable
@@ -272,7 +271,7 @@ class Nav(
}
}
@SuppressLint("RestrictedApi")
@Suppress("RestrictedApi")
override fun <T : Route> popUpTo(
route: Route,
klass: KClass<T>,
@@ -40,10 +40,10 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.lack_location_permissions
import com.vitorpamplona.amethyst.commons.resources.loading_location
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
/** The "around me" feed's subtitle: asks for location, then shows the city it resolved to. */
@OptIn(ExperimentalPermissionsApi::class)
@@ -22,40 +22,12 @@ package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.text.format.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Date/time formatters that respect the user's Android system settings:
* - Date order (dd/mm/yyyy, mm/dd/yyyy, yyyy-mm-dd…) is derived from the
* active [Locale] via [DateFormat.getBestDateTimePattern].
* - 12-hour vs 24-hour clock follows the system setting via
* [DateFormat.is24HourFormat] (which is the user's manual override on
* top of the locale default).
*
* All formatters here use Unicode LDML skeletons. `j` and `jm` are intentionally
* avoided — `j` only picks 12/24 hour from the *locale*, not the user override.
* We pick the time skeleton explicitly based on [DateFormat.is24HourFormat].
*/
private fun timeSkeleton(context: Context): String = if (DateFormat.is24HourFormat(context)) "Hm" else "hma"
private fun bestPattern(
context: Context,
skeletonBase: String,
includeTime: Boolean,
): SimpleDateFormat {
val locale = Locale.getDefault()
val skeleton = if (includeTime) skeletonBase + timeSkeleton(context) else skeletonBase
return SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale)
}
/** Locale-aware month + day + short time (e.g. "May 28, 14:32" / "28 May, 2:32 PM"). */
fun formatMonthDayTime(
epochSeconds: Long,
context: Context,
): String = bestPattern(context, "MMMd", includeTime = true).format(Date(epochSeconds * 1000L))
// Date/time formatters that respect the user's Android system settings: the date order comes
// from the active locale and the clock follows DateFormat.is24HourFormat (the user's manual
// override on top of the locale default). The shared month/day/time formatter lives in
// commonsUI (rememberMonthDayTimeFormatter).
/** Locale-aware medium date (e.g. "May 28, 2026" / "28 May 2026" / "28.05.2026"). */
fun formatMediumDate(
@@ -195,6 +195,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.commons.ui.note.elements.ShareOptionsBottomSheet
import com.vitorpamplona.amethyst.commons.ui.note.types.EditState
import com.vitorpamplona.amethyst.commons.ui.richtext.InLineIconRenderer
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.navigateToReloadMint
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
@@ -243,7 +244,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTargetsDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.paymentTargetStyleFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.commons.ui.note.DisplayBlankAuthor
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.note.buttons.CloseButton
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer
@@ -119,7 +120,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.util.showAmount
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
import kotlinx.collections.immutable.ImmutableList
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.polls
import android.annotation.SuppressLint
import android.text.format.DateFormat
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -164,7 +163,7 @@ fun PollDeadlinePicker(model: ShortNotePostViewModel) {
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
fun PollDeadlinePickerPreview() {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.polls
import android.annotation.SuppressLint
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -154,7 +153,7 @@ fun PollTypeSelector(
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
fun NewPollClosingPreview() {
@@ -90,6 +90,9 @@ import com.vitorpamplona.amethyst.commons.resources.strip_metadata_description
import com.vitorpamplona.amethyst.commons.resources.strip_metadata_label
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_description
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_label
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.note.CancelIcon
@@ -102,9 +105,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.ai.MLKitImageLabelService
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import kotlinx.collections.immutable.toImmutableList
@Composable
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -104,7 +103,7 @@ fun ZapPollConsensusThreshold(pollViewModel: ShortNotePostViewModel) {
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
fun ZapPollConsensusThresholdPreview() {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import android.annotation.SuppressLint
import android.text.format.DateFormat
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -164,7 +163,7 @@ fun ZapPollDeadlinePicker(model: ShortNotePostViewModel) {
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
fun ZapPollDeadlinePickerPreview() {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.IconButton
@@ -89,7 +88,7 @@ fun ZapPollOption(
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
fun ZapPollOptionPreview() {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -120,7 +119,7 @@ fun ZapPollVoteValueRange(pollViewModel: ShortNotePostViewModel) {
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
fun ZapPollVoteValueRangePreview() {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.nip22Comments
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
@@ -55,6 +54,12 @@ import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.service.upload.MediaUploadTracker
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.IAudience
@@ -66,13 +71,9 @@ import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.text.setTextAndPlaceCursorAtBeginning
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
@@ -787,11 +788,11 @@ open class CommentPostViewModel :
mediaQuality: Int,
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
stripMetadata: Boolean = true,
) {
try {
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, stripMetadata)
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, uploader, stripMetadata)
} catch (_: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
@@ -808,7 +809,7 @@ open class CommentPostViewModel :
mediaQuality: Int,
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
stripMetadata: Boolean = true,
) {
viewModelScope.launch(Dispatchers.IO) {
@@ -820,10 +821,10 @@ open class CommentPostViewModel :
myMultiOrchestrator.upload(
alt,
contentWarningReason,
MediaCompressor.intToCompressorQuality(mediaQuality),
CompressorQuality.fromSlider(mediaQuality),
server,
account,
context,
uploader,
stripMetadata = stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null,
@@ -832,30 +833,31 @@ open class CommentPostViewModel :
if (results.allGood) {
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
val uploaded = state.result
if (uploaded is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(uploaded.bytes, headerInfo = uploaded.fileHeader, alt, contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
"nostr:" + it.toNEvent()
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
} else if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
IMetaTagBuilder(uploaded.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
hash(uploaded.fileHeader.hash)
size(uploaded.fileHeader.size)
uploaded.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
uploaded.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
uploaded.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
uploaded.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
uploaded.magnet?.let { magnet(it) }
uploaded.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
@@ -863,7 +865,7 @@ open class CommentPostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
state.result.url
uploaded.url
} else {
null
}
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.note.nip22Comments
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
@@ -79,6 +78,10 @@ import com.vitorpamplona.amethyst.commons.resources.post_anonymously
import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind
import com.vitorpamplona.amethyst.commons.resources.zapraiser
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.commons.ui.note.BaseUserPicture
@@ -95,6 +98,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.AddZapraise
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.ZapRaiserRequest
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapToButton
import com.vitorpamplona.amethyst.commons.ui.note.nip22Comments.DisplayExternalId
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.commons.ui.platform.GeohashLocationPickerDialog
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -107,9 +111,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.components.getActivity
@@ -118,7 +119,6 @@ import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoad
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceFlap
import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceSheet
@@ -227,7 +227,7 @@ fun GenericCommentPostScreen(
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
BackHandler {
PlatformBackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
onUiThread { postViewModel.cancel() }
@@ -472,7 +472,7 @@ private fun GenericCommentPostBody(
accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata)
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, accountViewModel.host.mediaUploader, stripMetadata)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
@@ -34,6 +34,8 @@ import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.commons.ui.components.ZapButtonCallbacks
import com.vitorpamplona.amethyst.commons.ui.components.ZapButtonConfig
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.platform.NotePlatform
import com.vitorpamplona.amethyst.commons.ui.note.types.EditState
@@ -50,6 +52,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.VideoView as AppVi
import com.vitorpamplona.amethyst.ui.actions.EditPostView as AppEditPostView
import com.vitorpamplona.amethyst.ui.components.GifVideoView as AppGifVideoView
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview as AppLoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.ReusableZapButton as AppReusableZapButton
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView as AppZoomableContentView
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog as AppZoomableImageDialog
import com.vitorpamplona.amethyst.ui.components.rememberUrlPreviewState as AppRememberUrlPreviewState
@@ -584,6 +587,15 @@ object AndroidNotePlatform : NotePlatform {
nav = nav,
)
@Composable
override fun ReusableZapButton(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
config: ZapButtonConfig,
callbacks: ZapButtonCallbacks,
) = AppReusableZapButton(baseNote, accountViewModel, nav, config, callbacks)
@Composable
override fun EditPostView(
onClose: () -> Unit,
@@ -90,6 +90,9 @@ import com.vitorpamplona.amethyst.commons.resources.share_as_image
import com.vitorpamplona.amethyst.commons.resources.share_as_image_generating
import com.vitorpamplona.amethyst.commons.resources.share_as_image_url
import com.vitorpamplona.amethyst.commons.resources.share_as_image_watermark
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.components.LoadNote
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
@@ -98,10 +101,8 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackBu
import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -268,7 +269,7 @@ fun ShareNoteAsImageScreen(
compressionQuality = CompressorQuality.UNCOMPRESSED,
server = server,
account = account,
context = context,
uploader = accountViewModel.host.mediaUploader,
stripMetadata = false,
)
}
@@ -61,10 +61,10 @@ import com.vitorpamplona.amethyst.commons.resources.share_as_qr_mode_web
import com.vitorpamplona.amethyst.commons.ui.components.LoadNote
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.KeepScreenBrightAndAwake
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.KeepScreenBrightAndAwake
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_QUIET_ZONE_MODULES in QrCodeDrawer.kt) is a
// fixed pixel count subtracted from raw size.width, so its share of the tile grows as density
@@ -24,11 +24,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.favoriteCoordinateOf
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.StaticWebsiteCard
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher
@@ -68,7 +68,7 @@ fun RenderRootNappletEvent(
} else {
null
},
headerActions = { FavoriteToggleButton(FavoriteAppLauncher.coordinateOf(event), event.title() ?: "Napplet", event.icon()) },
headerActions = { FavoriteToggleButton(favoriteCoordinateOf(event), event.title() ?: "Napplet", event.icon()) },
)
}
@@ -98,7 +98,7 @@ fun RenderNamedNappletEvent(
} else {
null
},
headerActions = { FavoriteToggleButton(FavoriteAppLauncher.coordinateOf(event), event.title() ?: event.identifier(), event.icon()) },
headerActions = { FavoriteToggleButton(favoriteCoordinateOf(event), event.title() ?: event.identifier(), event.icon()) },
)
}
@@ -139,7 +139,7 @@ fun RenderRootSiteEvent(
} else {
null
},
headerActions = { FavoriteToggleButton(FavoriteAppLauncher.coordinateOf(event), event.title() ?: "nsite", event.icon()) },
headerActions = { FavoriteToggleButton(favoriteCoordinateOf(event), event.title() ?: "nsite", event.icon()) },
)
}
@@ -180,7 +180,7 @@ fun RenderNamedSiteEvent(
} else {
null
},
headerActions = { FavoriteToggleButton(FavoriteAppLauncher.coordinateOf(event), event.title() ?: event.identifier(), event.icon()) },
headerActions = { FavoriteToggleButton(favoriteCoordinateOf(event), event.title() ?: event.identifier(), event.icon()) },
)
}
@@ -20,19 +20,41 @@
*/
package com.vitorpamplona.amethyst.ui.platform
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.UriHandler
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.AppLauncher
import com.vitorpamplona.amethyst.commons.ui.platform.AppPlatform
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.ScanOutcome
import com.vitorpamplona.amethyst.commons.ui.settings.SettingsCategory
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.ui.navigation.topbars.AndroidAroundMeLocationLabel
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar as AppBottomBarImpl
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.AppBottomBar as AppBottomBarImpl
import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel as AppRememberManifestIconModel
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel as AppRememberNappletIconModel
import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel as AppRememberWebAppIconModel
import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerContent as AppGeohashLocationPickerContent
import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog as AppGeohashLocationPickerDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel as AppRememberConcordImageModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.QrCodeScannerDialog as AppQrCodeScannerDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.legalSettingsCategory as flavorLegalSettingsCategory
/** Android's [AppPlatform]: the app's own bottom bar and location label. */
/** Android's [AppPlatform]: the app's own shell pieces, camera scanner, app launcher and icon caches. */
object AndroidAppPlatform : AppPlatform {
override val isCastingAvailable: Boolean get() = BuildConfig.IS_CASTING_AVAILABLE
// Per flavour: Play links the hosted policies, F-Droid surfaces none.
override fun legalSettingsCategory(uriHandler: UriHandler): SettingsCategory? = flavorLegalSettingsCategory(uriHandler)
@Composable
override fun AppBottomBar(
selectedRoute: Route?,
@@ -58,4 +80,48 @@ object AndroidAppPlatform : AppPlatform {
onConfirm: (String) -> Unit,
modifier: Modifier,
) = AppGeohashLocationPickerContent(initialGeohash, confirmLabel, onConfirm, modifier)
@Composable
override fun QrCodeScannerDialog(
onDismiss: () -> Unit,
onScan: (String) -> ScanOutcome,
) = AppQrCodeScannerDialog(onDismiss, onScan)
@Composable
override fun rememberAppLauncher(): AppLauncher {
val context = LocalContext.current
return remember(context) { AndroidAppLauncher(context) }
}
@Composable
override fun rememberConcordImageModel(
pointer: ImagePointer?,
accountViewModel: AccountViewModel,
): String? = AppRememberConcordImageModel(pointer, accountViewModel)
@Composable
override fun rememberNappletIconModel(coordinate: String): String? = AppRememberNappletIconModel(coordinate)
@Composable
override fun rememberManifestIconModel(
author: String,
identifier: String,
): String? = AppRememberManifestIconModel(author, identifier)
@Composable
override fun rememberWebAppIconModel(url: String): String? = AppRememberWebAppIconModel(url)
}
private class AndroidAppLauncher(
private val context: Context,
) : AppLauncher {
override fun launch(
app: FavoriteApp,
stillLoading: String,
) = FavoriteAppLauncher.launch(context, app, stillLoading)
override fun launchUrl(
url: String,
preferTor: Boolean,
) = FavoriteAppLauncher.launchUrl(context, url, preferTor)
}
@@ -0,0 +1,52 @@
/*
* 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.ui.platform
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.service.AppServices
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
/**
* [AppServices] over the main process's app modules. Every member is a getter, so installing
* this object costs nothing and is safe in the `:napplet` process, where `Amethyst.instance` is
* never built, as long as nothing there reads it.
*/
object AndroidAppServices : AppServices {
override val favoriteApps: FavoriteAppsRegistry get() = Amethyst.instance.favoriteApps
override val browserHistory: BrowserHistoryRegistry get() = Amethyst.instance.browserHistory
override val browserIcons: BrowserIconRegistry get() = Amethyst.instance.browserIcons
override val nappletPermissionLedger: NappletPermissionLedger get() = Amethyst.instance.nappletPermissionLedger
override val signerPermissionStore: NostrSignerPermissionStore get() = Amethyst.instance.signerPermissionStore
override val torSettings: TorSettingsFlow get() = Amethyst.instance.torPrefs.value
override val namecoinResolver: NamecoinNameResolver get() = Amethyst.instance.namecoinResolver
}
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.UserCompose
import com.vitorpamplona.amethyst.commons.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
@@ -48,6 +47,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.currentBackStackEntryAsState
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalScreenLayout
import com.vitorpamplona.amethyst.commons.ui.layouts.NavigationStyle
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
@@ -182,7 +182,7 @@ private fun ModalDrawerShell(
gesturesEnabled = drawerGesturesEnabled,
drawerContent = {
DrawerContent(nav, openSheet, accountViewModel)
BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer)
PlatformBackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer)
},
content = {
if (showRail) {
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_save_the_video
import com.vitorpamplona.amethyst.commons.resources.video_saved_to_the_gallery
import com.vitorpamplona.amethyst.commons.service.isVideoOnline
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -65,8 +66,8 @@ suspend fun AccountViewModel.checkVideoIsOnline(videoUrl: String): Boolean = htt
suspend fun AccountViewModel.uploadMarmotGroupIcon(
uri: Uri,
mimeType: String?,
context: Context,
): MarmotGroupIconUpload = MarmotGroupIconUploader(account).upload(uri, mimeType, account.settings.defaultFileServer, context)
uploader: MediaUploader,
): MarmotGroupIconUpload = MarmotGroupIconUploader(account).upload(uri, mimeType, account.settings.defaultFileServer, uploader)
fun AccountViewModel.saveMediaToGallery(
videoUri: String?,
@@ -28,17 +28,24 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.service.lnurl.LnurlHttpTransport
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlTransport
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobFailure
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.tor.MoneyOpRelayRouting
import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModelHost
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismissNotificationForEvent
import com.vitorpamplona.amethyst.service.uploads.AndroidMediaUploader
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthSnapshot
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import kotlinx.collections.immutable.PersistentMap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
@@ -60,6 +67,13 @@ class AndroidAccountViewModelHost(
override val moneyOpRelays: MoneyOpRelayRouting get() = modules.torEvaluatorFlow
override val torRelayEvaluation: StateFlow<TorRelayEvaluation> get() = modules.torEvaluatorFlow.flow
override val relayAuthState: StateFlow<PersistentMap<NormalizedRelayUrl, RelayAuthSnapshot>>
get() = modules.authCoordinator.receiver.authStateFlow
override val mediaUploader: MediaUploader by lazy { AndroidMediaUploader(modules.appContext) }
override val savedAccounts: Flow<Set<HexKey>> =
LocalPreferences
.accountsFlow()
@@ -26,32 +26,32 @@ import androidx.compose.runtime.key
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.emojipacks.browse.datasource.BrowseEmojiSetsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.followPacks.list.datasource.FollowPacksFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.datasource.GeocachesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.highlights.datasource.HighlightsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.polls.datasource.PollsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.products.datasource.ProductsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.publicChats.datasource.PublicChatsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.workouts.datasource.WorkoutsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasource.FollowPacksFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.PublicChatsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription
@@ -96,6 +96,15 @@ import com.vitorpamplona.amethyst.commons.resources.backup_conflict_title
import com.vitorpamplona.amethyst.commons.resources.backup_review_changed_by_other_app
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.backups.BackupConflictReviewScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.backups.BarSegment
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.backups.ConflictCounts
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.backups.SplitBar
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.backups.StatusTag
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.backups.clip
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.backups.conflictTones
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.backups.countsOf
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.backups.eventTypeName
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
@@ -29,7 +28,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
@@ -60,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.commons.ui.components.rememberTextSharer
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeEditDraftTo
@@ -171,7 +170,7 @@ fun BookmarkGroupItemOptionsMenu(
onDismiss = onDismiss,
) {
val clipboardManager = LocalClipboard.current
val actContext = LocalContext.current
val sharer = rememberTextSharer()
val scope = rememberCoroutineScope()
// Bookmark Management section
@@ -240,23 +239,7 @@ fun BookmarkGroupItemOptionsMenu(
}
}
M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(Res.string.quick_action_share)) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note),
)
putExtra(
Intent.EXTRA_TITLE,
quickActionShareBrowserLinkStr,
)
}
val shareIntent =
Intent.createChooser(sendIntent, quickActionShareStr)
actContext.startActivity(shareIntent)
sharer.share(externalLinkForNote(note), quickActionShareBrowserLinkStr, quickActionShareStr)
onDismiss()
}
}
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.resources.follow_set_edit_list_metadat
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_sign_events
import com.vitorpamplona.amethyst.commons.resources.optional_picture_url
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.CreatingTopBar
@@ -62,7 +63,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -39,15 +39,15 @@ import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_title
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_upload_cancelled
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -24,7 +24,6 @@ import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
@@ -74,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.browser_unsupported
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -299,9 +299,9 @@ private fun EmbeddedWebAppTab(
}
val isFullscreen by controller.isFullscreen
BackHandler(enabled = canGoBack && !isFullscreen) { controller.back() }
PlatformBackHandler(enabled = canGoBack && !isFullscreen) { controller.back() }
// A fullscreen video inside the tab: back leaves fullscreen first, as in Chrome.
BackHandler(enabled = isFullscreen) { controller.exitFullscreen() }
PlatformBackHandler(enabled = isFullscreen) { controller.exitFullscreen() }
EmbeddedPageUi(controller, chrome.ui, showPageInfo, onPageInfoDismiss = { showPageInfo = false })
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.relayClient.channel.relayGroup.RELAY_GROUP_METADATA_KINDS
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.reconnectPoolAfterJoin
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
@@ -54,13 +54,13 @@ import com.vitorpamplona.amethyst.commons.resources.settings_section_reminders
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsBlockTile
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsDivider
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsSwitchTile
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettings
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSwitchTile
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@@ -275,7 +275,7 @@ private fun ImageRow(
if (uri == null) return@rememberLauncherForActivityResult
val mime = context.contentResolver.getType(uri)
scope.launch {
val ok = vm.uploadAndSetImage(uri, mime, context)
val ok = vm.uploadAndSetImage(uri, mime, accountViewModel.host.mediaUploader)
if (!ok) {
accountViewModel.toastManager.toast(
Res.string.calendar_event_image_upload_failed,
@@ -20,17 +20,17 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.parseIsoDateToUnixSeconds
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
@@ -157,7 +157,7 @@ class NewCalendarEventViewModel : ViewModel() {
suspend fun uploadAndSetImage(
uri: Uri,
mimeType: String?,
context: Context,
uploader: MediaUploader,
): Boolean {
if (!::account.isInitialized) return false
isUploadingImage.value = true
@@ -172,7 +172,7 @@ class NewCalendarEventViewModel : ViewModel() {
compressionQuality = CompressorQuality.MEDIUM,
server = server,
account = account,
context = context,
uploader = uploader,
)
val serverResult =
(result as? UploadingState.Finished)?.result as? UploadOrchestrator.OrchestratorResult.ServerResult
@@ -96,13 +96,13 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
import com.vitorpamplona.amethyst.commons.ui.note.types.CalendarRsvpRow
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size30dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.addToPhoneCalendar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestio
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cordn_composer_hint
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
@@ -103,6 +103,9 @@ import com.vitorpamplona.amethyst.commons.resources.cordn_voice_record
import com.vitorpamplona.amethyst.commons.resources.cordn_voice_stop
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
@@ -116,10 +119,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.model.cordn.CordnMediaService
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessageRecorder
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWaveformPlayer
@@ -141,6 +142,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
/**
* One cordn room.
@@ -518,7 +520,7 @@ private fun CordnGroupChat(
attaching = true
attachError = null
try {
sendAttachment(context, accountViewModel, room, uploadState)
sendAttachment(context, accountViewModel.host.mediaUploader, accountViewModel, room, uploadState)
// Only on success: a failed upload leaves the dialog up
// with what you picked still in it, so retrying is one
// tap rather than the picker again.
@@ -1006,6 +1008,7 @@ private class CordnAttachmentException(
*/
private suspend fun sendAttachment(
context: Context,
uploader: MediaUploader,
accountViewModel: AccountViewModel,
room: CordnGroupChatroom,
state: ChatFileUploadState,
@@ -1039,20 +1042,17 @@ private suspend fun sendAttachment(
try {
// The media-quality slider.
//
// Off the main thread, like the strip and the read below it.
// Off the main thread, like the strip and the read below it:
// `sendAttachment` is called from the composition's scope, so it
// inherits Main, and `MediaCompressor.compress` opens with
// `checkNotInMainThread()` — so every image attachment threw
// `OnMainThreadException` before it ever reached the uploader. The
// voice path never hit it because it posts its recording already
// encoded and skips compression entirely.
// inherits Main, and compression blocks. The voice path posts its
// recording already encoded and skips compression entirely.
val compressed =
withContext(Dispatchers.IO) {
item.orchestrator.compressIfNeeded(
uri = uri,
mimeType = declaredMime,
compressionQuality = MediaCompressor.intToCompressorQuality(state.mediaQualitySlider),
context = context,
compressionQuality = CompressorQuality.fromSlider(state.mediaQualitySlider),
uploader = uploader,
)
}
val mime = compressed.contentType ?: declaredMime
@@ -1095,8 +1095,8 @@ private suspend fun sendAttachment(
// copies of a message that is end-to-end encrypted everywhere else — the
// same reason the voice path deletes its recording. Both calls no-op on the
// user's own file.
item.orchestrator.deleteTempUri(finalUri, uri)
item.orchestrator.deleteTempUri(compressed.uri, uri)
deleteTempFile(finalUri, uri)
if (compressed.uri != finalUri) deleteTempFile(compressed.uri, uri)
}
} finally {
// Always, not just on success: a failure leaves the dialog up to retry from,
@@ -1334,3 +1334,18 @@ private suspend fun sendVoiceNote(
withContext(Dispatchers.IO) { recording.file.delete() }
}
}
/** Deletes a pipeline temp file; a no-op on the user's own file, which is [originalUri]. */
private fun deleteTempFile(
tempUri: Uri,
originalUri: Uri,
) {
if (tempUri == originalUri) return
val path = tempUri.path ?: return
try {
val file = File(path)
if (file.exists() && !file.delete()) Log.w("CordnGroupChat") { "Could not delete temp file $path" }
} catch (e: Exception) {
Log.w("CordnGroupChat", "Failed to delete temp file $path", e)
}
}
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.ShareOptionsBottomShe
import com.vitorpamplona.amethyst.commons.ui.note.elements.noteActionSections
import com.vitorpamplona.amethyst.commons.ui.note.elements.observeBookmarksFollowsAndAccount
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.navigateToReloadMint
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
@@ -106,7 +107,6 @@ import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoiceGrid
import com.vitorpamplona.amethyst.ui.note.observeZapRailCapability
import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
@@ -73,9 +73,9 @@ import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SearchTopBarAction
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatActingIdentities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatDisplayNameResolver
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatReactOverride
@@ -69,16 +69,16 @@ import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The dedicated top-level list of the user's joined Bitchat-interoperable location channels — the
* geohash-chat analogue of [com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.PublicChatsScreen]
* geohash-chat analogue of [com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.publicChats.PublicChatsScreen]
* (NIP-28) and the Relay Groups / Concord home screens. Reached from the drawer's "Feeds" section and
* pinnable as a bottom-bar tab. Each row opens the cell's chat; the "+" opens [NewGeohashChatScreen] to
* join more (near me / manual / teleport).

Some files were not shown because too many files have changed in this diff Show More