mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into ccr-7900824a-sqkcsw
# Conflicts: # commonsUI/src/commonMain/composeResources/values/strings.xml
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+4
-3
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+66
@@ -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
|
||||
+33
@@ -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)
|
||||
}
|
||||
+135
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+144
@@ -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
|
||||
}
|
||||
}
|
||||
+225
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
-51
@@ -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
|
||||
+2
-26
@@ -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 =
|
||||
|
||||
+2
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -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.
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+119
-3
@@ -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
|
||||
|
||||
+5
@@ -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"
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+3
-5
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
-63
@@ -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()
|
||||
}
|
||||
}
|
||||
+3
-14
@@ -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).
|
||||
|
||||
+8
-6
@@ -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)
|
||||
}
|
||||
|
||||
+3
-8
@@ -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
|
||||
|
||||
+1
-1
@@ -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) }
|
||||
|
||||
+6
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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 =
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+56
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+5
-6
@@ -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
|
||||
}
|
||||
|
||||
-17
@@ -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?,
|
||||
|
||||
+29
@@ -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()
|
||||
|
||||
+7
-7
@@ -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
|
||||
|
||||
+9
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-219
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
*
|
||||
|
||||
+4
-3
@@ -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(
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
+5
-3
@@ -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 -> "—"
|
||||
}
|
||||
|
||||
|
||||
+6
-4
@@ -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) }
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
-146
@@ -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)
|
||||
}
|
||||
+3
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
-60
@@ -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)
|
||||
}
|
||||
+4
-4
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+9
-9
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-9
@@ -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
|
||||
|
||||
+145
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
|
||||
+9
-9
@@ -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,
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+3
-2
@@ -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(
|
||||
|
||||
+4
-33
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
-219
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-219
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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).
|
||||
*/
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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")
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user