Merge remote-tracking branch 'origin/main' into ccr-7900824a-sqkcsw

# Conflicts:
#	commonsUI/src/commonMain/composeResources/values/strings.xml
This commit is contained in:
Claude
2026-10-01 16:04:51 +00:00
614 changed files with 14777 additions and 5052 deletions
@@ -4,8 +4,8 @@ Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexabl
expression. **Update this file in the same PR as any change to the searchable set or to an
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-09-17.
Counts: 137 concrete classes covering 140 kind values (`GitStatusEvent` spans 4 kinds;
kind 30063 is shared by two NIPs — see the footnote). File paths are under
Counts: 139 concrete classes covering 140 kind values (`GitStatusEvent` spans 4 kinds;
kind 30063 is shared by two NIPs and kind 38000 by three classes — see the footnotes). File paths are under
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
@@ -134,7 +134,9 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 36787 | MusicTrackEvent | experimental/music/track | `listOfNotNull(title(), artist(), album(), content)` NL |
| 37516 | GeocacheListingEvent | nipCCGeocaching/listing | `listOfNotNull(cacheName(), content)` NL (the `hint` is deliberately not indexed — matching a hint is spoiling it) |
| 37517 | GeocacheCurationListEvent | nipCCGeocaching/curation | `listOfNotNull(title(), description(), content)` NL |
| 38000 | MintRecommendationEvent | nip87Ecash/recommendation | `content` |
| 38000 | MintRecommendationEvent ‡ | nip87Ecash/recommendation | `content` |
| 38000 | BallotEvent ‡ | experimental/ballots | `(listOfNotNull(election()) + answers().map { it.answer })` NL |
| 38000 | PredictionMarketEvent ‡ | experimental/predictionMarkets | `listOfNotNull(title(), description())` NL |
| 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL |
| 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) |
| 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL |
@@ -155,6 +157,15 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
(the release notes) only when the event carries the NIP-82 `i` + `version` tags — a NIP-51 set
may hold encrypted private items in `content`, which must never be indexed.
‡ **Kind 38000 is shared** by NIP-87 mint recommendations and two app formats, and `EventFactory`
picks the class by tags: `MintRecommendationEvent` when any `k` is 38172/38173 (or, with no
`k`, a non-blank `u` or an `a` to a 38172/38173 address); else `BallotEvent` on a non-blank
`election`; else `PredictionMarketEvent` on a `market`, ≥2 `outcome`s, or `type` + `end`; else
`UnrecognizedKind38000Event` — addressable (stores still key it by `d`) but unsearchable (the
spam votes that make up most of the kind). Kind-level probes (`EventFactory.probe`) answer as
`MintRecommendationEvent`. A market's `title()`/`description()` come from its `data` tag JSON,
the `title` tag, or JSON `content`, in that order.
## Abstract bases (no kind of their own)
| Base class | Body | Concrete kinds |
+7 -7
View File
@@ -28,7 +28,7 @@ jobs:
run: .claude/hooks/compose_escaping_check.py
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: 21
@@ -84,7 +84,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: 21
@@ -176,7 +176,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: 21
@@ -226,7 +226,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: 21
@@ -281,7 +281,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: 21
@@ -363,7 +363,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: 21
@@ -409,7 +409,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: 21
+4 -4
View File
@@ -78,7 +78,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: 21
@@ -405,7 +405,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: 21
@@ -662,7 +662,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: 21
@@ -953,7 +953,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: 21
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
fetch-depth: 0
- name: crowdin action
uses: crowdin/github-action@v2
uses: crowdin/github-action@v3
with:
upload_sources: true
upload_translations: true
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: 21
@@ -66,7 +66,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: 21
@@ -24,6 +24,8 @@ import android.os.Environment
import androidx.core.content.FileProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.getPhotoUri
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.getVideoUri
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
@@ -22,14 +22,15 @@ package com.vitorpamplona.amethyst.service.ai
import android.content.Context
import android.net.Uri
import com.vitorpamplona.amethyst.commons.service.ai.AltTextSuggester
@Suppress("UNUSED_PARAMETER")
class MLKitImageLabelService(
context: Context,
) {
) : AltTextSuggester {
suspend fun labelImage(uri: Uri): List<Pair<String, Float>> = emptyList()
suspend fun suggestAltText(uri: Uri): String? = null
override suspend fun suggestAltText(uri: Uri): String? = null
fun close() {}
override fun close() {}
}
+24
View File
@@ -90,6 +90,11 @@
<action android:name="android.intent.action.VIEW" />
<data android:scheme="tron" />
</intent>
<!-- Media apps (they expose a MediaBrowserService), so the now-playing settings can show
the name of the app playing instead of its package name. -->
<intent>
<action android:name="android.media.browse.MediaBrowserService" />
</intent>
</queries>
@@ -556,6 +561,25 @@
</intent-filter>
</service>
<!-- Reads other apps' media sessions to share what the user listens to as a NIP-38 music
status. Android only exposes other apps' sessions to an enabled notification listener;
this one never reads notifications. The system binds it only after the user grants
"Notification access", and it stays in the main process with the account. -->
<service
android:name=".service.nowPlaying.OtherAppsNowPlayingService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
<!-- Media sessions do not depend on which notifications are delivered, so ask for the
narrowest type: fewer wakeups, and the access screen shows what is actually read. -->
<meta-data
android:name="android.service.notification.default_filter_types"
android:value="ongoing" />
</service>
<service
android:name=".service.call.CallForegroundService"
android:foregroundServiceType="microphone|camera|phoneCall|mediaProjection"
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore
import com.vitorpamplona.amethyst.commons.model.preferences.ConcordDirectInviteDeclineStore
import com.vitorpamplona.amethyst.commons.model.preferences.DrawerSectionCollapsePreferences
import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.NowPlayingSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupAdminCacheStore
import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupDeletionStore
@@ -111,6 +112,7 @@ import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServ
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
import com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.nowPlaying.NowPlayingStatusCoordinator
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
@@ -1073,6 +1075,8 @@ class AppModules(
BuzzAttestationStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzAttestation)
// Concord Direct Invites the user declined (CORD-05 §6) stay declined across restarts.
ConcordDirectInviteDeclineStore(sharedSettingsStore, account.scope, account.pubKey, account.concord.directInviteInbox)
// Whether this account shares what it listens to as its music status, and from which apps.
NowPlayingSettingsStore(sharedSettingsStore, account.scope, account.pubKey, account.nowPlayingSettings)
},
)
@@ -1096,6 +1100,15 @@ class AppModules(
accountFlow = sessionManager.accountContent.map { (it as? AccountState.LoggedIn)?.account },
).also { it.start() }
// Shares what the user listens to (in the app, or in other apps once notification access is
// granted) as the logged-in account's NIP-38 music status. Off until the account opts in.
val nowPlayingStatusCoordinator =
NowPlayingStatusCoordinator(
scope = applicationIOScope,
accountFlow = sessionManager.accountContent.map { (it as? AccountState.LoggedIn)?.account },
relayServices = relayProxyClientConnector.relayServices,
).also { it.start() }
fun subscribedFlow(
address: Address,
account: Account,
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.marmot.EncryptedMarmotMessageStore
import com.vitorpamplona.amethyst.commons.marmot.EncryptedMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.marmot.EncryptedPublishObligationStore
import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.model.AMETHYST_CLIENT_TAG_NAME
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.AccountSettings
import com.vitorpamplona.amethyst.commons.model.GeohashIdentityStore
@@ -419,7 +420,7 @@ class AccountCacheState(
}
companion object {
const val CLIENT_TAG_NAME = "Amethyst"
const val CLIENT_TAG_NAME = AMETHYST_CLIENT_TAG_NAME
}
}
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
@@ -38,7 +38,7 @@ fun resolveNappletMeta(
untitled: String,
): Pair<String, String?> {
val events =
Amethyst.instance.cache
LocalCache
.filter(
Filter(
kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND, RootSiteEvent.KIND, NamedSiteEvent.KIND),
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.tor.MoneyOpRelayRouting
import com.vitorpamplona.quartz.experimental.clink.client.OfferClient
import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent
import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse
@@ -57,6 +57,7 @@ object ClinkOfferPayer {
*/
suspend fun requestInvoice(
account: Account,
moneyOpRelays: MoneyOpRelayRouting,
offer: NOffer,
amountSats: Long? = null,
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
@@ -103,7 +104,7 @@ object ClinkOfferPayer {
// pushing the payment through Tor (and failing on services that block Tor exits) even when
// the user disabled Tor for money operations. The subscribe() below triggers a reconnect, and
// BasicRelayClient rebuilds any socket left on the now-wrong (Tor) transport onto clearnet.
val torState = Amethyst.instance.torEvaluatorFlow
val torState = moneyOpRelays
torState.registerMoneyOpRelays(relays)
account.client.subscribe(subId, filters, listener)
try {
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.location
import android.content.Context
import android.location.Location
import android.location.LocationManager
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision
@@ -75,7 +76,7 @@ class LocationState(
LocationFlow(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager)
.get(minTimeMs, minDistanceM, onListening)
},
) {
) : DeviceLocation {
companion object {
/** A 5 km cell takes 2.5 minutes to cross at 120 km/h; 60s/500m is ample. */
const val COARSE_MIN_TIME: Long = 60_000L
@@ -111,7 +112,7 @@ class LocationState(
private val latestPreciseLocation = MutableStateFlow<LocationResult>(LocationResult.Loading)
fun setLocationPermission(newValue: Boolean) {
override fun setLocationPermission(newValue: Boolean) {
if (newValue != hasLocationPermission.value) {
hasLocationPermission.tryEmit(newValue)
}
@@ -195,7 +196,7 @@ class LocationState(
}
}.stateIn(scope, SharingStarted.WhileSubscribed(SUBSCRIPTION_STOP_TIMEOUT_MS), cache.value)
val geohashStateFlow: StateFlow<LocationResult> by lazy {
override val geohashStateFlow: StateFlow<LocationResult> by lazy {
buildGeohashStateFlow(
tag = "GeohashStateFlow",
charsCount = GeohashPrecision.KM_5_X_5.digits,
@@ -217,7 +218,7 @@ class LocationState(
* building-level today. The profile is kept so the intent survives if the
* app ever requests `ACCESS_FINE_LOCATION`.
*/
val preciseGeohashStateFlow: StateFlow<LocationResult> by lazy {
override val preciseGeohashStateFlow: StateFlow<LocationResult> by lazy {
buildGeohashStateFlow(
tag = "PreciseGeohashStateFlow",
charsCount = GeohashChannelLevel.BUILDING.chars,
@@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.notifications.NotificationContent
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_message
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_message_highlight
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.notifications.NotificationContent
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_issue
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_patch
@@ -41,7 +42,6 @@ import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channe
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_open
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
@@ -24,8 +24,8 @@ import android.content.Context
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conversation
@@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.notifications.NotificationContent
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_message_photo
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_message_video
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
@@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.notifications.NotificationContent
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_channel_message
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
@@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.notifications.NotificationContent
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_message
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_message_for
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
@@ -24,11 +24,11 @@ import android.content.Context
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.notifications.NotificationContent
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_me
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.InlineReplyTarget
@@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.notifications.NotificationContent
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_channel_message
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.notifications.NotificationContent
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_nutzap_channel_message_from
import com.vitorpamplona.amethyst.commons.resources.app_notification_onchain_channel_message_from
@@ -34,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channe
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.util.showAmount
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
@@ -0,0 +1,66 @@
/*
* 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.nowPlaying
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.MonogramBadge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** The launcher icon of [packageName] for the now-playing app list, or a monogram if it is hidden. */
@Composable
fun AndroidAppIcon(
packageName: String,
label: String,
) {
val context = LocalContext.current
val icon by produceState<ImageBitmap?>(null, packageName) {
value =
withContext(Dispatchers.IO) {
runCatching {
context.packageManager
.getApplicationIcon(packageName)
.toBitmap(ICON_PX, ICON_PX)
.asImageBitmap()
}.getOrNull()
}
}
val current = icon
if (current != null) {
Image(bitmap = current, contentDescription = null, modifier = Modifier.fillMaxSize())
} else {
MonogramBadge(label, size = 36, shape = RoundedCornerShape(10.dp))
}
}
private const val ICON_PX = 108
@@ -0,0 +1,33 @@
/*
* 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.nowPlaying
import android.content.Context
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingAccess
/** Notification access, which Android requires before it shares other apps' media sessions. */
class AndroidNowPlayingAccess(
private val context: Context,
) : NowPlayingAccess {
override fun hasAccess() = OtherAppsNowPlayingService.hasAccess(context)
override fun requestAccess() = OtherAppsNowPlayingService.openAccessSettings(context)
}
@@ -0,0 +1,135 @@
/*
* 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.nowPlaying
import androidx.compose.runtime.Immutable
import androidx.media3.common.C
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlin.math.abs
/** What one of the app's players is audibly playing: its item's `nostr:` URI and where it ends. */
@Immutable
data class InAppPlayback(
val callbackUri: String,
val title: String?,
val artist: String?,
val endsAt: Long?,
) {
/** Same item, ending within a few seconds: a position tick rather than a change. */
fun isSameMoment(other: InAppPlayback?): Boolean {
if (other == null || callbackUri != other.callbackUri || title != other.title || artist != other.artist) return false
val a = endsAt
val b = other.endsAt
return if (a == null || b == null) a == b else abs(a - b) <= NowPlaying.MOMENT_TOLERANCE_SECONDS
}
}
/**
* The players in this process that are audibly playing something with a Nostr event behind it,
* keyed by player. Fed by [InAppPlaybackListener]; read by the now-playing coordinator, which
* resolves the URI to a music track or podcast episode and ignores everything else.
*/
object InAppPlaybackRegistry {
private val playing = MutableStateFlow<Map<Int, InAppPlayback>>(emptyMap())
val flow: StateFlow<Map<Int, InAppPlayback>> = playing
fun update(
player: Player,
playback: InAppPlayback?,
) {
val key = System.identityHashCode(player)
playing.update { current ->
val previous = current[key]
when {
playback == null -> if (previous != null) current - key else current
// Live streams and some players report timeline/position changes every few
// seconds; keeping the same map avoids re-running everything downstream.
playback.isSameMoment(previous) -> current
// The same item with a corrected end keeps its place; a new item moves to the
// end, so iterating backwards visits the most recently started first.
previous?.callbackUri == playback.callbackUri -> current + (key to playback)
else -> (current - key) + (key to playback)
}
}
}
/** A released player reports nothing on its way out, so the pool drops it here. */
fun forget(player: Player) = update(player, null)
}
/**
* Reports a player's audible playback to [InAppPlaybackRegistry]. A muted player (feed autoplay)
* or one without a `nostr:` callback URI reports nothing.
*
* Attached once per player in `ExoPlayerBuilder`; runs on the player's looper.
*/
class InAppPlaybackListener(
private val registry: InAppPlaybackRegistry = InAppPlaybackRegistry,
private val nowSeconds: () -> Long = TimeUtils::now,
) : Player.Listener {
override fun onEvents(
player: Player,
events: Player.Events,
) {
if (events.containsAny(*RELEVANT_EVENTS)) {
registry.update(player, snapshot(player))
}
}
private fun snapshot(player: Player): InAppPlayback? {
if (!player.isPlaying || player.volume < 0.001f) return null
val item = player.currentMediaItem ?: return null
val metadata = item.mediaMetadata
val callbackUri = metadata.extras?.getString(MediaItemCache.EXTRA_CALLBACK_URI) ?: return null
if (!callbackUri.startsWith("nostr:")) return null
val duration = player.duration.takeIf { it != C.TIME_UNSET && !player.isCurrentMediaItemLive }
return InAppPlayback(
callbackUri = callbackUri,
// MediaItemCache falls back to the media URL (the media id) when there is no title.
title = metadata.title?.toString()?.takeUnless { it == item.mediaId },
artist = metadata.artist?.toString(),
endsAt = NowPlaying.endsAt(nowSeconds(), duration, player.currentPosition, player.playbackParameters.speed),
)
}
companion object {
private val RELEVANT_EVENTS =
intArrayOf(
Player.EVENT_IS_PLAYING_CHANGED,
Player.EVENT_MEDIA_ITEM_TRANSITION,
Player.EVENT_POSITION_DISCONTINUITY,
Player.EVENT_TIMELINE_CHANGED,
Player.EVENT_VOLUME_CHANGED,
Player.EVENT_PLAYBACK_PARAMETERS_CHANGED,
Player.EVENT_MEDIA_METADATA_CHANGED,
)
}
}
@@ -0,0 +1,144 @@
/*
* 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.nowPlaying
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingPublisher
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingResolver
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingStatusService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/**
* Publishes what the user is listening to, in Amethyst or in another app, as the logged-in
* account's NIP-38 music status, following that account's now-playing settings.
*
* App-scoped, like the other background watchers in AppModules: the music keeps playing (and
* other apps keep reporting) after the activity is gone. While the app is in the background the
* relay pool is paused, so every publish holds [relayServices] open for [RELAY_HOLD_MS], long
* enough for the event to go out.
*/
class NowPlayingStatusCoordinator(
private val scope: CoroutineScope,
private val accountFlow: Flow<Account?>,
private val relayServices: Flow<*>,
private val inApp: StateFlow<Map<Int, InAppPlayback>> = InAppPlaybackRegistry.flow,
private val otherApps: StateFlow<NowPlaying?> = OtherAppsPlaybackRegistry.flow,
) {
private var relayHold: Job? = null
fun start() {
scope.launch(Dispatchers.IO) {
accountFlow
.distinctUntilChanged { a, b -> a?.signer?.pubKey == b?.signer?.pubKey }
.collectLatest { account ->
if (account == null || !account.isWriteable()) return@collectLatest
run(account)
}
}
}
private suspend fun run(account: Account) {
// Every player that is audibly playing, most recently started first: the first one that
// is a music track or podcast episode wins, so an unmuted feed video started after a song
// does not hide the song.
val inAppTracks =
inApp
.map { playing -> playing.values.reversed().firstNotNullOfOrNull(::resolve) }
.flowOn(Dispatchers.IO)
val publisher =
NowPlayingPublisher(
// Relays are woken first: in the background the pool is paused, and the event
// waits in the outbox until a relay connects.
publish = { track, expiration ->
holdRelays()
account.publishNowPlaying(track, expiration)
},
clear = {
holdRelays()
account.clearNowPlaying()
},
)
NowPlayingStatusService(
settings = account.nowPlayingSettings.flow,
sources = listOf(inAppTracks, otherApps),
onOtherAppSeen = { account.nowPlayingSettings.rememberApp(it.id, it.label) },
publisher = publisher,
).run()
}
/**
* What a player's item is as a status, without its end time. Keyed by the item's `nostr:` URI
* so the NIP-19 parse and the cache lookup run once per item, not once per position update.
* Only touched from the single flow collecting [inApp].
*/
private val resolved = HashMap<String, Resolution>()
private class Resolution(
val track: NowPlaying?,
)
private fun resolve(playback: InAppPlayback): NowPlaying? {
val cached =
resolved[playback.callbackUri] ?: run {
val key = NowPlayingResolver.noteKey(playback.callbackUri)
val event =
if (key != null) {
// Not loaded yet: resolve again on the next update instead of caching a miss.
LocalCache.getNoteIfExists(key)?.event ?: return null
} else {
null
}
val track = event?.let { NowPlayingResolver.fromEvent(it, playback.title, playback.artist) }
if (resolved.size >= MAX_RESOLVED) resolved.clear()
Resolution(track).also { resolved[playback.callbackUri] = it }
}
return cached.track?.copy(endsAt = playback.endsAt)
}
private fun holdRelays() {
relayHold?.cancel()
relayHold =
scope.launch(Dispatchers.IO) {
withTimeoutOrNull(RELAY_HOLD_MS) { relayServices.collect {} }
}
}
companion object {
private const val RELAY_HOLD_MS = 30_000L
private const val MAX_RESOLVED = 64
}
}
@@ -0,0 +1,225 @@
/*
* 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.nowPlaying
import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.MediaSession
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.provider.Settings
import android.service.notification.NotificationListenerService
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** What another app on this device is playing, as reported by its media session. */
object OtherAppsPlaybackRegistry {
private val playing = MutableStateFlow<NowPlaying?>(null)
val flow: StateFlow<NowPlaying?> = playing
fun update(track: NowPlaying?) {
// Many players report their position every second; only a real change goes downstream.
if (track != null && track.isSameMoment(playing.value)) return
playing.value = track
}
}
/**
* Reads the media sessions of other apps (Spotify, YouTube Music, podcast players, ...) to share
* what the user is listening to as a NIP-38 music status.
*
* Android only hands out other apps' sessions to an enabled notification listener, which is why
* this is one: it never reads or posts notifications. The user grants it in the system's
* "Notification access" screen ([openAccessSettings]); until then the system never binds it.
* Whether anything is published is still up to the account's now-playing settings.
*
* Runs in the main process (it is not declared in `:napplet`), where the account lives. The
* system may start that process just to bind this listener; AppModules then logs the saved
* account in on its own, as it does on every start, so the publisher has it.
*/
class OtherAppsNowPlayingService : NotificationListenerService() {
private val handler = Handler(Looper.getMainLooper())
private var sessionManager: MediaSessionManager? = null
private val watched = mutableMapOf<MediaSession.Token, Pair<MediaController, MediaController.Callback>>()
// App labels come from PackageManager, an IPC; players report state up to once a second.
private val labels = mutableMapOf<String, String>()
private val sessionsListener =
MediaSessionManager.OnActiveSessionsChangedListener { controllers ->
watch(controllers.orEmpty())
}
override fun onListenerConnected() {
super.onListenerConnected()
val manager = getSystemService(MediaSessionManager::class.java) ?: return
sessionManager = manager
try {
val component = ComponentName(this, OtherAppsNowPlayingService::class.java)
manager.addOnActiveSessionsChangedListener(sessionsListener, component, handler)
watch(manager.getActiveSessions(component))
} catch (e: SecurityException) {
// Access was revoked between the bind and this call.
Log.w("OtherAppsNowPlaying", "Notification access is not granted", e)
}
}
override fun onListenerDisconnected() {
sessionManager?.removeOnActiveSessionsChangedListener(sessionsListener)
sessionManager = null
unwatchAll()
OtherAppsPlaybackRegistry.update(null)
super.onListenerDisconnected()
}
override fun onDestroy() {
sessionManager?.removeOnActiveSessionsChangedListener(sessionsListener)
unwatchAll()
OtherAppsPlaybackRegistry.update(null)
super.onDestroy()
}
private fun watch(controllers: List<MediaController>) {
val others = controllers.filter { it.packageName != packageName }
val keys = others.mapTo(mutableSetOf()) { it.sessionToken }
watched.keys.filter { it !in keys }.forEach { key ->
watched.remove(key)?.let { (controller, callback) -> controller.unregisterCallback(callback) }
}
others.forEach { controller ->
val key = controller.sessionToken
if (key !in watched) {
val callback =
object : MediaController.Callback() {
override fun onPlaybackStateChanged(state: PlaybackState?) = refresh()
override fun onMetadataChanged(metadata: MediaMetadata?) = refresh()
override fun onSessionDestroyed() {
watched.remove(key)?.let { (it, cb) -> it.unregisterCallback(cb) }
refresh()
}
}
controller.registerCallback(callback, handler)
watched[key] = controller to callback
}
}
refresh()
}
private fun unwatchAll() {
watched.values.forEach { (controller, callback) -> controller.unregisterCallback(callback) }
watched.clear()
}
/** The first session that is playing, in the system's priority order. */
private fun refresh() {
val track = watched.values.firstNotNullOfOrNull { (controller, _) -> toNowPlaying(controller) }
OtherAppsPlaybackRegistry.update(track)
}
private fun toNowPlaying(controller: MediaController): NowPlaying? {
val state = controller.playbackState ?: return null
if (state.state != PlaybackState.STATE_PLAYING) return null
val metadata = controller.metadata ?: return null
val title =
metadata.getString(MediaMetadata.METADATA_KEY_TITLE)?.ifBlank { null }
?: metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)?.ifBlank { null }
?: return null
val artist =
metadata.getString(MediaMetadata.METADATA_KEY_ARTIST)?.ifBlank { null }
?: metadata.getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST)?.ifBlank { null }
?: metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE)?.ifBlank { null }
val duration = metadata.getLong(MediaMetadata.METADATA_KEY_DURATION).takeIf { it > 0 }
val speed = state.playbackSpeed.takeIf { it > 0f } ?: 1f
val position =
if (state.lastPositionUpdateTime > 0) {
state.position + ((SystemClock.elapsedRealtime() - state.lastPositionUpdateTime) * speed).toLong()
} else {
state.position
}
return NowPlaying(
title = title,
artist = artist,
source = NowPlayingSource.OtherApp(controller.packageName, appLabel(controller.packageName)),
endsAt = NowPlaying.endsAt(TimeUtils.now(), duration, position, speed),
)
}
private fun appLabel(packageName: String): String =
labels.getOrPut(packageName) {
try {
packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString()
} catch (_: PackageManager.NameNotFoundException) {
packageName
}
}
companion object {
fun hasAccess(context: Context): Boolean = context.packageName in NotificationManagerCompat.getEnabledListenerPackages(context)
/** Opens the system screen where the user grants or revokes access to this listener. */
fun openAccessSettings(context: Context) {
val component = ComponentName(context, OtherAppsNowPlayingService::class.java).flattenToString()
// Straight to this app's toggle where the system supports it, else the list of listeners.
val intents =
listOfNotNull(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS)
.putExtra(Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME, component)
} else {
null
},
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS),
)
for (intent in intents) {
try {
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
return
} catch (e: ActivityNotFoundException) {
Log.w("OtherAppsNowPlaying", "No activity for ${intent.action}", e)
}
}
}
}
}
@@ -46,6 +46,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.ContentFrame
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow
import com.vitorpamplona.amethyst.commons.video.isHlsMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.playback.composable.controls.BottomGradientOverlay
import com.vitorpamplona.amethyst.service.playback.composable.controls.FullscreenSwipeControlsState
@@ -61,7 +62,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.applyView
import com.vitorpamplona.amethyst.service.playback.composable.controls.clampViewportShortSide
import com.vitorpamplona.amethyst.service.playback.composable.controls.fullscreenSwipeControls
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.AudioPlayingAnimation
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.rememberIsAudioTrack
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.video.isHlsMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.service.cast.CastRequest
@@ -80,7 +81,6 @@ import com.vitorpamplona.amethyst.service.cast.resolveCastLiveness
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.service.playback.diskCache.HlsLivenessCache
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.cast.CastDevicePickerDialog
@@ -1,51 +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.playback.composable.mediaitem
import androidx.media3.common.MimeTypes
/**
* Whether a URL plus its imeta mime identifies HLS.
*
* This is the caller-side form for code that holds a URL and a mime but no `MediaItem` yet — UI that
* has a [MediaItemData] or a `MediaUrlContent`. Once a `MediaItem` exists,
* `isHlsMediaItem` is the equivalent, and both must answer the same way: the UI decides what to
* render, the factory decides how to load it, and a disagreement shows up as a player that streams
* something the surrounding chrome says is a still image.
*
* Defined in terms of [MediaItemCache.toExoPlayerMimeType] rather than re-deriving the rules, so it
* cannot drift from the mime that actually reaches ExoPlayer. That normalizer prefers an explicit
* mime (mapping the four HLS aliases onto [MimeTypes.APPLICATION_M3U8]) and otherwise falls back to a
* **path-anchored** `.m3u8` test.
*
* Both halves matter. A BUD-10 blossom playlist is `https://host/<sha256>` with no extension at all,
* so only the mime identifies it; and anchoring to the path stops `video.mp4?ref=a.m3u8` counting as
* HLS on the strength of its query string.
*
* Note this answers *is it HLS*, which callers use as a proxy for *is it live*. The proxy is
* imprecise in the same way for every HLS URL — an on-demand HLS playlist also answers true — and
* that imprecision is older than this function. Liveness is only truly knowable from
* `#EXT-X-ENDLIST` once the playlist is loaded, which is what `HlsLivenessCache` records.
*/
fun isHlsMedia(
url: String,
mimeType: String?,
): Boolean = MediaItemCache.toExoPlayerMimeType(mimeType, url) == MimeTypes.APPLICATION_M3U8
@@ -25,9 +25,9 @@ import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCache
import com.vitorpamplona.amethyst.commons.video.normalizeStreamMimeType
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -45,23 +45,7 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
internal fun toExoPlayerMimeType(
mimeType: String?,
videoUri: String? = null,
): String? {
if (!mimeType.isNullOrBlank()) {
return when (mimeType.lowercase()) {
"application/vnd.apple.mpegurl",
"application/x-mpegurl",
"audio/x-mpegurl",
"audio/mpegurl",
-> MimeTypes.APPLICATION_M3U8
else -> mimeType
}
}
if (videoUri != null && hasM3u8PathExtension(videoUri)) {
return MimeTypes.APPLICATION_M3U8
}
return null
}
): String? = normalizeStreamMimeType(mimeType, videoUri)
// A side-loaded track needs its media type declared: a NIP-71 `text-track` says nothing
// about the format, and a blossom-hosted one is `https://host/<sha256>` with no extension
@@ -81,14 +65,6 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
language != null && type != null -> "$language \u00B7 $type"
else -> language ?: type
}
// Restrict `.m3u8` matching to the path component so a query param or
// fragment that happens to mention .m3u8 (e.g. `?ref=a.m3u8`) on an
// MP4 URI doesn't misroute to HlsMediaSource.
private fun hasM3u8PathExtension(uri: String): Boolean {
val path = uri.substringBefore('?').substringBefore('#')
return path.endsWith(".m3u8", ignoreCase = true)
}
}
override suspend fun compute(key: MediaItemData): LoadedMediaItem =
@@ -33,6 +33,7 @@ import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.audio.TeeAudioProcessor
import com.vitorpamplona.amethyst.commons.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.nowPlaying.InAppPlaybackListener
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.CurrentPlayPositionCacher
@@ -89,6 +90,7 @@ class ExoPlayerBuilder(
addListener(KeepVideosPlaying(this))
addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache))
addListener(MediaPlayTimeTracker())
addListener(InAppPlaybackListener())
}
}
@@ -25,6 +25,7 @@ import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import com.vitorpamplona.amethyst.service.nowPlaying.InAppPlaybackRegistry
import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
@@ -132,6 +133,7 @@ class ExoPlayerPool(
if (error != null) {
Log.d("PlaybackService") { "ExoPlayerPool discarding errored warm player: $preferredMediaId (${error.errorCodeName})" }
PcmTapRegistry.unregisterPlayer(warm)
InAppPlaybackRegistry.forget(warm)
warm.release()
releaseDecoder()
} else {
@@ -239,6 +241,7 @@ class ExoPlayerPool(
if (error != null) {
Log.d("PlaybackService") { "ExoPlayerPool dropping errored player: ${player.currentMediaItem?.mediaId} (${error.errorCodeName})" }
PcmTapRegistry.unregisterPlayer(player)
InAppPlaybackRegistry.forget(player)
player.release()
releaseDecoder()
return@withLock
@@ -309,6 +312,7 @@ class ExoPlayerPool(
}
} else {
PcmTapRegistry.unregisterPlayer(player)
InAppPlaybackRegistry.forget(player)
player.release() // Release if pool is full.
}
}
@@ -345,11 +349,13 @@ class ExoPlayerPool(
}
warmSnapshot.forEach {
PcmTapRegistry.unregisterPlayer(it.player)
InAppPlaybackRegistry.forget(it.player)
it.player.release()
releaseDecoder()
}
coldPool.forEach {
PcmTapRegistry.unregisterPlayer(it)
InAppPlaybackRegistry.forget(it)
it.release()
}
coldPool.clear()
@@ -21,108 +21,11 @@
package com.vitorpamplona.amethyst.service.pow
import android.content.Context
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_days
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_hours
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_instant
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_minutes
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_seconds
import com.vitorpamplona.amethyst.commons.resources.pow_time_left
import com.vitorpamplona.amethyst.commons.resources.pow_time_left_soon
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import org.jetbrains.compose.resources.PluralStringResource
import kotlin.math.roundToLong
/**
* This device's effective mining rate: the [PoWEstimator] benchmark run with
* the same worker count the mining queue uses (half the cores, see
* [PoWPolicy.minerWorkers] and AppModules.powPublishQueue). Every UI estimate
* must use this rate, or it would describe a single-threaded miner that no
* longer exists. Cached after the first call (~250 ms).
*/
suspend fun deviceHashesPerSecond(): Double = PoWEstimator.hashesPerSecond(PoWPolicy.minerWorkers(Runtime.getRuntime().availableProcessors()))
/**
* "45 seconds" / "10 minutes" / "3 hours" — the one human-readable rendering
* of a PoW duration estimate, shared by the settings picker, the composer
* difficulty menu, the mining banner, and the mining notification.
*
* Estimates come from [com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator]
* and are the statistical mean of a memoryless search — any single post can be
* much luckier or unluckier, so always present these as approximations.
*/
@Composable
fun formatApproxDuration(seconds: Double): String {
@Composable
fun quantity(
id: PluralStringResource,
count: Long,
) = pluralStringRes(id, count.toInt(), count.toInt())
return when {
seconds < 1.0 -> stringRes(Res.string.pow_estimate_instant)
seconds < 90.0 -> quantity(Res.plurals.pow_estimate_seconds, seconds.roundToLong())
seconds < 90.0 * 60.0 -> quantity(Res.plurals.pow_estimate_minutes, (seconds / 60.0).roundToLong())
seconds < 48.0 * 3600.0 -> quantity(Res.plurals.pow_estimate_hours, (seconds / 3600.0).roundToLong())
else -> quantity(Res.plurals.pow_estimate_days, (seconds / 86400.0).roundToLong())
}
}
/**
* "≈ 10 minutes left" while [elapsedSec] is inside the [expectedSec] mean,
* "any moment now" once past it — a memoryless search has no shrinking
* remainder, so past the mean the only honest claim is "soon".
*/
@Composable
fun formatTimeLeft(
expectedSec: Double,
elapsedSec: Long,
): String {
val remaining = expectedSec - elapsedSec
return if (remaining > 1.0) {
stringRes(Res.string.pow_time_left, formatApproxDuration(remaining))
} else {
stringRes(Res.string.pow_time_left_soon)
}
}
/** Suspend twin of [formatApproxDuration] for the mining foreground service,
* which builds its notification text outside composition. */
suspend fun loadApproxDuration(seconds: Double): String {
suspend fun quantity(
id: PluralStringResource,
count: Long,
) = loadPluralStringRes(id, count.toInt(), count.toInt())
return when {
seconds < 1.0 -> loadStringRes(Res.string.pow_estimate_instant)
seconds < 90.0 -> quantity(Res.plurals.pow_estimate_seconds, seconds.roundToLong())
seconds < 90.0 * 60.0 -> quantity(Res.plurals.pow_estimate_minutes, (seconds / 60.0).roundToLong())
seconds < 48.0 * 3600.0 -> quantity(Res.plurals.pow_estimate_hours, (seconds / 3600.0).roundToLong())
else -> quantity(Res.plurals.pow_estimate_days, (seconds / 86400.0).roundToLong())
}
}
/** Suspend twin of [formatTimeLeft]; see [loadApproxDuration]. */
suspend fun loadTimeLeft(
expectedSec: Double,
elapsedSec: Long,
): String {
val remaining = expectedSec - elapsedSec
return if (remaining > 1.0) {
loadStringRes(Res.string.pow_time_left, loadApproxDuration(remaining))
} else {
loadStringRes(Res.string.pow_time_left_soon)
}
}
/**
* Android-resource twin of [formatApproxDuration]; see [powKindLabelResId] for why
* the mining notification cannot read from the Compose catalog.
@@ -44,11 +44,11 @@ import com.vitorpamplona.amethyst.commons.resources.error_dialog_button_ok
import com.vitorpamplona.amethyst.commons.resources.notify_block_relay
import com.vitorpamplona.amethyst.commons.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size16dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
@Composable
fun NotifyRequestDialog(
@@ -22,11 +22,14 @@ package com.vitorpamplona.amethyst.service.uploads
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
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.http.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.commons.service.upload.FileHeader
@@ -40,18 +43,21 @@ 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.buzz.media.BuzzMediaSanitizer
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.TimeUtils
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 java.util.Locale
import kotlin.coroutines.cancellation.CancellationException
/**
@@ -243,6 +249,31 @@ class AndroidMediaUploader(
return firstError!!.also { updateState(0.0, it) }
}
private class SanitizedBlob(
val file: File,
val uri: Uri,
val size: Long,
)
/**
* A copy of [uri] without the metadata a Buzz workspace refuses (HTTP 422: EXIF/XMP/ICC
* segments, PNG text chunks, trailing bytes), or null when there was nothing to remove or
* the format isn't one [BuzzMediaSanitizer] rewrites. The caller deletes the file.
*/
private fun sanitizeForBuzzWorkspace(
uri: Uri,
contentType: String?,
context: Context,
): SanitizedBlob? {
if (!BuzzMediaSanitizer.handles(contentType)) return null
val original = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return null
val clean = BuzzMediaSanitizer.sanitize(original, contentType)
if (clean.contentEquals(original)) return null
val file = File.createTempFile("buzz-upload-", ".bin", context.cacheDir)
file.writeBytes(clean)
return SanitizedBlob(file, Uri.fromFile(file), clean.size.toLong())
}
/**
* 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
@@ -279,11 +310,13 @@ class AndroidMediaUploader(
forcedSigner: NostrSigner?,
context: Context,
sharedAuth: SharedUploadAuth,
buzzWorkspace: Boolean = false,
): 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
// path (e.g. NIP-46 draft signing) always uses the bit-exact /upload, and so does a
// Buzz workspace, whose relay checks the imeta `x` against the blob it stored.
val useMedia = !buzzWorkspace && forcedSigner == null && account.settings.optimizeMediaOnUpload.value
return try {
val result =
BlossomUploader()
@@ -303,6 +336,20 @@ class AndroidMediaUploader(
httpAuth = { hash, size, alt ->
sharedAuth.get {
when {
// Buzz's strict mode demands exactly one `server` tag naming the
// workspace and a token that expires within 60s of signing.
buzzWorkspace -> {
val now = TimeUtils.now()
BlossomAuthorizationEvent.createUploadAuth(
hash = hash,
size = size,
alt = alt,
signer = forcedSigner ?: account.signer,
servers = listOf(serverBaseUrl),
createdAt = now,
expiration = now + BUZZ_UPLOAD_TOKEN_SECS,
)
}
forcedSigner != null -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner)
useMedia -> account.createBlossomMediaAuth(hash, size, alt)
else -> account.createBlossomUploadAuth(hash, size, alt)
@@ -314,6 +361,12 @@ class AndroidMediaUploader(
)
val finalState =
if (buzzWorkspace) {
// The workspace already bound the stored blob to our X-SHA-256 and the token's
// `x`, and its /media reads need a signed GET this client doesn't send — so take
// its descriptor rather than downloading the blob back to hash it again.
trustWorkspaceDescriptor(result, contentType, size, contentTypeForResult, originalHash)
} else {
verifyHeader(
uploadResult = result,
localContentType = contentType,
@@ -321,12 +374,13 @@ class AndroidMediaUploader(
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) {
if (finalState is UploadingState.Finished && !buzzWorkspace && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) {
account.scope.launch(Dispatchers.IO) {
try {
mirrorToOtherServers(result, serverBaseUrl, account)
@@ -389,6 +443,27 @@ class AndroidMediaUploader(
}
}
private fun trustWorkspaceDescriptor(
uploadResult: MediaUploadResult,
localContentType: String?,
localSize: Long?,
originalContentType: String?,
originalHash: String?,
): UploadingFinalState {
val url = uploadResult.url
val hash = uploadResult.sha256
if (url.isNullOrBlank() || hash.isNullOrBlank()) return error(UploadError.SERVER_DID_NOT_PROVIDE_URL)
val fileHeader =
FileHeader(
mimeType = uploadResult.type ?: localContentType,
hash = hash,
size = (uploadResult.size ?: localSize)?.toInt() ?: 0,
dim = uploadResult.dimension,
blurHash = uploadResult.blurHash,
)
return finish(UploadOrchestrator.OrchestratorResult.ServerResult(fileHeader, url, uploadResult.magnet, hash, originalContentType, originalHash))
}
private suspend fun verifyHeader(
uploadResult: MediaUploadResult,
localContentType: String?,
@@ -536,6 +611,16 @@ class AndroidMediaUploader(
uploadBlossomWithFallback(server, account) { baseUrl, auth ->
uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, baseUrl, null, null, account, forcedSigner, context, auth)
}
// Private to the workspace: never retried on, or mirrored to, a public server.
ServerType.BuzzWorkspace -> {
val clean = sanitizeForBuzzWorkspace(finalUri, compressed.contentType, context)
try {
uploadBlossom(clean?.uri ?: finalUri, compressed.contentType, clean?.size ?: compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context, SharedUploadAuth(), buzzWorkspace = true)
.also { if (it is UploadingState.Error) updateState(0.0, it) }
} finally {
clean?.file?.delete()
}
}
}
} finally {
deleteTempUri(finalUri, uri)
@@ -586,10 +671,41 @@ class AndroidMediaUploader(
uploadBlossomWithFallback(server, account) { baseUrl, auth ->
uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context, auth)
}
ServerType.BuzzWorkspace ->
uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context, SharedUploadAuth(), buzzWorkspace = true)
.also { if (it is UploadingState.Error) updateState(0.0, it) }
}
} finally {
deleteTempUri(encrypted.uri, uri)
}
}
}
override suspend fun remoteFileHeader(
url: String,
httpClients: IRoleBasedHttpClientBuilder,
): FileHeader? =
withContext(Dispatchers.IO) {
val fileExtension: String = MimeTypeMap.getFileExtensionFromUrl(url)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.lowercase(Locale.getDefault()))
FileHeader.prepare(url, mimeType, null) { httpClients.okHttpClientForImage(it) }.getOrNull()
}
override fun displayName(uri: MediaUri): String? =
appContext.contentResolver.query(uri, null, null, null, null)?.use {
val idx = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && it.moveToFirst()) it.getString(idx) else null
}
override fun discardTempFile(uri: MediaUri) {
try {
uri.path?.let { path -> File(path).takeIf { it.exists() }?.delete() }
} catch (e: Exception) {
Log.w("AndroidMediaUploader", "Failed to delete temp file", e)
}
}
}
/** Lifetime of a Buzz workspace upload token; Buzz rejects one that outlives 60s from signing. */
private const val BUZZ_UPLOAD_TOKEN_SECS = 55L
@@ -192,6 +192,9 @@ class BlossomUploader {
requestBuilder
.addHeader("Content-Length", length.toString())
// The blob's hash (BUD-06). Servers that bind the body to the token's `x` tag before
// reading it — Buzz answers 401 without it — need it up front.
.addHeader(SHA256_HEADER, hash)
.url(apiUrl)
.put(requestBody)
@@ -282,3 +285,5 @@ class BlossomUploader {
private fun parseResults(body: String): BlossomUploadResult = JsonMapper.fromJson<BlossomUploadResult>(body)
}
private const val SHA256_HEADER = "X-SHA-256"
@@ -74,6 +74,10 @@ object HlsBlobUploaderFactory {
"NIP-95 storage stores each blob as an event and is not suitable for HLS renditions",
)
}
ServerType.BuzzWorkspace -> {
throw IllegalArgumentException("HLS publishing does not target a Buzz workspace's media server")
}
}
private fun blossomAdapter(
@@ -26,7 +26,9 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.CompositionLocalProvider
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.ui.components.LocalWindowViewModelStoreOwner
import com.vitorpamplona.amethyst.commons.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
@@ -61,11 +63,15 @@ class MainActivity : AppCompatActivity() {
setContent {
AmethystTheme {
NowProvider {
// The Activity outlives every nav destination: screens that share state
// across destinations (chess lobby + board, the Cordn group draft) keep it here.
CompositionLocalProvider(LocalWindowViewModelStoreOwner provides this) {
AccountScreen(Amethyst.instance.sessionManager)
}
}
}
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onResume() {
@@ -79,6 +79,7 @@ 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.UrlUserTagOutputTransformation
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
@@ -88,7 +89,11 @@ 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.NoteCompose
import com.vitorpamplona.amethyst.commons.ui.note.creators.invoice.AddLnInvoiceButton
import com.vitorpamplona.amethyst.commons.ui.note.creators.invoice.InvoiceRequest
import com.vitorpamplona.amethyst.commons.ui.note.creators.uploads.ImageVideoDescription
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadUrlPreview
import com.vitorpamplona.amethyst.commons.ui.note.platform.VideoView
import com.vitorpamplona.amethyst.commons.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation
@@ -102,10 +107,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
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.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -48,12 +48,12 @@ import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessin
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.screen.loggedIn.home.UserSuggestionAnchor
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.ui.screen.loggedIn.home.UserSuggestionAnchor
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -131,13 +131,13 @@ import com.vitorpamplona.amethyst.commons.resources.retry
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.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.platform.FullscreenVideoView
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportType
import kotlinx.coroutines.launch
@@ -535,15 +535,13 @@ private fun BlossomBlobViewer(
if (url != null && isVideo) {
val controllerVisible = remember { mutableStateOf(true) }
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
VideoViewInner(
FullscreenVideoView(
videoUri = url,
mimeType = row.type,
contentScale = ContentScale.Fit,
borderModifier = Modifier.fillMaxWidth(),
automaticallyStartPlayback = true,
controllerVisible = controllerVisible,
isFullscreen = true,
accountViewModel = accountViewModel,
modifier = Modifier.fillMaxWidth(),
)
}
} else if (url != null) {
@@ -72,6 +72,8 @@ import com.vitorpamplona.amethyst.commons.resources.nests_servers_use_defaults
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.SavingTopBar
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.SettingsCategoryWithButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertPadding
@@ -84,8 +86,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategoryWithButton
import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter
import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent
@@ -36,6 +36,7 @@ import androidx.compose.ui.platform.LocalContext
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.record_a_message_description
import com.vitorpamplona.amethyst.commons.ui.components.ToggleableBox
@@ -43,8 +44,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
const val MAX_VOICE_RECORD_SECONDS = 600
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun RecordAudioBox(
@@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.audio.ExpandingCirclesAnimation
import com.vitorpamplona.amethyst.commons.audio.FloatingRecordingIndicator
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
@@ -1,63 +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.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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Resolves a shared content-URI string (from an Android SEND intent) into a
* [SelectedMedia] by reading its MIME type off the main thread. Returns null for
* a null/blank URI. Shared by the share-to-compose pre-fill paths (DM chatroom,
* group DM, …) so the URI→MIME→SelectedMedia logic lives in one place.
*/
suspend fun resolveSharedMedia(
context: Context,
uriString: String?,
): SelectedMedia? =
uriString?.ifBlank { null }?.toUri()?.let { uri ->
withContext(Dispatchers.IO) {
SelectedMedia(uri, context.contentResolver.getType(uri))
}
}
/**
* Batch form for SEND_MULTIPLE shares: resolves every URI in one trip to the IO dispatcher
* instead of one context switch per file. Blank entries are dropped, so an empty list in means
* an empty list out and the composer stays closed.
*/
suspend fun resolveSharedMedia(
context: Context,
uriStrings: List<String>,
): ImmutableList<SelectedMedia> {
val uris = uriStrings.mapNotNull { it.ifBlank { null }?.toUri() }
if (uris.isEmpty()) return persistentListOf()
return withContext(Dispatchers.IO) {
uris.map { SelectedMedia(it, context.contentResolver.getType(it)) }.toImmutableList()
}
}
@@ -25,6 +25,7 @@ import android.media.MediaCodecInfo
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMuxer
import com.vitorpamplona.amethyst.commons.audio.AnonymizedResult
import com.vitorpamplona.amethyst.commons.audio.PitchShifter
import com.vitorpamplona.amethyst.commons.audio.VoicePreset
import com.vitorpamplona.quartz.utils.Log
@@ -33,24 +34,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import okio.Path.Companion.toOkioPath
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.abs
/**
* Result of voice anonymization processing.
*
* @property file The output audio file (AAC in MP4 container)
* @property waveform Amplitude data for waveform visualization (one value per second)
* @property duration Audio duration in seconds
*/
data class AnonymizedResult(
val file: File,
val waveform: List<Float>,
val duration: Int,
)
/**
* Processes audio files to alter voice characteristics for privacy.
*
@@ -109,7 +98,7 @@ class VoiceAnonymizer {
}
onProgress(1f)
Result.success(AnonymizedResult(outputFile, waveform, duration))
Result.success(AnonymizedResult(outputFile.toOkioPath(), waveform, duration))
} catch (e: CancellationException) {
// Let cancellation propagate so structured concurrency works (e.g. the
// upload screen being dismissed cancels this job).
@@ -48,6 +48,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
@@ -58,18 +59,19 @@ import com.vitorpamplona.amethyst.commons.resources.record_a_message
import com.vitorpamplona.amethyst.commons.resources.recording_indicator_description
import com.vitorpamplona.amethyst.commons.resources.remove
import com.vitorpamplona.amethyst.commons.ui.components.AudioWaveformReadOnly
import com.vitorpamplona.amethyst.commons.ui.note.platform.MAX_VOICE_RECORD_SECONDS
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.util.formatSecondsToTime
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import java.io.File
import okio.Path
@Composable
fun VoiceMessagePreview(
voiceMetadata: AudioMeta,
localFile: File? = null,
localFile: Path? = null,
onRemove: () -> Unit,
onReRecord: ((RecordingResult) -> Unit)? = null,
isUploading: Boolean = false,
@@ -263,7 +265,7 @@ private fun ReRecordButton(
@Composable
private fun ManageMediaPlayer(
voiceMetadata: AudioMeta,
localFile: File?,
localFile: Path?,
onCompletion: () -> Unit,
onPlayerChanged: (MediaPlayer?) -> Unit,
onRelease: () -> Unit,
@@ -371,12 +373,12 @@ private fun handleWaveformScrub(
private fun createMediaPlayer(
url: String,
localFile: File?,
localFile: Path?,
): MediaPlayer? =
try {
MediaPlayer().apply {
if (localFile != null && localFile.exists()) {
setDataSource(localFile.absolutePath)
if (localFile != null && localFile.toFile().exists()) {
setDataSource(localFile.toString())
} else {
setDataSource(url)
}
@@ -24,6 +24,7 @@ import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import androidx.media3.common.MimeTypes
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -35,15 +36,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okio.Path.Companion.toOkioPath
import java.io.File
class RecordingResult(
val file: File,
val mimeType: String,
val amplitudes: List<Float>,
val duration: Int,
)
@Suppress("DEPRECATION")
class VoiceMessageRecorder {
@Volatile
@@ -145,7 +140,7 @@ class VoiceMessageRecorder {
return if (duration >= 1 && file != null) {
RecordingResult(
file,
file.toOkioPath(),
MimeTypes.AUDIO_AAC,
amplitudesCopy,
duration,
@@ -102,12 +102,12 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
import com.vitorpamplona.amethyst.commons.service.pow.powKindLabelRes
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.pow.deviceHashesPerSecond
import com.vitorpamplona.amethyst.commons.ui.pow.formatTimeLeft
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
import com.vitorpamplona.amethyst.service.pow.deviceHashesPerSecond
import com.vitorpamplona.amethyst.service.pow.formatTimeLeft
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
@@ -95,9 +95,9 @@ import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewerControlsVisibility
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier
import com.vitorpamplona.amethyst.commons.video.isHlsMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -60,10 +60,14 @@ 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.actions.bolt12Offers.Bolt12OffersScreen
import com.vitorpamplona.amethyst.commons.ui.actions.paymentTargets.PaymentTargetsScreen
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.note.nip22Comments.ReplyCommentPostScreen
import com.vitorpamplona.amethyst.commons.ui.note.share.ShareNoteAsQrScreen
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
@@ -71,7 +75,9 @@ 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.display.BookmarkGroupScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.list.metadata.BookmarkGroupMetadataScreen
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
@@ -85,29 +91,62 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.AgentWorkBoard
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.BuzzForumThreadScreen
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.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup.CordnCreateGroupScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup.CordnCreateMembersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup.CordnGroupInfoScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup.CordnGroupListScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup.CordnInvitationsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.geohashChat.GeohashChatScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.geohashChat.GeohashChatsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.geohashChat.GeohashTeleportScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.geohashChat.NewGeohashChatScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.CreateGroupScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.EditGroupInfoScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupInfoScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupListScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.minichat.MinichatScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelListScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelScreen
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.ephemChat.EphemeralChatScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupBrowseScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChatScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupCreateScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupDiscoveryScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupEditScreen
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.chats.share.ShareToDMScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess.ChessLobbyScreen
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.discover.DiscoverScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
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
@@ -119,34 +158,51 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.emojipacks.membersh
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.create.NewGeocacheScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.detail.GeocacheDetailScreen
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.GeoHashPostScreen
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.HashtagPostScreen
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.highlights.NewHighlightScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.HomeScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ShortNotePostScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.VoiceReplyScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.nip75Goals.NewGoalScreen
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.list.metadata.FollowPackMetadataScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen
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.longs.LongsScreen
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.nests.room.lobby.NestLobbyScreen
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.notifications.publicMessages.NewPublicMessageScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nsites.NsitesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.pictures.PicturesScreen
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.PodcastScreen
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.PollPostScreen
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
@@ -156,32 +212,46 @@ 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.AllRelayListScreen
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.subscriptions.ActiveSubscriptionsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.vanish.VanishEventsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.search.SearchScreen
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.ComposeSettingsScreen
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.NIP47SetupScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.NowPlayingSettingsScreen
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.UpdateZapAmountScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.UserSettingsScreen
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.shorts.ShortsScreen
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.threadview.ThreadScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.url.UrlPostScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.url.UrlScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video.VideoScreen
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
@@ -202,16 +272,16 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.workouts.WorkoutsSc
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.nowPlaying.AndroidAppIcon
import com.vitorpamplona.amethyst.service.nowPlaying.AndroidNowPlayingAccess
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.bolt12Offers.Bolt12OffersScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomBlobManagerScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomImportScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DisplayBlossomSyncProgress
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.amethyst.ui.components.getActivity
@@ -224,55 +294,18 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
import com.vitorpamplona.amethyst.ui.navigation.routes.isBaseRoute
import com.vitorpamplona.amethyst.ui.note.PayViaIntentScreen
import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen
import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageFileScreen
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.bookmarkgroups.display.BookmarkGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadata.BookmarkGroupMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumThreadScreen
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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen
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.CordnGroupListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.NewGeohashChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.CreateGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroupInfoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupInfoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.minichat.MinichatScreen
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.ConcordChannelScreen
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.RelayGroupChannelListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupCreateScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupEditScreen
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.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.embed.EmbeddedTabAccountWatcher
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader
@@ -281,62 +314,32 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FavoriteAppManifestPr
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.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.geohash.GeoHashPostScreen
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.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.list.metadata.FollowPackMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen
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.NewMusicTrackScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppDetailScreen
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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
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.NewPodcastEpisodeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen
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.qrcode.ScanQrImageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
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.ComposeSettingsScreen
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.ResourceUsageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen
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.cordn.CordnBackupScreen
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.shorts.ShortsScreen
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.webBookmarks.WebBookmarksScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.NewWorkoutScreen
@@ -653,6 +656,11 @@ fun BuildNavigation(
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.NowPlayingSettings> {
val context = LocalContext.current
val access = remember(context) { AndroidNowPlayingAccess(context.applicationContext) }
NowPlayingSettingsScreen(accountViewModel, nav, access) { appId, label -> AndroidAppIcon(appId, label) }
}
composableFromEnd<Route.NotificationSettings> { NotificationSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ResourceUsage> { ResourceUsageScreen(accountViewModel, nav) }
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
@@ -165,6 +165,7 @@ import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import org.jetbrains.compose.resources.StringResource
@@ -355,7 +356,11 @@ private fun EditStatusBoxes(
accountViewModel: AccountViewModel,
nav: INav,
) {
val statuses by observeUserStatuses(baseAccountUser, accountViewModel)
val allStatuses by observeUserStatuses(baseAccountUser, accountViewModel)
// The music status is written by the now-playing publisher, not typed here: editing it would
// overwrite the track and keep its expiration, so only the other statuses are editable.
val statuses = remember(allStatuses) { allStatuses.filter { it.address.dTag != UserStatusEvent.MUSIC } }
if (statuses.isEmpty()) {
PreviewStatusEditBar(accountViewModel = accountViewModel, nav = nav)
@@ -24,8 +24,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.ObservableNav
@Composable
fun rememberNav(): Nav {
@@ -37,14 +35,3 @@ fun rememberNav(): Nav {
Nav(navController, scope, ime)
}
}
@Composable
fun rememberExtendedNav(
nav: INav,
onBeforeNavigate: () -> Unit,
): INav {
val scope = rememberCoroutineScope()
return remember(nav, scope) {
ObservableNav(nav, scope, onBeforeNavigate)
}
}
@@ -34,13 +34,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
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.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
@@ -61,7 +61,7 @@ fun AndroidAroundMeLocationLabel() {
overflow = TextOverflow.Ellipsis,
)
} else {
val location by Amethyst.instance.locationManager.geohashStateFlow
val location by LocalAppServices.current.deviceLocation.geohashStateFlow
.collectAsStateWithLifecycle()
when (val myLocation = location) {
@@ -66,7 +66,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Surface
@@ -77,7 +76,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
@@ -92,10 +90,8 @@ import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Alignment.Companion.CenterStart
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -115,16 +111,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.commons.audio.FloatingRecordingIndicator
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.MIN_ONCHAIN_ZAP_SATS
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.ReactionRowAction
import com.vitorpamplona.amethyst.commons.model.ReactionRowItem
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.ZapraiserStatus
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
@@ -138,6 +130,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteReposts
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteRepostsBy
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteZaps
import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.boost
@@ -156,15 +149,12 @@ import com.vitorpamplona.amethyst.commons.resources.no_payment_app_found_for_typ
import com.vitorpamplona.amethyst.commons.resources.no_reaction_type_setup_long_press_to_change
import com.vitorpamplona.amethyst.commons.resources.no_reactions_setup
import com.vitorpamplona.amethyst.commons.resources.no_wallet_found
import com.vitorpamplona.amethyst.commons.resources.nutzap
import com.vitorpamplona.amethyst.commons.resources.open_all_reactions_to_this_post
import com.vitorpamplona.amethyst.commons.resources.payment_targets
import com.vitorpamplona.amethyst.commons.resources.payto_amount_set_in_app
import com.vitorpamplona.amethyst.commons.resources.quick_zap_amounts
import com.vitorpamplona.amethyst.commons.resources.quote
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.resources.reload_mint_title
import com.vitorpamplona.amethyst.commons.resources.sats_to_complete
import com.vitorpamplona.amethyst.commons.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox
@@ -174,6 +164,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.toasts.multiline.UserBas
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.routeReplyTo
import com.vitorpamplona.amethyst.commons.ui.note.CASHU_PREFERRED_BELOW_SATS
import com.vitorpamplona.amethyst.commons.ui.note.ChangeReactionIcon
import com.vitorpamplona.amethyst.commons.ui.note.CommentIcon
import com.vitorpamplona.amethyst.commons.ui.note.ExpandLessIcon
@@ -181,32 +172,34 @@ import com.vitorpamplona.amethyst.commons.ui.note.ExpandMoreIcon
import com.vitorpamplona.amethyst.commons.ui.note.LikeIcon
import com.vitorpamplona.amethyst.commons.ui.note.LikedIcon
import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.commons.ui.note.ONCHAIN_PREFERRED_ABOVE_SATS
import com.vitorpamplona.amethyst.commons.ui.note.ObserveZapAmountText
import com.vitorpamplona.amethyst.commons.ui.note.OutlinedZapIcon
import com.vitorpamplona.amethyst.commons.ui.note.RenderBoostGallery
import com.vitorpamplona.amethyst.commons.ui.note.RenderLikeGallery
import com.vitorpamplona.amethyst.commons.ui.note.RenderReaction
import com.vitorpamplona.amethyst.commons.ui.note.RenderZapGallery
import com.vitorpamplona.amethyst.commons.ui.note.RenderZapRaiser
import com.vitorpamplona.amethyst.commons.ui.note.RepostIcon
import com.vitorpamplona.amethyst.commons.ui.note.RepostedIcon
import com.vitorpamplona.amethyst.commons.ui.note.ShareIcon
import com.vitorpamplona.amethyst.commons.ui.note.VoiceReplyIcon
import com.vitorpamplona.amethyst.commons.ui.note.ZapIcon
import com.vitorpamplona.amethyst.commons.ui.note.ZapRail
import com.vitorpamplona.amethyst.commons.ui.note.ZapRailIcon
import com.vitorpamplona.amethyst.commons.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.commons.ui.note.elements.ShareOptionsBottomSheet
import com.vitorpamplona.amethyst.commons.ui.note.platform.MAX_VOICE_RECORD_SECONDS
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
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Font14SP
import com.vitorpamplona.amethyst.commons.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.commons.ui.theme.Height24dpFilledModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpFilledModifier
import com.vitorpamplona.amethyst.commons.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.commons.ui.theme.NoSoTinyBorders
import com.vitorpamplona.amethyst.commons.ui.theme.ReactionRowExpandButton
import com.vitorpamplona.amethyst.commons.ui.theme.ReactionRowHeight
import com.vitorpamplona.amethyst.commons.ui.theme.ReactionRowHeightWithPadding
@@ -223,11 +216,9 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size28Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.TinyBorders
import com.vitorpamplona.amethyst.commons.ui.theme.defaultTweenDuration
import com.vitorpamplona.amethyst.commons.ui.theme.defaultTweenFloatSpec
import com.vitorpamplona.amethyst.commons.ui.theme.defaultTweenIntOffsetSpec
import com.vitorpamplona.amethyst.commons.ui.theme.fundraiserProgressColor
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.reactionBox
import com.vitorpamplona.amethyst.commons.ui.theme.ripple24dp
@@ -239,8 +230,6 @@ import com.vitorpamplona.amethyst.model.zap.CashuRailStatus
import com.vitorpamplona.amethyst.model.zap.RailCapability
import com.vitorpamplona.amethyst.model.zap.RailCapabilityResolver
import com.vitorpamplona.amethyst.service.payments.PayToAppAvailability
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription
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
@@ -268,10 +257,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlin.math.roundToInt
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import androidx.compose.material3.Icon as Material3Icon
@Composable
fun ReactionsRow(
@@ -524,57 +511,6 @@ fun LoadAndDisplayZapraiser(
}
}
@Composable
fun RenderZapRaiser(
baseNote: Note,
zapraiserAmount: Long,
details: Boolean,
accountViewModel: AccountViewModel,
) {
val zapsState by observeNoteZaps(baseNote, accountViewModel)
var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) }
LaunchedEffect(key1 = zapsState) {
zapsState?.note?.let {
val newStatus = accountViewModel.calculateZapraiser(baseNote)
if (zapraiserStatus != newStatus) {
zapraiserStatus = newStatus
}
}
}
LinearProgressIndicator(
modifier = if (details) Height24dpFilledModifier else Height4dpFilledModifier,
color = MaterialTheme.colorScheme.fundraiserProgressColor,
progress = { zapraiserStatus.progress },
gapSize = 0.dp,
strokeCap = StrokeCap.Square,
drawStopIndicator = {},
)
if (details) {
Box(
contentAlignment = Center,
modifier = TinyBorders,
) {
val totalPercentage by
remember(zapraiserStatus) {
derivedStateOf { "${(zapraiserStatus.progress * 100).roundToInt()}%" }
}
Text(
text =
stringRes(id = Res.string.sats_to_complete, totalPercentage, zapraiserStatus.left),
modifier = NoSoTinyBorders,
// color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font14SP,
maxLines = 1,
)
}
}
}
@Composable
private fun WatchReactionsZapsBoostsAndDisplayIfExists(
baseNote: Note,
@@ -816,7 +752,7 @@ fun ReplyViaVoiceReaction(
nav.nav {
Route.VoiceReply(
replyToNoteId = baseNote.idHex,
recordingFilePath = audio.file.absolutePath,
recordingFilePath = audio.file.toString(),
mimeType = audio.mimeType,
duration = audio.duration,
amplitudes = Json.encodeToString(audio.amplitudes),
@@ -1668,45 +1604,6 @@ fun ObserveZapIcon(
inner(wasZappedByLoggedInUser)
}
@Composable
fun ObserveZapAmountText(
baseNote: Note,
accountViewModel: AccountViewModel,
inner: @Composable (String) -> Unit,
) {
val zapsState by observeNoteZaps(baseNote, accountViewModel)
if (zapsState?.note?.zapPayments?.isNotEmpty() == true) {
zapsState?.note?.zapPayments?.forEach {
if (it.value == null) {
NWCFinderFilterAssemblerSubscription(it.key, accountViewModel)
}
}
@Suppress("ProduceStateDoesNotAssignValue")
val zapAmountTxt by
produceState(initialValue = showAmount(baseNote.zapsAmount), key1 = zapsState) {
zapsState?.note?.let {
val newZapAmount = accountViewModel.calculateZapAmount(it)
if (value != newZapAmount) {
value = newZapAmount
}
}
}
inner(zapAmountTxt)
} else {
// Include the signed-in user's own pending onchain zaps so
// the counter reflects the optimistic value the gallery shows.
val ownPubKey = accountViewModel.account.userProfile().pubkeyHex
val note = zapsState?.note
val total =
(note?.zapsAmount ?: java.math.BigDecimal(0)) +
java.math.BigDecimal(note?.extraOwnPendingOnchainSats(ownPubKey) ?: 0L)
inner(showAmount(total))
}
}
@Composable
private fun BoostTypeChoicePopup(
baseNote: Note,
@@ -2684,106 +2581,6 @@ private fun UnifiedZapAmountChip(
}
}
/** Payment rails a zap-amount chip can offer, in canonical display order. */
internal enum class ZapRail { CASHU, RELOAD, LIGHTNING, ONCHAIN }
/** Below this, the default rail is cashu (Lightning min/fees make tiny zaps awkward). */
internal const val CASHU_PREFERRED_BELOW_SATS = 10L
/** Above this, the default rail is an on-chain transaction. */
internal const val ONCHAIN_PREFERRED_ABOVE_SATS = 10_000L
/**
* Default rail for a preset amount with no recipient context (the settings
* preview): cashu under [CASHU_PREFERRED_BELOW_SATS], on-chain over
* [ONCHAIN_PREFERRED_ABOVE_SATS] (and at/above the on-chain minimum), else
* Lightning. Mirrors the live chip's tiering so the preview matches the feed.
*/
internal fun previewPreferredRail(amountInSats: Long): ZapRail =
when {
amountInSats < CASHU_PREFERRED_BELOW_SATS -> ZapRail.CASHU
amountInSats > ONCHAIN_PREFERRED_ABOVE_SATS && amountInSats >= MIN_ONCHAIN_ZAP_SATS -> ZapRail.ONCHAIN
else -> ZapRail.LIGHTNING
}
/** Rails a preset of [amountInSats] could use, default first then the rest. */
internal fun previewRailsFor(amountInSats: Long): List<ZapRail> {
val preferred = previewPreferredRail(amountInSats)
val all =
buildList {
add(ZapRail.CASHU)
add(ZapRail.LIGHTNING)
if (amountInSats >= MIN_ONCHAIN_ZAP_SATS) add(ZapRail.ONCHAIN)
}
return listOf(preferred) + all.filter { it != preferred }
}
/**
* Just the rail's logo (no click target), drawn at [size]. [colored] renders it
* in its accent colour (the preferred rail); otherwise it's flattened to the
* on-surface monochrome tint. The cashu mark is drawn ~0.86× the symbol size —
* the new monochrome outline carries less weight than the old multi-tone mark,
* so it needs less shrinking to read at a matching optical size.
*/
@Composable
internal fun ZapRailIcon(
rail: ZapRail,
colored: Boolean,
size: Dp = 18.dp,
) {
val mono = MaterialTheme.colorScheme.onSurface
val cashuSize = size * 0.86f
when (rail) {
ZapRail.CASHU -> {
Material3Icon(
imageVector = CustomHashTagIcons.Cashu,
contentDescription = stringRes(Res.string.nutzap),
modifier = Modifier.size(cashuSize),
// The cashu mark is a monochrome, tintable outline — colour it the
// same brand orange as the other rails when selected, else mono.
tint = if (colored) BitcoinOrange else mono,
)
}
ZapRail.RELOAD -> {
// Funds exist but in the wrong mint — a dimmed cashu logo with a
// small "+" badge; tapping it opens the top-up screen.
Box(contentAlignment = Alignment.BottomEnd) {
Material3Icon(
imageVector = CustomHashTagIcons.Cashu,
contentDescription = stringRes(Res.string.reload_mint_title),
modifier = Modifier.size(cashuSize).alpha(0.5f),
tint = if (colored) BitcoinOrange else mono,
)
Icon(
symbol = MaterialSymbols.AddCircle,
contentDescription = null,
modifier = Modifier.size(size * 0.5f),
tint = if (colored) BitcoinOrange else mono,
)
}
}
ZapRail.LIGHTNING -> {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = null,
modifier = Modifier.size(size),
tint = if (colored) BitcoinOrange else mono,
)
}
ZapRail.ONCHAIN -> {
Icon(
symbol = MaterialSymbols.CurrencyBitcoin,
contentDescription = null,
modifier = Modifier.size(size),
tint = if (colored) BitcoinOrange else mono,
)
}
}
}
/**
* Exercises the chip across rail combinations and amount bands so the default
* (coloured, left of the amount) and the alternative circular buttons can be
@@ -182,7 +182,7 @@ fun ClinkOfferPreview(
) {
val amount = if (amountRequired) amountInput.toLongOrNull() else useOffer.price
val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, useOffer, amountSats = amount)
val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, accountViewModel.host.moneyOpRelays, useOffer, amountSats = amount)
val bolt11 = response?.bolt11
val movedTo =
@@ -82,7 +82,6 @@ import androidx.compose.ui.window.DialogProperties
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
@@ -97,10 +96,12 @@ import com.vitorpamplona.amethyst.commons.resources.location_picker_search_hint
import com.vitorpamplona.amethyst.commons.resources.location_picker_title
import com.vitorpamplona.amethyst.commons.resources.location_picker_use_mine
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.geohashChat.label
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.location.ForwardGeolocation
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.label
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
import kotlinx.coroutines.Dispatchers
@@ -206,7 +207,7 @@ fun GeohashLocationPickerContent(
val context = LocalContext.current
val keyboard = LocalSoftwareKeyboardController.current
val scope = rememberCoroutineScope()
val locationManager = Amethyst.instance.locationManager
val locationManager = LocalAppServices.current.deviceLocation
val seed = remember(initialGeohash) { initialGeohash?.takeIf { it.isNotBlank() }?.let { GeoHash.decode(it) } }
val seedLen = initialGeohash?.trim()?.length ?: 0
@@ -47,8 +47,8 @@ import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
/** Default close-up zoom for a single pinned location (street-level). */
private const val DEFAULT_ZOOM = 16.0
/** Default close-up zoom for a single pinned location (street-level), when the caller picks none. */
const val DEFAULT_MAP_ZOOM = 16.0
/**
* Night-mode filter for the (always-light) MAPNIK tiles so the map follows the
@@ -145,7 +145,7 @@ fun LocationPreviewMap(
latitude: Double,
longitude: Double,
modifier: Modifier = Modifier,
zoom: Double = DEFAULT_ZOOM,
zoom: Double = DEFAULT_MAP_ZOOM,
aspectRatio: Float = 1f,
pinColor: Color? = null,
pinEmoji: String? = null,
@@ -42,9 +42,9 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.poll_consensus_threshold
import com.vitorpamplona.amethyst.commons.resources.poll_consensus_threshold_percent
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
import kotlinx.coroutines.CancellationException
@Composable
@@ -29,6 +29,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
import com.vitorpamplona.amethyst.commons.audio.WaveformData
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
@@ -41,15 +42,21 @@ import com.vitorpamplona.amethyst.commons.ui.note.platform.NotePlatform
import com.vitorpamplona.amethyst.commons.ui.note.types.EditState
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
import com.vitorpamplona.amethyst.ui.note.creators.location.DEFAULT_MAP_ZOOM
import com.vitorpamplona.amethyst.ui.screen.loggedIn.urlPreview
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import kotlinx.collections.immutable.ImmutableList
import okio.Path
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderMeetingRoomEvent as AppRenderMeetingRoomEvent
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderMeetingRoomPresence as AppRenderMeetingRoomPresence
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderMeetingSpaceEvent as AppRenderMeetingSpaceEvent
import com.vitorpamplona.amethyst.service.playback.composable.VideoView as AppVideoView
import com.vitorpamplona.amethyst.ui.actions.EditPostView as AppEditPostView
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton as AppRecordVoiceButton
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview as AppVoiceMessagePreview
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
@@ -81,6 +88,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent as AppRende
import com.vitorpamplona.amethyst.ui.note.types.RenderRootNappletEvent as AppRenderRootNappletEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent as AppRenderRootSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderVoiceTrack as AppRenderVoiceTrack
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.QuickZapAmountRow as AppQuickZapAmountRow
/** Android's [NotePlatform]: the app's own media, map, audio, chess, git, napplet and zap composables. */
object AndroidNotePlatform : NotePlatform {
@@ -206,9 +214,13 @@ object AndroidNotePlatform : NotePlatform {
pinColor: Color?,
pinEmoji: String?,
pinAlpha: Float,
modifier: Modifier,
zoom: Double?,
) = AppLocationPreviewMap(
latitude = latitude,
longitude = longitude,
modifier = modifier,
zoom = zoom ?: DEFAULT_MAP_ZOOM,
aspectRatio = aspectRatio,
pinColor = pinColor,
pinEmoji = pinEmoji,
@@ -596,6 +608,50 @@ object AndroidNotePlatform : NotePlatform {
callbacks: ZapButtonCallbacks,
) = AppReusableZapButton(baseNote, accountViewModel, nav, config, callbacks)
@Composable
override fun FullscreenVideoView(
videoUri: String,
mimeType: String?,
contentScale: ContentScale,
modifier: Modifier,
controllerVisible: MutableState<Boolean>,
accountViewModel: AccountViewModel,
) = VideoViewInner(
videoUri = videoUri,
mimeType = mimeType,
contentScale = contentScale,
borderModifier = modifier,
automaticallyStartPlayback = true,
controllerVisible = controllerVisible,
isFullscreen = true,
accountViewModel = accountViewModel,
)
@Composable
override fun RecordVoiceButton(
onVoiceTaken: (RecordingResult) -> Unit,
maxDurationSeconds: Int?,
) = AppRecordVoiceButton(onVoiceTaken, maxDurationSeconds)
@Composable
override fun VoiceMessagePreview(
voiceMetadata: AudioMeta,
localFile: Path?,
onRemove: () -> Unit,
onReRecord: ((RecordingResult) -> Unit)?,
isUploading: Boolean,
modifier: Modifier,
) = AppVoiceMessagePreview(voiceMetadata, localFile, onRemove, onReRecord, isUploading, modifier)
@Composable
override fun QuickZapAmountRow(
note: Note,
onDismiss: () -> Unit,
onOnchainRequest: (Long?) -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) = AppQuickZapAmountRow(note, onDismiss, onOnchainRequest, accountViewModel, nav)
@Composable
override fun EditPostView(
onClose: () -> Unit,
@@ -93,6 +93,7 @@ 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.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.commons.ui.components.LoadNote
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
@@ -103,7 +104,6 @@ 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.ui.actions.uploads.UploadProgressIndicator
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -34,7 +34,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.AddressableNote
@@ -47,14 +46,15 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard
import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.commons.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarAppointmentLines
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarDateBadge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detailRouteFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
@@ -133,10 +133,9 @@ private fun RsvpWithAppointment(
CalendarRsvpCard(event)
return
}
val context = LocalContext.current
val timeFormatter = rememberTimeOfDayFormatter()
val appointmentEvent = appointment.event
val range = remember(appointmentEvent) { formatCalendarRange(appointment, context) }
val range = remember(appointmentEvent) { formatCalendarRange(appointment, timeFormatter) }
val relative = rememberRelativeTimeLabel(view, appointmentEvent?.id)
val route = remember(appointment) { detailRouteFor(appointment) }
// Past a week the relative formatter falls back to a plain date ("October 31"), which the
@@ -59,9 +59,10 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
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.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess.ChessViewModelFactory
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
@@ -127,7 +128,7 @@ fun RenderLiveChessChallenge(
val chessViewModel: ChessViewModel =
viewModel(
key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account),
factory = ChessViewModelFactory(accountViewModel.account, LocalAppServices.current.appStores),
)
val isOpenChallenge = event.opponentPubkey() == null
@@ -37,8 +37,8 @@ import com.vitorpamplona.amethyst.commons.ui.components.BlurhashBackdrop
import com.vitorpamplona.amethyst.commons.ui.components.ContentWarningGate
import com.vitorpamplona.amethyst.commons.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.commons.ui.components.mediaSizingModifier
import com.vitorpamplona.amethyst.commons.ui.note.platform.ZoomableContentView
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
@@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size75Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.VoiceHeightModifier
import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier
import com.vitorpamplona.amethyst.commons.video.isHlsMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
@@ -74,7 +75,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.PictureIn
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
@@ -26,6 +26,7 @@ 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.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.navigation.Route
@@ -37,6 +38,7 @@ 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.screen.loggedIn.nests.room.activity.NestActivity
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
@@ -52,6 +54,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.legalSettingsCateg
object AndroidAppPlatform : AppPlatform {
override val isCastingAvailable: Boolean get() = BuildConfig.IS_CASTING_AVAILABLE
// NestActivity.launch adds FLAG_ACTIVITY_NEW_TASK, so the application context can start it.
override fun openNestRoom(addressValue: String) = NestActivity.launch(Amethyst.instance.appContext, addressValue)
// Per flavour: Play links the hosted policies, F-Droid surfaces none.
override fun legalSettingsCategory(uriHandler: UriHandler): SettingsCategory? = flavorLegalSettingsCategory(uriHandler)
@@ -25,10 +25,18 @@ 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.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.service.AppServices
import com.vitorpamplona.amethyst.commons.service.BlossomServerFinder
import com.vitorpamplona.amethyst.commons.service.ai.AltTextSuggester
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.service.ai.MLKitImageLabelService
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* [AppServices] over the main process's app modules. Every member is a getter, so installing
@@ -48,5 +56,32 @@ object AndroidAppServices : AppServices {
override val torSettings: TorSettingsFlow get() = Amethyst.instance.torPrefs.value
override val torBootstrapped: Flow<Boolean> get() =
Amethyst.instance.torManager.status
.map { it.isFullyBootstrapped }
override val appStores: AppPreferenceStores get() = Amethyst.instance.appStores
override val namecoinResolver: NamecoinNameResolver get() = Amethyst.instance.namecoinResolver
override val blossomServerFinder: BlossomServerFinder = AndroidBlossomServerFinder
override val deviceLocation: DeviceLocation get() = Amethyst.instance.locationManager
override fun cachedPlaceName(geohash: String): String? = CachedReversedGeoLocations.cached(geohash)
override fun createAltTextSuggester(): AltTextSuggester = MLKitImageLabelService(Amethyst.instance.appContext)
}
/** [BlossomServerFinder] over the app's BUD-10 resolver, read lazily (main process only). */
private object AndroidBlossomServerFinder : BlossomServerFinder {
override fun cachedServerUrl(blossomUri: String): String? =
Amethyst.instance.blossomResolver
.cachedFindServer(blossomUri)
?.serverUrl
override suspend fun findServerUrl(blossomUri: String): String? =
Amethyst.instance.blossomResolver
.findServers(blossomUri)
?.serverUrl
}
@@ -21,24 +21,20 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.marmot.MarmotGroupIconUpload
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
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUploader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -56,19 +52,6 @@ fun AccountViewModel.urlPreview(
suspend fun AccountViewModel.checkVideoIsOnline(videoUrl: String): Boolean = httpClientBuilder.isVideoOnline(videoUrl)
/**
* Encrypt + upload a picked image as a group avatar (canonical `marmot-group-image-v1` scheme).
* The returned handle is later passed to [AccountViewModel.updateMarmotGroupMetadata] as
* `MarmotGroupIconChange.Set` to commit it into the group's metadata. Uploading is separated from
* the metadata commit so the (slow) Blossom upload can show its own progress before the commit is
* signed.
*/
suspend fun AccountViewModel.uploadMarmotGroupIcon(
uri: Uri,
mimeType: String?,
uploader: MediaUploader,
): MarmotGroupIconUpload = MarmotGroupIconUploader(account).upload(uri, mimeType, account.settings.defaultFileServer, uploader)
fun AccountViewModel.saveMediaToGallery(
videoUri: String?,
mimeType: String?,
@@ -25,6 +25,11 @@ import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.AppModules
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.audio.AnonymizedResult
import com.vitorpamplona.amethyst.commons.audio.VoicePreset
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.commons.service.ai.WritingAssistant
import com.vitorpamplona.amethyst.commons.service.lnurl.LnurlHttpTransport
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlTransport
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobFailure
@@ -32,8 +37,10 @@ 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.ai.WritingAssistantFactory
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismissNotificationForEvent
import com.vitorpamplona.amethyst.service.uploads.AndroidMediaUploader
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizer
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -48,6 +55,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import okio.Path
/** [AccountViewModelHost] over the app's [AppModules]. Lazy services are read on first use. */
class AndroidAccountViewModelHost(
@@ -74,6 +82,27 @@ class AndroidAccountViewModelHost(
override val mediaUploader: MediaUploader by lazy { AndroidMediaUploader(modules.appContext) }
override val deviceLocation: DeviceLocation get() = modules.locationManager
override val scheduledPostStore: ScheduledPostStore get() = modules.scheduledPostStore
override suspend fun hasBackedUpKeys(npub: String): StateFlow<Boolean> = LocalPreferences.hasBackedUpKeys(npub)
override suspend fun setHasBackedUpKeys(
npub: String,
value: Boolean,
) = LocalPreferences.setHasBackedUpKeys(value, npub)
override suspend fun anonymizeVoice(
input: Path,
presetName: String,
): Result<AnonymizedResult> = VoiceAnonymizer().anonymize(input.toFile(), VoicePreset.valueOf(presetName))
// Built on the application context: the assistant outlives any one Activity inside the ViewModel.
override val supportsWritingAssistant: Boolean get() = WritingAssistantFactory.IS_SUPPORTED
override fun createWritingAssistant(): WritingAssistant = WritingAssistantFactory.create(modules.appContext)
override val savedAccounts: Flow<Set<HexKey>> =
LocalPreferences
.accountsFlow()
@@ -28,32 +28,32 @@ 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.calendars.datasource.CalendarsFilterAssemblerSubscription
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.discover.datasource.DiscoveryFilterAssemblerSubscription
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.home.datasource.HomeFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.longs.datasource.LongsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssemblerSubscription
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.shorts.datasource.ShortsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription
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.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
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.shorts.datasource.ShortsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription
/**
* Activates the relay subscription for each feed the user has pinned to the bottom
@@ -45,7 +45,12 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_opening_external_signer
import com.vitorpamplona.amethyst.commons.resources.error_opening_external_signer_description
import com.vitorpamplona.amethyst.commons.ui.components.rememberCoarseLocationPermission
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreload
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.BuzzDmJoinedChatTailPreload
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedChatTailPreload
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedStatePreload
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription
@@ -55,9 +60,6 @@ import com.vitorpamplona.amethyst.service.resourceusage.innermostSigner
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmDiscoveryPreload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.BuzzDmJoinedChatTailPreload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedChatTailPreload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedStatePreload
import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
@@ -133,13 +135,13 @@ fun LoggedInPage(
)
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun WatchLocationPermissions() {
val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION)
val locationPermissionState = rememberCoarseLocationPermission()
LaunchedEffect(locationPermissionState.status.isGranted) {
Amethyst.instance.locationManager.setLocationPermission(locationPermissionState.status.isGranted)
val locationSource = LocalAppServices.current.deviceLocation
LaunchedEffect(locationPermissionState.isGranted) {
locationSource.setLocationPermission(locationPermissionState.isGranted)
}
}
@@ -1,219 +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.ui.screen.loggedIn.bookmarkgroups.list.metadata
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.model.nip51Lists.bookmarkSets.BookmarkSet
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_upload
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.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.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Stable
class BookmarkGroupMetadataViewModel : ViewModel() {
private lateinit var accountViewModel: AccountViewModel
private lateinit var account: Account
var bookmarkGroup by mutableStateOf<BookmarkSet?>(null)
val isNewList by derivedStateOf { bookmarkGroup == null }
val name = mutableStateOf(TextFieldValue())
val picture = mutableStateOf(TextFieldValue())
val description = mutableStateOf(TextFieldValue())
var isUploadingImageForPicture by mutableStateOf(false)
val canPost by derivedStateOf {
name.value.text.isNotBlank()
}
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun new() {
bookmarkGroup = null
clear()
}
fun load(dTag: String) {
bookmarkGroup = account.bookmarkSets.getBookmarkList(dTag)
name.value = TextFieldValue(bookmarkGroup?.title ?: "")
picture.value = TextFieldValue(bookmarkGroup?.image ?: "")
description.value = TextFieldValue(bookmarkGroup?.description ?: "")
}
fun isNewChannel() = bookmarkGroup == null
fun createOrUpdate() {
accountViewModel.launchSigner {
val bookmarkGroup = bookmarkGroup
if (bookmarkGroup == null) {
accountViewModel.account.bookmarkSets.addBookmarkSet(
listName = name.value.text,
listDescription = description.value.text,
listImage = picture.value.text,
account = accountViewModel.account,
)
} else {
accountViewModel.account.bookmarkSets.updateMetadata(
listName = name.value.text,
listDescription = description.value.text,
listImage = picture.value.text,
bookmarkList = bookmarkGroup,
account = accountViewModel.account,
)
}
clear()
}
}
fun clear() {
name.value = TextFieldValue()
picture.value = TextFieldValue()
description.value = TextFieldValue()
}
fun uploadForPicture(
uri: SelectedMedia,
context: Context,
onError: (String, String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
onUploading = { isUploadingImageForPicture = it },
onUploaded = { picture.value = TextFieldValue(it) },
onError = onError,
)
}
}
private suspend fun upload(
galleryUri: SelectedMedia,
context: Context,
onUploading: (Boolean) -> Unit,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit,
) {
onUploading(true)
val sourceUri =
try {
if (account.settings.stripLocationOnUpload) {
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
if (!result.stripped) {
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.metadata_strip_failed_upload_cancelled),
)
onUploading(false)
return
}
result.uri
} else {
galleryUri.uri
}
} catch (e: AvifMetadataNotVerifiableException) {
onUploading(false)
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName),
)
return
}
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = accountViewModel.httpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = accountViewModel.httpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
if (result.url != null) {
onUploading(false)
onUploaded(result.url)
} else {
onUploading(false)
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(Res.string.server_did_not_provide_a_url_after_uploading))
}
} catch (_: SignerExceptions.ReadOnlyException) {
onUploading(false)
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(Res.string.login_with_a_private_key_to_be_able_to_upload))
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
}
@@ -52,7 +52,7 @@ import kotlinx.coroutines.flow.map
* owns the one `#p=me` REQ per workspace relay — so this reads them back out of [LocalCache], fetches
* each discovered channel's 39000-39003 directory (so its `t`=dm marker + participants land), and
* records the ones that turn out to be DMs into [BuzzDmChannels]. The companion
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.BuzzDmJoinedChatTailPreload]
* [com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.BuzzDmJoinedChatTailPreload]
* then keeps those channels' messages warm app-wide — which is what lets a Buzz DM show on the
* Notifications tab and in push without the viewer opening the conversation first.
*
@@ -60,10 +60,11 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_member_count
import com.vitorpamplona.amethyst.commons.resources.relay_group_no_messages_yet
import com.vitorpamplona.amethyst.commons.ui.note.timeAgo
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.observeChatPreviewText
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/** A first screen's worth of recent messages to prefetch per visible card, so previews fill in. */
@@ -259,10 +260,10 @@ private fun BuzzChannelPreviewLine(
summary != null -> summary
author != null -> {
val authorName by observeUserName(author, accountViewModel)
val body = event.content.take(80)
val body = (lastNote?.let { observeChatPreviewText(it, 80) } ?: "")
if (body.isBlank()) authorName else "$authorName: $body"
}
else -> event.content.take(80)
else -> (lastNote?.let { observeChatPreviewText(it, 80) } ?: "")
}
}
Text(
@@ -157,7 +157,7 @@ fun BuzzWorkspaceOverflowMenu(
)
} catch (e: Exception) {
if (e is CancellationException) throw e
error = e.message ?: e::class.simpleName
error = e.message ?: e.javaClass.simpleName
} finally {
minting = false
}
@@ -43,7 +43,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
@@ -64,6 +63,9 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_day
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_day
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatLongDate
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatTimeOfDay
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -161,7 +163,7 @@ private fun DayRow(
}
val startSeconds = view.startSeconds
val context = LocalContext.current
val timeFormatter = rememberTimeOfDayFormatter()
val timeLabel =
when {
view.isAllDay -> stringRes(Res.string.calendar_all_day)
@@ -170,7 +172,7 @@ private fun DayRow(
// misleading on day 2 since the event has been ongoing overnight. Show a
// continuation marker so the user reads it as "still happening".
stringRes(Res.string.calendar_continues)
startSeconds != null -> formatTimeOfDay(startSeconds, context)
startSeconds != null -> formatTimeOfDay(startSeconds, timeFormatter)
else -> "—"
}
@@ -40,7 +40,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -53,8 +52,11 @@ import com.vitorpamplona.amethyst.commons.model.nip52Calendar.CalendarAppointmen
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import java.time.Instant
import java.time.ZoneId
@@ -80,8 +82,8 @@ fun CalendarEventListCard(
footer: (@Composable () -> Unit)? = null,
) {
val view = note.appointmentView() ?: return
val context = LocalContext.current
val range = remember(note.idHex) { formatCalendarRange(note, context) }
val timeFormatter = rememberTimeOfDayFormatter()
val range = remember(note.idHex) { formatCalendarRange(note, timeFormatter) }
val relative = rememberRelativeTimeLabel(view, note.idHex)
val event = note.event ?: return
val detailRoute = remember(event.id) { detailRouteFor(note) }
@@ -61,6 +61,9 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_month
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_month
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatLongDate
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatMonthYear
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatShortWeekday
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -1,146 +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.ui.screen.loggedIn.calendars
import android.content.Context
import android.text.format.DateFormat
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
// Skeletons follow Unicode LDML — getBestDateTimePattern picks locale ordering.
private const val DAY_MONTH_SKELETON = "EEEMMMd" // "Mon, May 28" / "Mon 28 May"
private const val FULL_DATE_SKELETON = "EEEEMMMMdy" // "Monday, May 28, 2026" / "Monday, 28 May 2026"
private const val MONTH_YEAR_SKELETON = "MMMMy" // "May 2026" / "Mai 2026"
private const val WEEKDAY_SHORT_SKELETON = "EEE" // "Mon" — order doesn't matter
/**
* Per-thread cached [SimpleDateFormat] keyed off the current default [Locale].
*
* Cached because these run per cell in the calendar grid (≈42 cells per month,
* 7 per weekday header) — allocating a fresh SimpleDateFormat each call
* showed up as scroll-time GC churn. Per-thread because SimpleDateFormat
* isn't thread-safe; calendars today are read on the UI thread but future
* callers could differ. ThreadLocal gives both — no locks, no contention,
* lazy rebuild on locale change.
*/
private class LocaleAwareFormatter(
private val skeleton: String,
) {
private val cache = ThreadLocal<Pair<Locale, SimpleDateFormat>>()
fun get(): SimpleDateFormat {
val current = Locale.getDefault()
val cached = cache.get()
if (cached != null && cached.first == current) return cached.second
val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current)
cache.set(current to fresh)
return fresh
}
}
private val dayMonthFormat = LocaleAwareFormatter(DAY_MONTH_SKELETON)
private val fullDateFormat = LocaleAwareFormatter(FULL_DATE_SKELETON)
private val monthYearFormat = LocaleAwareFormatter(MONTH_YEAR_SKELETON)
private val weekdayShortFormat = LocaleAwareFormatter(WEEKDAY_SHORT_SKELETON)
// Time format respects the user's Android 12/24-hour system setting.
private fun timeFormat(context: Context): java.text.DateFormat = DateFormat.getTimeFormat(context)
fun formatCalendarRange(
note: Note,
context: Context,
): String? {
val view = note.appointmentView() ?: return null
val start = view.startSeconds ?: return null
return if (view.isAllDay) {
formatDateRange(start, view.endSeconds)
} else {
formatTimeRange(start, view.endSeconds, context)
}
}
private fun formatTimeRange(
start: Long,
end: Long?,
context: Context,
): String {
val dayMonth = dayMonthFormat.get()
val time = timeFormat(context)
val startMs = start * 1000
val startStr = "${dayMonth.format(Date(startMs))} · ${time.format(Date(startMs))}"
if (end == null || end == start) return startStr
val endMs = end * 1000
return if (isSameDay(startMs, endMs)) {
"$startStr – ${time.format(Date(endMs))}"
} else {
"$startStr – ${dayMonth.format(Date(endMs))} · ${time.format(Date(endMs))}"
}
}
private fun formatDateRange(
start: Long,
end: Long?,
): String {
val dayMonth = dayMonthFormat.get()
val startStr = dayMonth.format(Date(start * 1000))
if (end == null || end == start) return startStr
return "$startStr – ${dayMonth.format(Date(end * 1000))}"
}
fun formatLongDate(unixSeconds: Long): String = fullDateFormat.get().format(Date(unixSeconds * 1000))
fun formatMonthYear(
year: Int,
monthZeroBased: Int,
): String {
val cal = Calendar.getInstance()
cal.clear()
cal.set(year, monthZeroBased, 1)
return monthYearFormat.get().format(cal.time)
}
fun formatTimeOfDay(
unixSeconds: Long,
context: Context,
): String = timeFormat(context).format(Date(unixSeconds * 1000))
fun formatShortWeekday(weekdayZeroBased: Int): String {
val cal = Calendar.getInstance()
cal.clear()
cal.firstDayOfWeek = Calendar.SUNDAY
cal.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY)
cal.add(Calendar.DAY_OF_YEAR, weekdayZeroBased)
return weekdayShortFormat.get().format(cal.time)
}
private fun isSameDay(
aMs: Long,
bMs: Long,
): Boolean {
val ca = Calendar.getInstance().apply { timeInMillis = aMs }
val cb = Calendar.getInstance().apply { timeInMillis = bMs }
return ca.get(Calendar.YEAR) == cb.get(Calendar.YEAR) &&
ca.get(Calendar.DAY_OF_YEAR) == cb.get(Calendar.DAY_OF_YEAR)
}
@@ -61,6 +61,9 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_week
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_week
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatLongDate
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatMonthYear
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatShortWeekday
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -39,8 +39,8 @@ 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.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import kotlinx.coroutines.launch
@Composable
@@ -1,60 +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.ui.screen.loggedIn.calendars
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Writes [content] to a `.ics` file in the app's cache dir and opens the system share sheet so
* the user can hand the file to a calendar app, email client, file manager, etc.
*
* The file lives in `cacheDir/calendar/` — covered by the existing `<cache-path>` entry in
* `file_paths.xml`, so [FileProvider] can hand out a `content://` URI without further config.
* The receiver gets a read-permission grant via [Intent.FLAG_GRANT_READ_URI_PERMISSION] that
* lasts only for the duration of the share.
*/
fun shareIcs(
context: Context,
filename: String,
content: String,
chooserTitle: String,
) {
val dir = File(context.cacheDir, "calendar").apply { mkdirs() }
val file = File(dir, filename)
file.writeText(content)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
val intent =
Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val chooser =
Intent
.createChooser(intent, chooserTitle)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooser)
}
@@ -42,6 +42,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton
import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
@@ -49,7 +50,10 @@ 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.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.commons.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
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
@@ -62,11 +66,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat
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.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
/**
@@ -68,6 +68,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
import com.vitorpamplona.amethyst.commons.audio.WaveformData
import com.vitorpamplona.amethyst.commons.chats.ui.AutoScrollToNewest
import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager
@@ -112,20 +113,19 @@ import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
import com.vitorpamplona.amethyst.commons.ui.note.platform.ZoomableContentView
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.commons.ui.stringRes
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.MetadataStripper
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessageRecorder
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWaveformPlayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnBlobUpload
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaAttachment
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaCipher
@@ -219,7 +219,7 @@ private fun CordnGroupChat(
// it after sending. A note recorded and then abandoned — the screen closed,
// the room switched — never reached that, so it stayed on disk: an
// unencrypted copy of a message that was never even sent.
pendingVoice?.file?.delete()
pendingVoice?.file?.toFile()?.delete()
}
}
var attachError by remember { mutableStateOf<String?>(null) }
@@ -595,11 +595,11 @@ private fun CordnGroupChat(
onVoiceNote = { recording ->
// Re-recording replaces what was there; the old file is a temp
// file this screen owns, so it goes now rather than being leaked.
pendingVoice?.file?.delete()
pendingVoice?.file?.toFile()?.delete()
pendingVoice = recording
},
onRemoveVoice = {
pendingVoice?.file?.delete()
pendingVoice?.file?.toFile()?.delete()
pendingVoice = null
},
attaching = attaching,
@@ -1311,7 +1311,7 @@ private suspend fun sendVoiceNote(
?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session))
try {
val bytes = withContext(Dispatchers.IO) { recording.file.readBytes() }
val bytes = withContext(Dispatchers.IO) { recording.file.toFile().readBytes() }
val tag =
CordnMediaService(accountViewModel.account)
.upload(
@@ -1331,7 +1331,7 @@ private suspend fun sendVoiceNote(
// invisible to the person who sent it.
room.add(session.manager.send(room.gid, content = caption.trim(), tags = arrayOf(tag)))
} finally {
withContext(Dispatchers.IO) { recording.file.delete() }
withContext(Dispatchers.IO) { recording.file.toFile().delete() }
}
}
@@ -99,6 +99,14 @@ import com.vitorpamplona.amethyst.commons.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ActionTile
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatChipFlowRow
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.MoreActionsToggle
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ReactionChip
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ReactionChipView
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.SectionDivider
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.TileRow
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.authorNameColorFor
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -109,14 +117,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ActionTile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatChipFlowRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.MoreActionsToggle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ReactionChip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ReactionChipView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.SectionDivider
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.TileRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.authorNameColorFor
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnAnnotationIndex
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage
@@ -143,7 +143,7 @@ import kotlin.math.abs
* opened a menu and nothing could be swiped.
*
* What cordn cannot share is the *content* of those slots. The shared fillings
* ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatReactionChips],
* ([com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatReactionChips],
* `ChatMessageActionSheet`, `ChatMessageFooter`, `DrawAuthorInfo`) all take a `Note`,
* and a `Note` comes from `LocalCache` — which nothing in a cordn room may enter, see
* the screen's KDoc. So the slots are filled from the envelope and the annotation fold
@@ -0,0 +1,145 @@
/*
* 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.screen.loggedIn.chats.feed
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_dialog_zap_error
import com.vitorpamplona.amethyst.commons.resources.no_wallet_found
import com.vitorpamplona.amethyst.commons.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.navigateToReloadMint
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoiceGrid
import com.vitorpamplona.amethyst.ui.note.observeZapRailCapability
import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlin.uuid.ExperimentalUuidApi
/**
* The user's zap presets unpacked as rail-aware amount chips (same grid the zap
* amount popup shows), firing directly from the sheet. Lightning/cashu zaps
* dismiss immediately — errors surface as toasts and the receipt lands on the
* bubble's sats chip; on-chain amounts hand off to the dialog hosted by the sheet.
*/
@OptIn(ExperimentalUuidApi::class)
@Composable
fun QuickZapAmountRow(
note: Note,
onDismiss: () -> Unit,
onOnchainRequest: (Long?) -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noWalletFoundStr = stringRes(Res.string.no_wallet_found)
val zapAmountChoices by
accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
.collectAsStateWithLifecycle()
val amountChoices = remember(zapAmountChoices) { zapAmountChoices.distinct().toImmutableList() }
if (amountChoices.isEmpty()) return
val railCapability =
observeZapRailCapability(
baseNote = note,
accountViewModel = accountViewModel,
// Onchain zap events are public and would e-tag the rumor id.
onchainSupported = !note.isPrivateRumor(),
)
val context = LocalContext.current
val onError = { _: String, message: String, user: User? ->
// Payment failed — drop the optimistic "zapping" indicator on the bubble.
accountViewModel.endZapInFlight(note.idHex)
accountViewModel.toastManager.toast(Res.string.error_dialog_zap_error, message, user)
}
val onPayViaIntent = { payables: ImmutableList<ZapPaymentHandler.Payable> ->
// Handoff to an external wallet: we can't observe whether it completes, so clear
// the optimistic indicator rather than leave it spinning forever.
accountViewModel.endZapInFlight(note.idHex)
payViaIntentOrManualSplit(payables, context, noWalletFoundStr, accountViewModel, nav)
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.Center,
) {
ZapAmountChoiceGrid(
amountChoices = amountChoices,
railCapability = railCapability,
onLightningZap = { amountInSats ->
// Show a pending zap chip on the bubble immediately; it settles into the
// real sats chip once the receipt lands (or clears on error/timeout).
accountViewModel.markZapInFlight(note.idHex)
accountViewModel.zap(
note,
amountInSats * 1000,
null,
"",
true,
onError,
{ },
onPayViaIntent,
)
onDismiss()
},
onNutzap = { amountInSats ->
accountViewModel.markZapInFlight(note.idHex)
accountViewModel.sendNutzap(
baseNote = note,
amountSats = amountInSats,
message = "",
onError = onError,
onProgress = { },
)
onDismiss()
},
onOnchainAmount = onOnchainRequest,
onReloadNutzap = { amount ->
navigateToReloadMint(accountViewModel, nav, note, amount)
onDismiss()
},
onChangeAmount = {
nav.nav(Route.UpdateZapAmount())
onDismiss()
},
// Hands off to another app; the sheet must not stay stacked behind it.
onHandedOff = onDismiss,
)
}
}
@@ -29,10 +29,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.ChatroomView
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.ShortEphemeralChatChannelHeader
import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -62,7 +62,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.buzz.ui.PresenceDot
import com.vitorpamplona.amethyst.commons.chats.publicChannels.relayGroup.newestTimelineNote
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -107,11 +106,15 @@ import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeAgoLabels
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.BuzzDmListViewModel
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.HiddenDmHeader
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.observeChatPreviewText
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
@@ -120,8 +123,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzAddPeopleDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzImportRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzRelayImportViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspaceOverflowMenu
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_DM
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -366,7 +367,7 @@ fun RelayGroupChannelListScreen(
// was never consulted, and neither was whether this relay is Tor-routed at all (Tor being *on*
// doesn't mean this relay goes through it; the per-role presets decide).
val torEvaluation =
Amethyst.instance.torEvaluatorFlow.flow
accountViewModel.host.torRelayEvaluation
.collectAsStateWithLifecycle()
// The same predicate the relay pool itself dials with, so the banner can't claim Tor for a relay
// the app is reaching over clearnet (onion / localhost / trusted-off-Tor are all folded in here).
@@ -374,9 +375,8 @@ fun RelayGroupChannelListScreen(
// While Tor is bootstrapping every Tor-routed relay is silent — that's Tor's own failure (and its
// own dialog), so don't let it read as "this relay blocks Tor exits".
val torStatus by Amethyst.instance.torManager.status
.collectAsStateWithLifecycle()
val torIsUp = torStatus.isFullyBootstrapped
val torIsUp by LocalAppServices.current.torBootstrapped
.collectAsStateWithLifecycle(false)
// The offer adds the relay to the kind-10089 Trusted list, which only moves it to clearnet while
// trusted relays are *off* Tor. Under the Small-Payloads / Full-Privacy presets they are on Tor,
@@ -914,10 +914,10 @@ private fun BuzzDmPreviewLine(
summary != null -> summary
author != null -> {
val authorName by observeUserName(author, accountViewModel)
val body = event.content.take(80)
val body = (lastNote?.let { observeChatPreviewText(it, 80) } ?: "")
if (body.isBlank()) authorName else "$authorName: $body"
}
else -> event.content.take(80)
else -> (lastNote?.let { observeChatPreviewText(it, 80) } ?: "")
}
Text(
preview,
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.zonedDrawerSwipeIfModal
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.rooms.feed.ChatroomListFeedView
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size40dp
import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight
@@ -43,12 +43,12 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.TwoPaneNav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.ChatroomView
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelView
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.topbars.AmethystClickableIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.WarmJoinedRelayGroupNip11
@Composable
@@ -37,10 +37,11 @@ import com.vitorpamplona.amethyst.commons.nip64Chess.ui.NewChessGameDialog
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.new_chess_game
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess.ChessViewModelFactory
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory
/**
* Floating action button for creating new chess game challenges
@@ -56,7 +57,7 @@ fun NewChessGameButton(
val chessViewModel: ChessViewModel =
viewModel(
key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account),
factory = ChessViewModelFactory(accountViewModel.account, LocalAppServices.current.appStores),
)
FloatingActionButton(
@@ -20,16 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup
import android.app.Activity
import android.content.ClipData
import android.content.Context
import android.content.ContextWrapper
import android.os.PersistableBundle
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -136,14 +132,13 @@ 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.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.privacylock.rememberDeviceAuthenticator
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.BackButton
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.note.authenticate
import com.vitorpamplona.amethyst.ui.note.rememberAuthPromptLabels
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
import kotlinx.coroutines.CoroutineScope
@@ -701,13 +696,6 @@ private class KeyAccessGate {
var isUnlocked by mutableStateOf(false)
private set
/**
* The action waiting on the keyguard fallback activity to return. Only a new request or
* that activity's result replaces it: the activity stops this screen (so ON_STOP must not
* clear it), and a wrong fingerprint before the lockout fallback is not a final failure.
*/
var pending: (() -> Unit)? = null
var prompt: ((onApproved: () -> Unit) -> Unit)? = null
fun withAccess(action: () -> Unit) {
@@ -728,32 +716,15 @@ private class KeyAccessGate {
@Composable
private fun rememberKeyAccessGate(accountViewModel: AccountViewModel): KeyAccessGate {
val context = LocalContext.current
val authLabels = rememberAuthPromptLabels()
val authenticator = rememberDeviceAuthenticator()
val authTitle = stringRes(Res.string.backup_keys)
val gate = remember { KeyAccessGate() }
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
val action = gate.pending
gate.pending = null
if (result.resultCode == Activity.RESULT_OK) {
action?.invoke()
}
}
SideEffect {
gate.prompt = { onApproved ->
gate.pending = onApproved
authenticate(
authenticator.authenticate(
title = authTitle,
context = context,
labels = authLabels,
keyguardLauncher = keyguardLauncher,
onApproved = {
gate.pending = null
onApproved()
},
onApproved = onApproved,
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
}
@@ -1,219 +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.ui.screen.loggedIn.lists.list.metadata
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.PeopleList
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_upload
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.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.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Stable
class FollowPackMetadataViewModel : ViewModel() {
private lateinit var accountViewModel: AccountViewModel
private lateinit var account: Account
var peopleList by mutableStateOf<PeopleList?>(null)
val isNewPack by derivedStateOf { peopleList == null }
val name = mutableStateOf(TextFieldValue())
val picture = mutableStateOf(TextFieldValue())
val description = mutableStateOf(TextFieldValue())
var isUploadingImageForPicture by mutableStateOf(false)
val canPost by derivedStateOf {
name.value.text.isNotBlank()
}
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun new() {
peopleList = null
clear()
}
fun load(dTag: String) {
peopleList = account.starterPacks.selectList(dTag)
name.value = TextFieldValue(peopleList?.title ?: "")
picture.value = TextFieldValue(peopleList?.image ?: "")
description.value = TextFieldValue(peopleList?.description ?: "")
}
fun createOrUpdate() {
accountViewModel.launchSigner {
val peopleList = peopleList
if (peopleList == null) {
val newListIdentifier =
accountViewModel.account.starterPacks.addFollowList(
name = name.value.text,
desc = description.value.text,
image = picture.value.text,
account = accountViewModel.account,
)
} else {
accountViewModel.account.starterPacks.updateMetadata(
name = name.value.text,
desc = description.value.text,
image = picture.value.text,
peopleList = peopleList,
account = accountViewModel.account,
)
}
clear()
}
}
fun clear() {
name.value = TextFieldValue()
picture.value = TextFieldValue()
description.value = TextFieldValue()
}
fun uploadForPicture(
uri: SelectedMedia,
context: Context,
onError: (String, String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
onUploading = { isUploadingImageForPicture = it },
onUploaded = { picture.value = TextFieldValue(it) },
onError = onError,
)
}
}
private suspend fun upload(
galleryUri: SelectedMedia,
context: Context,
onUploading: (Boolean) -> Unit,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit,
) {
onUploading(true)
val sourceUri =
if (account.settings.stripLocationOnUpload) {
val result =
try {
MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
} catch (e: AvifMetadataNotVerifiableException) {
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName),
)
onUploading(false)
return
}
if (!result.stripped) {
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.metadata_strip_failed_upload_cancelled),
)
onUploading(false)
return
}
result.uri
} else {
galleryUri.uri
}
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = accountViewModel.httpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = accountViewModel.httpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
if (result.url != null) {
onUploading(false)
onUploaded(result.url)
} else {
onUploading(false)
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(Res.string.server_did_not_provide_a_url_after_uploading))
}
} catch (_: SignerExceptions.ReadOnlyException) {
onUploading(false)
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(Res.string.login_with_a_private_key_to_be_able_to_upload))
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
}
@@ -1,219 +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.ui.screen.loggedIn.lists.list.metadata
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.PeopleList
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_upload
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.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.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Stable
class PeopleListMetadataViewModel : ViewModel() {
private lateinit var accountViewModel: AccountViewModel
private lateinit var account: Account
var peopleList by mutableStateOf<PeopleList?>(null)
val isNewList by derivedStateOf { peopleList == null }
val name = mutableStateOf(TextFieldValue())
val picture = mutableStateOf(TextFieldValue())
val description = mutableStateOf(TextFieldValue())
var isUploadingImageForPicture by mutableStateOf(false)
val canPost by derivedStateOf {
name.value.text.isNotBlank()
}
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun new() {
peopleList = null
clear()
}
fun load(dTag: String) {
peopleList = account.followSets.selectList(dTag)
name.value = TextFieldValue(peopleList?.title ?: "")
picture.value = TextFieldValue(peopleList?.image ?: "")
description.value = TextFieldValue(peopleList?.description ?: "")
}
fun isNewChannel() = peopleList == null
fun createOrUpdate() {
accountViewModel.launchSigner {
val peopleList = peopleList
if (peopleList == null) {
accountViewModel.account.followSets.addFollowList(
listName = name.value.text,
listDescription = description.value.text,
listImage = picture.value.text,
account = accountViewModel.account,
)
} else {
accountViewModel.account.followSets.updateMetadata(
listName = name.value.text,
listDescription = description.value.text,
listImage = picture.value.text,
peopleList = peopleList,
account = accountViewModel.account,
)
}
clear()
}
}
fun clear() {
name.value = TextFieldValue()
picture.value = TextFieldValue()
description.value = TextFieldValue()
}
fun uploadForPicture(
uri: SelectedMedia,
context: Context,
onError: (String, String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
onUploading = { isUploadingImageForPicture = it },
onUploaded = { picture.value = TextFieldValue(it) },
onError = onError,
)
}
}
private suspend fun upload(
galleryUri: SelectedMedia,
context: Context,
onUploading: (Boolean) -> Unit,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit,
) {
onUploading(true)
val sourceUri =
try {
if (account.settings.stripLocationOnUpload) {
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
if (!result.stripped) {
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.metadata_strip_failed_upload_cancelled),
)
onUploading(false)
return
}
result.uri
} else {
galleryUri.uri
}
} catch (e: AvifMetadataNotVerifiableException) {
onUploading(false)
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName),
)
return
}
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = accountViewModel.httpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = accountViewModel.httpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
if (result.url != null) {
onUploading(false)
onUploaded(result.url)
} else {
onUploading(false)
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(Res.string.server_did_not_provide_a_url_after_uploading))
}
} catch (_: SignerExceptions.ReadOnlyException) {
onUploading(false)
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(Res.string.login_with_a_private_key_to_be_able_to_upload))
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
}
@@ -250,7 +250,7 @@ fun CreateNestSheet(
/**
* Schedule-start picker. Shows the currently-selected start time
* as a button; tapping it opens a Material3 DatePicker → TimePicker
* chain (same shape as [com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker]
* chain (same shape as [com.vitorpamplona.amethyst.commons.ui.note.creators.expiration.ExpirationDatePicker]
* — hour + minute resolved in the local zone, then converted to
* UTC unix seconds via the system zone offset).
*/
@@ -48,11 +48,13 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.nest_chat_empty
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nests.room.chat.NestEditFieldRow
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nests.room.chat.NestNewMessageViewModel
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.ui.navigation.navs.BouncingIntentNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -555,7 +555,7 @@ private fun broadcastWithDiagnostics(
} catch (ce: CancellationException) {
throw ce
} catch (e: Throwable) {
val klass = e::class.simpleName ?: "Throwable"
val klass = e.javaClass.simpleName ?: "Throwable"
val msg = e.message?.takeIf { it.isNotBlank() } ?: "no message"
accountViewModel.toastManager.toast(failedTitle, "$klass: $msg")
}
@@ -172,7 +172,7 @@ fun RenderCardFeed(
*
* The padding is applied here because only the `Loaded` branch has a `LazyColumn` to carry the
* scaffold's inset as content padding; without it the header would draw under the disappearing top bar.
* Same shape [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.ChatroomListFeedView] uses.
* Same shape [com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.rooms.feed.ChatroomListFeedView] uses.
*/
@Composable
private fun HeaderAbove(

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