mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
Merge remote-tracking branch 'origin/main' into claude/hopeful-brown-1suxdw
Keeps both member-screen confirmations (main's Ban confirm, this branch's Kick confirm). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-3
@@ -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()
|
||||
|
||||
/**
|
||||
|
||||
+1
-73
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
-2
@@ -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,
|
||||
|
||||
+2
@@ -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] /
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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])
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+595
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-29
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
-602
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
|
||||
+1
-6
@@ -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 {
|
||||
|
||||
+2
-7
@@ -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)
|
||||
}
|
||||
|
||||
+11
-9
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-6
@@ -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 -> {}
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+4
-2
@@ -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() }
|
||||
}
|
||||
|
||||
|
||||
+6
-31
@@ -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)
|
||||
|
||||
+4
-32
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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 }
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+5
-2
@@ -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>,
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+4
-32
@@ -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
|
||||
|
||||
+1
-2
@@ -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() {
|
||||
|
||||
+1
-2
@@ -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() {
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+1
-2
@@ -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() {
|
||||
|
||||
+1
-2
@@ -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() {
|
||||
|
||||
+1
-2
@@ -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() {
|
||||
|
||||
+1
-2
@@ -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() {
|
||||
|
||||
+26
-24
@@ -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
|
||||
}
|
||||
|
||||
+7
-7
@@ -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,
|
||||
|
||||
+12
@@ -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,
|
||||
|
||||
+5
-4
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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()) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+68
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+3
-2
@@ -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?,
|
||||
|
||||
+14
@@ -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()
|
||||
|
||||
+7
-7
@@ -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
|
||||
|
||||
|
||||
+9
@@ -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
|
||||
|
||||
+3
-20
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+3
-3
@@ -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 })
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+28
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -95,6 +95,7 @@ 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.chats.publicChannels.concord.ConcordExpiringPinDialog
|
||||
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
|
||||
@@ -108,7 +109,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.concord.cord03Channels.ConcordDisappearing
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
Reference in New Issue
Block a user