diff --git a/.claude/skills/searchable-events/references/searchable-kinds.md b/.claude/skills/searchable-events/references/searchable-kinds.md index 532ecc3d68..02761064ca 100644 --- a/.claude/skills/searchable-events/references/searchable-kinds.md +++ b/.claude/skills/searchable-events/references/searchable-kinds.md @@ -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 | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 32fad8469a..509202f13c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 4683d17921..f9b2ce31d3 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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 diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 4235feb76b..f0ba59cb7f 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -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 diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index 81052bac97..2b93dbd05f 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -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 diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/actions/uploads/FileProviderPathsTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/actions/uploads/FileProviderPathsTest.kt index ae096595f1..0bb6202786 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/actions/uploads/FileProviderPathsTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/actions/uploads/FileProviderPathsTest.kt @@ -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 diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt index f9bd93d747..19935df1a3 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt @@ -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> = emptyList() - suspend fun suggestAltText(uri: Uri): String? = null + override suspend fun suggestAltText(uri: Uri): String? = null - fun close() {} + override fun close() {} } diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 91acb98039..467e9ac3d9 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -90,6 +90,11 @@ + + + + @@ -556,6 +561,25 @@ + + + + + + + + + { val events = - Amethyst.instance.cache + LocalCache .filter( Filter( kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND, RootSiteEvent.KIND, NamedSiteEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt index 711c20d74e..d317102733 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index 34743eeada..74d92448ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -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.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 by lazy { + override val geohashStateFlow: StateFlow 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 by lazy { + override val preciseGeohashStateFlow: StateFlow by lazy { buildGeohashStateFlow( tag = "PreciseGeohashStateFlow", charsCount = GeohashChannelLevel.BUILDING.chars, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt index bdb675c0e7..b4fc5a6aac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt index 9a2021d43d..91d004f271 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt index 80d8f92e46..1b82cf7a04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt index 42b4222dbd..a27c24e5e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt index 3f8d5fff0c..bab0db155d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReactionNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReactionNotification.kt index 0570b46bce..032b0eddea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReactionNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReactionNotification.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt index f8bb2d5828..c49fc9c28d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/RepostNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/RepostNotification.kt index 2e278ca683..837ba7f550 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/RepostNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/RepostNotification.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt index d6674e156b..92e9e002f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidAppIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidAppIcon.kt new file mode 100644 index 0000000000..7cea29a589 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidAppIcon.kt @@ -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(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidNowPlayingAccess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidNowPlayingAccess.kt new file mode 100644 index 0000000000..93bb929353 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidNowPlayingAccess.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/InAppPlayback.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/InAppPlayback.kt new file mode 100644 index 0000000000..3515857edd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/InAppPlayback.kt @@ -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>(emptyMap()) + + val flow: StateFlow> = 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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/NowPlayingStatusCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/NowPlayingStatusCoordinator.kt new file mode 100644 index 0000000000..40485ae3df --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/NowPlayingStatusCoordinator.kt @@ -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, + private val relayServices: Flow<*>, + private val inApp: StateFlow> = InAppPlaybackRegistry.flow, + private val otherApps: StateFlow = 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() + + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/OtherAppsNowPlayingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/OtherAppsNowPlayingService.kt new file mode 100644 index 0000000000..a461d2b516 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/OtherAppsNowPlayingService.kt @@ -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(null) + + val flow: StateFlow = 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>() + + // App labels come from PackageManager, an IPC; players report state up to once a second. + private val labels = mutableMapOf() + + 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) { + 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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 6463ff915f..6785b44283 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index 18348acf29..0761745b1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/IsHlsMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/IsHlsMedia.kt deleted file mode 100644 index 464553f440..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/IsHlsMedia.kt +++ /dev/null @@ -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/` 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt index 9a3096fe8c..4c6c5871fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt @@ -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(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/` with no extension @@ -81,14 +65,6 @@ class MediaItemCache : GenericBaseCache(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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index aa969245d4..888df68e76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -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()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index 6e9c18c89c..c6de2f111e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt index bf4189fdba..99135a4755 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt index e268a9d483..a496e38c19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt index 2e670493a4..8c4bf3638b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt @@ -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,19 +361,26 @@ class AndroidMediaUploader( ) val finalState = - verifyHeader( - uploadResult = result, - localContentType = contentType, - okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, - originalHash = originalHash, - originalContentType = contentTypeForResult, - ) + 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, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + originalHash = originalHash, + originalContentType = contentTypeForResult, + ) + } // BUD-04: replicate the blob to the user's other Blossom servers for redundancy. // Fire-and-forget on the account scope AFTER the upload is finished: mirroring is // pure background redundancy, so it must never delay, alter, or fail the upload the // user already completed, and must not touch the on-screen progress state. - if (finalState is UploadingState.Finished && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) { + 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index f8aaa70f92..61b3fc61de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -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(body) } + +private const val SHA256_HEADER = "X-SHA-256" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt index ffc280a8b1..b88fa27907 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 1e0a0202c3..7a7d55aa09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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,7 +63,11 @@ class MainActivity : AppCompatActivity() { setContent { AmethystTheme { NowProvider { - AccountScreen(Amethyst.instance.sessionManager) + // 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) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index e093d90651..56faf351c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 68377fd17c..8f24845dad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index 13069aa9df..33be70f938 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt index d72db6f0ea..50262e6e54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index de9d78d496..7df22cea86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt index fe3f79176a..cfc50ac5a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SharedMediaResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SharedMediaResolver.kt deleted file mode 100644 index 465afd5ee8..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SharedMediaResolver.kt +++ /dev/null @@ -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, -): ImmutableList { - 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() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt index 0ea2b1c747..bb8e710f24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt @@ -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, - 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). diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt index f43c6b5a19..8ed32a442b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt index 892b84128b..2181d9cd0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt @@ -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, - 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt index 37afb26a79..d84ac1db1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 8e982bba44..ded4209c58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b7271fb601..56689d78ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { ProfileUiSettingsScreen(accountViewModel, nav) } composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } composableFromEnd { CallSettingsScreen(accountViewModel, nav) } + composableFromEnd { + val context = LocalContext.current + val access = remember(context) { AndroidNowPlayingAccess(context.applicationContext) } + NowPlayingSettingsScreen(accountViewModel, nav, access) { appId, label -> AndroidAppIcon(appId, label) } + } composableFromEnd { NotificationSettingsScreen(accountViewModel, nav) } composableFromEnd { ResourceUsageScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index e947d9f041..5190d02c82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt index a9133a7c43..061581ce95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt @@ -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) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AroundMeLocationLabel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AroundMeLocationLabel.kt index dae3a52c12..37716f40e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AroundMeLocationLabel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AroundMeLocationLabel.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 88beb7be95..38be6838c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -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 { - 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt index 9fbe8d74f1..fb26066669 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt index 02445f15df..9621f5b755 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt index 217e151d5f..746fce691d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollConsensusThreshold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollConsensusThreshold.kt index c05c747b13..4f8273b3e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollConsensusThreshold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollConsensusThreshold.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/platform/AndroidNotePlatform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/platform/AndroidNotePlatform.kt index f887f9ff58..9a1b12e86e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/platform/AndroidNotePlatform.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/platform/AndroidNotePlatform.kt @@ -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, + 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt index a218c7d509..456783c0cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt index 9acc19fe61..46d47a6e65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt index d1afd49999..6f51e05d79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index d6017a041d..67ab2f71bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt index 137fc0b083..745a0d2a72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppPlatform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppPlatform.kt index 468d992788..5be307c587 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppPlatform.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppPlatform.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppServices.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppServices.kt index b31a64e385..f6f1375a9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppServices.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppServices.kt @@ -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 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModelAndroidActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModelAndroidActions.kt index d4d9d0c2c5..ff7f245dd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModelAndroidActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModelAndroidActions.kt @@ -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?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AndroidAccountViewModelHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AndroidAccountViewModelHost.kt index 3f7a1d7466..1e38a05e73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AndroidAccountViewModelHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AndroidAccountViewModelHost.kt @@ -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 = LocalPreferences.hasBackedUpKeys(npub) + + override suspend fun setHasBackedUpKeys( + npub: String, + value: Boolean, + ) = LocalPreferences.setHasBackedUpKeys(value, npub) + + override suspend fun anonymizeVoice( + input: Path, + presetName: String, + ): Result = 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> = LocalPreferences .accountsFlow() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 557a03c373..52947ae777 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 8312cf5c02..d1f1967776 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt deleted file mode 100644 index ca9386f767..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt +++ /dev/null @@ -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(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) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt index f0835ff2e2..01da8dee98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt @@ -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. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt index 528deed877..aca15d390a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt index cd03b8f84b..1c7dd54571 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt index aa4f0a7981..03701e97df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt @@ -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 -> "—" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt index 7c874b1323..705333eca4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt index d07e649d06..dad7066cc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt deleted file mode 100644 index 7f026605ab..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt +++ /dev/null @@ -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>() - - 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) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt index 4b504f1c73..6e048018da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt index 0403996741..b43337ca94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/IcsShare.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/IcsShare.kt deleted file mode 100644 index fad8c29ef1..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/IcsShare.kt +++ /dev/null @@ -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 `` 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) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt index c72f18e79b..5050bf3973 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt @@ -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 /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt index f5815f5152..55689c5623 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt @@ -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(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() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt index b789cd5a8e..740aa35c9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/QuickZapAmountRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/QuickZapAmountRow.kt new file mode 100644 index 0000000000..00e43f4b4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/QuickZapAmountRow.kt @@ -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 -> + // 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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 76540a7d80..78eefa0c0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt index 607b791ad4..d5bb8da50a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 324fd1b444..bb51b1c646 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 1f5b5d2314..aa9c4ebe6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 4a6e0a8ddb..4ab05433ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt index 22c74905d0..1d6270286f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt index cee705213a..f8652808a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt @@ -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) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt deleted file mode 100644 index 5b66e7921c..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt +++ /dev/null @@ -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(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) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt deleted file mode 100644 index d3d833908c..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt +++ /dev/null @@ -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(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) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt index 2ea4775057..1ef0ed505c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt @@ -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). */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestChatPanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestChatPanel.kt index 33273adf90..814f7a393d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestChatPanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestChatPanel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt index 1a371c87b1..a83271bea9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt @@ -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") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index a8329a987c..4bdcf09f49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index 784402841a..c50272a55c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold 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.notifications.ObserveInboxRelayListAndDisplayIfNotFound import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardComposePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardComposePreview.kt index eda5b2b6e6..03b466f446 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardComposePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardComposePreview.kt @@ -36,6 +36,8 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.ui.components.LoadNote import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.pictures.PictureCardCaption +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.pictures.PictureCardCompose import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt index 6807e8465a..0b56c3bddb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.commons.ui.note.CheckHiddenFeedWatchBlockAndReport import com.vitorpamplona.amethyst.commons.ui.note.WatchNoteEvent +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.profile.gallery.GalleryThumbnail import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt index 647bbdfc7c..5cdec7ba24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt @@ -46,10 +46,10 @@ import com.vitorpamplona.amethyst.commons.resources.profile_banner import com.vitorpamplona.amethyst.commons.resources.profile_image import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.ui.components.util.setText +import com.vitorpamplona.amethyst.commons.ui.note.platform.ZoomableImageDialog import com.vitorpamplona.amethyst.commons.ui.painterRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt index cc5d516355..c7c08b227f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt @@ -64,13 +64,13 @@ import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelectSingle import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.commons.ui.note.platform.ZoomableImageDialog import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size100dp import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp import com.vitorpamplona.amethyst.commons.ui.theme.userProfileBorderModifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.UserExternalIdentitiesViewModel -import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt index fef65c0796..e8c363be58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt @@ -71,6 +71,8 @@ import com.vitorpamplona.amethyst.commons.resources.send_payment_method_onchain import com.vitorpamplona.amethyst.commons.ui.components.util.setText import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.profile.header.Bolt12OffersDialog +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.profile.header.abbreviateBolt12Offer import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.commons.ui.theme.Size16Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt index 0ad2717538..283adadbc8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt @@ -45,12 +45,12 @@ import com.vitorpamplona.amethyst.commons.nip85TrustedAssertions.ui.EditNickname import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserNickname import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.nickname_private +import com.vitorpamplona.amethyst.commons.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.commons.ui.richtext.CreateTextWithEmoji import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList /** * The nickname (petname) and private note the account keeps for [baseUser], diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt index 91bb215d53..4a3a9b18f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt @@ -48,10 +48,10 @@ import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection import com.vitorpamplona.amethyst.commons.ui.components.util.setText +import com.vitorpamplona.amethyst.commons.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.commons.ui.note.externalLinkForUser import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.quartz.nip56Reports.ReportType import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt index f30f5a1bbe..c695660415 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt @@ -488,7 +488,7 @@ private fun SendPaymentLoaded( amount: Long?, followMoved: Boolean, ) { - 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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt index 9bd8dff391..815f999872 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt @@ -44,11 +44,11 @@ import com.vitorpamplona.amethyst.commons.resources.public_notif_section_explain import com.vitorpamplona.amethyst.commons.ui.components.util.setText import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.RelayCompose +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.MyRelayInfo import com.vitorpamplona.amethyst.commons.viewmodels.RelayFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import kotlinx.coroutines.launch @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt index 7a42bf22e3..c8245ef129 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt @@ -279,7 +279,7 @@ class HlsPublishOrchestrator( // scope; NewHlsVideoViewModel.cancel() calls reset() to put state back to Idle. throw e } catch (e: Throwable) { - _state.value = HlsPublishState.Failure(message = e.message ?: e::class.simpleName.orEmpty()) + _state.value = HlsPublishState.Failure(message = e.message ?: e.javaClass.simpleName.orEmpty()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 04a3631842..13abace69b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.model.AccentColorType import com.vitorpamplona.amethyst.commons.model.FontFamilyType import com.vitorpamplona.amethyst.commons.model.FontSizeType import com.vitorpamplona.amethyst.commons.model.ThemeType +import com.vitorpamplona.amethyst.commons.ui.components.DefaultInlineQuoteRenderer import com.vitorpamplona.amethyst.commons.ui.components.LocalInlineQuoteRenderer import com.vitorpamplona.amethyst.commons.ui.components.LocalTranslationPlatform import com.vitorpamplona.amethyst.commons.ui.note.platform.LocalNotePlatform @@ -54,7 +55,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.isDarkTheme import com.vitorpamplona.amethyst.commons.ui.theme.isLight import com.vitorpamplona.amethyst.commons.ui.theme.transparentBackground import com.vitorpamplona.amethyst.ui.components.AndroidRichTextPlatform -import com.vitorpamplona.amethyst.ui.components.DefaultInlineQuoteRenderer import com.vitorpamplona.amethyst.ui.components.FlavorTranslationPlatform import com.vitorpamplona.amethyst.ui.note.platform.AndroidNotePlatform import com.vitorpamplona.amethyst.ui.platform.AndroidAppPlatform diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt index 16f827f205..56f3a337c4 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt @@ -29,6 +29,7 @@ import com.google.mlkit.genai.imagedescription.ImageDescriber import com.google.mlkit.genai.imagedescription.ImageDescriberOptions import com.google.mlkit.genai.imagedescription.ImageDescription import com.google.mlkit.genai.imagedescription.ImageDescriptionRequest +import com.vitorpamplona.amethyst.commons.service.ai.AltTextSuggester import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -41,7 +42,7 @@ import kotlinx.coroutines.withContext */ class MLKitImageLabelService( private val context: Context, -) { +) : AltTextSuggester { private var describer: ImageDescriber? = null // FeatureStatus is an Int enum. Cached per-instance — describer availability does not flip @@ -58,7 +59,7 @@ class MLKitImageLabelService( null } - suspend fun suggestAltText(uri: Uri): String? = describeWithGenAi(uri) + override suspend fun suggestAltText(uri: Uri): String? = describeWithGenAi(uri) private suspend fun describeWithGenAi(uri: Uri): String? = withContext(Dispatchers.IO) { @@ -116,7 +117,7 @@ class MLKitImageLabelService( return sample } - fun close() { + override fun close() { describer?.close() describer = null cachedGenAiStatus = null diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnScreenIndependenceTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnScreenIndependenceTest.kt index e5a28cf819..d45467d629 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnScreenIndependenceTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnScreenIndependenceTest.kt @@ -41,20 +41,32 @@ import java.io.File * asks for, added with the first screen rather than after the fifth. */ class CordnScreenIndependenceTest { - private val screensRoot: File by lazy { + /** + * The chats screen packages, in the app and in `commonsUI`: screens move from the first to the + * second one by one, and the guard has to follow them. + */ + private val screensRoots: List by lazy { // Resolved, not assumed: a wrong root makes an architecture test pass // for the wrong reason, which is worse than not having one. - generateSequence(File(".").absoluteFile) { it.parentFile } - .map { File(it, "amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats") } - .firstOrNull { it.isDirectory } - ?: error("cannot locate the chats screen package from ${File(".").absolutePath}") + val repo = + generateSequence(File(".").absoluteFile) { it.parentFile } + .firstOrNull { File(it, "commonsUI").isDirectory && File(it, "amethyst").isDirectory } + ?: error("cannot locate the repository root from ${File(".").absolutePath}") + listOf( + "amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats", + "commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats", + ).map { File(repo, it) } + .filter { it.isDirectory } + .ifEmpty { error("cannot locate the chats screen packages under $repo") } } private fun kotlinFilesIn(pkg: String): List = - File(screensRoot, pkg) - .walkTopDown() - .filter { it.isFile && it.extension == "kt" } - .toList() + screensRoots.flatMap { root -> + File(root, pkg) + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .toList() + } private fun importsMatching( pkg: String, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt index ba5ce493c0..18aeddc54c 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.notifications import android.os.Looper import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.notifications.NotificationContent import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentPrivateZapTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentPrivateZapTest.kt index 0097f9855d..e74fff86c9 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentPrivateZapTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentPrivateZapTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.notifications +import com.vitorpamplona.amethyst.commons.notifications.NotificationContent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/QrPayloadTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/QrPayloadTest.kt index e00804aded..0e5a42badd 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/QrPayloadTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/QrPayloadTest.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.note.share import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.ui.note.share.QrPayloadMode +import com.vitorpamplona.amethyst.commons.ui.note.share.qrPayloadFor import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt index 87b3beca2f..c97b8c2673 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.subscriptions.aggregateSubscriptions import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import org.junit.Assert.assertEquals diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 86c5c10c33..5903154980 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -87,8 +87,9 @@ kotlin { // the ForwardingFileSystem the apps wrap Coil's disk cache in. It // used to arrive transitively through Coil; with Coil in // :commonsUI the Apple targets lost it (JVM still saw it via - // OkHttp), so declare the dependency the file actually has. - implementation(libs.okio) + // OkHttp), so declare the dependency the file actually has. `api` + // because voice recordings cross module boundaries as okio Paths. + api(libs.okio) // DataStore (KMP, Apache-2.0) — the preference storage layer. // Publishes android/jvm/ios/linux/macos variants plus common diff --git a/commons/plans/2026-09-27-one-ui-android-desktop.md b/commons/plans/2026-09-27-one-ui-android-desktop.md index cfdef91a50..891b398efc 100644 --- a/commons/plans/2026-09-27-one-ui-android-desktop.md +++ b/commons/plans/2026-09-27-one-ui-android-desktop.md @@ -426,13 +426,70 @@ assemblers, `EventSync`, …). Packages are renamed on the way: image. They now call the shared `uploadToDefaultServer`. - Moved: the profile editor, the emoji pack screen and the emoji pack metadata screen (7 files). - - **Next:** most composer screens still exit through `ShowImageUploadItem` (the upload - preview, which uses Android media APIs to show a thumbnail) and `SelectFromFiles`. - After those comes the per-composer state: `ShortNotePostViewModel`, `PreviewState` - and the location pickers. The video player stack (media3) holds about 22 screens. - Smaller exits remain: the Android-only `AccountViewModel` actions, - `CalendarTimeFormat`, `NestUiState`, `NappletLauncher`, `UpdateZapAmountDialog` - (biometrics), the `java.time` pickers and the language list. + - **Wave 7 (2026-09-29):** + - The upload preview, the file picker and the single-document picker are shared. + - A shared wallet-app launcher, edge-to-edge dialog properties, `CashuWalletDiscovery` + in commonMain, the Namecoin resolver on `AppServices`, and `ReusableZapButton` as a + slot. + - Moved: the wallet screen, the discovery tabs, badges, communities, music playlists, + Concord create/edit, the Cashu wizard, podcast show/trailer, geocache log and the + visualizer settings. 136 of 252 screens shared. + - **Player seam (2026-09-30):** the media3 player stays in the app, like WebView. Shared + UI reaches it only through `NotePlatform` slots. + - Twenty-three screens used to reach it through side doors: direct calls to + `EditPostView`, `ZoomableContentView`, the audio/music/voice renderers and + `VideoViewInner`. They now call the slots, plus a new `FullscreenVideoView` slot. + - No screen reaches `service/playback` now. + - Moved: the thread screen, bookmark groups, the URL feed and relay-group metadata. + - **Later:** sharing the player *chrome* (voice/music/podcast rows, controls, waveform) + needs a `PlaybackController` port: state flows plus play/pause/seek/mute, wrapping the + pooled media3 `MediaController` on Android. Do it when Desktop gets a player, and pick + that engine through the license gate. VLCJ's README says "GPL, version 3 or later", + with no linking exception (a commercial license is sold separately), so it is a stop. + - **Wave 8 (2026-09-30):** the composers. + - Ports: `FileSharer` (the relay ZIP export), `TakePicture`/`TakeVideo`, + `rememberCoarseLocationPermission`, `SharedMediaResolver` + `OnIncomingShare` (SEND + intents), `DeviceLocation` (on `AccountViewModelHost` and `AppServices`), + `AltTextSuggester`, `BlossomServerFinder`, `mediaUriOfFile`, `availableProcessors`, + the language names, `LocalClock.utcOffsetSeconds` and `rememberIs24HourClock`. + - `MediaUploader` gained `remoteFileHeader` (imeta for pasted links) and `displayName`. + The host gained `scheduledPostStore`, `anonymizeVoice` and `createWritingAssistant`. + - Voice recordings are okio `Path`s. `commons` exposes okio as `api`. + - `WalletAppLauncher` opens any payment URI; BOLT-11 and BOLT-12 are extensions. + - Toasts go through `rememberShortNotice`/`rememberLongNotice`. + - Moved: every note composer and `ShortNotePostViewModel`; the public, ephemeral, + geohash, DM, Marmot, Concord, Minichat and relay-group chats; the media feeds; the + relay settings; the metadata editors; podcasts; Bolt12 offers; the vanish and + language settings. 180 of 252 screens shared. + - **Wave 9 (2026-09-30):** `AppServices` gained `appStores`, `torBootstrapped` and + `cachedPlaceName`; the host gained the backed-up-keys flag. `rememberDeviceAuthenticator` + wraps BiometricPrompt with the keyguard fallback. `CalendarTimeFormat` is shared on + `DateSkeletonFormatter`. Moved: search, the long-form composer, the calendar collection + editor, NWC setup and the zap-amount settings. 185 of 252 screens shared. + - **Wave 10 (2026-09-30):** Home, Discover, the nest lobby and the Marmot group editor + first; then the date pickers on `LocalClock` (poll and zap-poll deadlines, schedule-at + with its presets), `FileSharer.shareTextFile` (the .ics export), `PhoneCalendar` (the + system event composer), `relativeTimeSpan` (`DateUtils` / `NSRelativeDateTimeFormatter`), + `rememberWindowViewModel` + `LocalWindowViewModelStoreOwner` (state shared across + destinations: the chess lobby and board, the Cordn group draft), `isHlsMedia` in + `commons.video`, `ChessEventBroadcaster` in commonMain and `supportsWritingAssistant` on + the host. `geo:` links go through `LocalUriHandler`. Moved: the note and poll composers, + voice reply, the calendar collections and event detail, the geocache detail and editor, + the live-activity channel, chess, Cordn group creation, the QR share and the compose + settings. 204 of 252 screens shared. + - **Next:** 48 screens remain, and what blocks them is the platform itself: + - `ReactionsRow` (2.7k lines: pay-to-app, voice replies, wallet intents). + - The Nests activity and PiP. + - The call screens. + - The WebView browser and napplet launcher. + - The osmdroid geocache map. + - Health Connect workouts. + - The Cordn backup/migrate file flows. + - Share-as-image (bitmaps). + - `AccountSessionManager` (login and sign-up). + - The Blossom health probe. + - `AppSettingsScreen` (`BuildConfig`). + Each needs a slot or a port of its own, so step 7 can start alongside them. 7. **Navigation**: the library swap, then `AppNavigation` + rail + drawer + bottom bar. 8. **The app root port** and the new JVM shim. Then the Desktop feature inventory, and retiring the old `desktopApp`. diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.android.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.android.kt index b38e335f3f..53d0e23e82 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.android.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.android.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.amethyst.commons.service.uploads import android.net.Uri +import okio.Path actual typealias MediaUri = Uri actual fun MediaUri.lastPathSegmentOrNull(): String? = lastPathSegment + +actual fun mediaUriOfFile(path: Path): MediaUri = Uri.fromFile(path.toFile()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VoiceRecording.kt similarity index 62% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VoiceRecording.kt index a0cba559e4..7529c01300 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VoiceRecording.kt @@ -18,19 +18,27 @@ * 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.chess +package com.vitorpamplona.amethyst.commons.audio -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import com.vitorpamplona.amethyst.Amethyst +import okio.Path + +/** A finished voice-message recording: the audio file, its type, and its waveform. */ +class RecordingResult( + val file: Path, + val mimeType: String, + val amplitudes: List, + val duration: Int, +) /** - * Where Android keeps the dismissed-chess-games store. + * A recording re-voiced for privacy. * - * A new file rather than a migration of `chess_dismissed_games`: the dismissed - * list is a convenience, and chess has few enough users that carrying the old - * data over is not worth the code. Anyone who had dismissed a game sees it once - * more and dismisses it again. + * @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 */ -internal val chessDismissedGamesData: DataStore - get() = Amethyst.instance.appStores.getDataStore("chess_dismissed_games_v2") +data class AnonymizedResult( + val file: Path, + val waveform: List, + val duration: Int, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/publicChannels/concord/ConcordUnread.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/publicChannels/concord/ConcordUnread.kt index 4e4f6bb5f0..02a6de045d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/publicChannels/concord/ConcordUnread.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/publicChannels/concord/ConcordUnread.kt @@ -100,7 +100,7 @@ fun concordCommunityHasUnreadFlow( /** * True for a note the Concord channel *timeline* actually renders — the same predicate as - * [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedFilter]'s + * [com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedFilter]'s * `isTimelineMessage`: a loaded, acceptable message that is **not** a kind-1111 [CommentEvent]. * * A [CommentEvent] is a *minichat thread reply* that lives inside its parent's thread, not on the diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt index dca2865779..557380d38c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt @@ -93,6 +93,8 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListS import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.OwnedEmojiPacksState import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState import com.vitorpamplona.amethyst.commons.model.nip46Signer.Nip46ConsentPrompter import com.vitorpamplona.amethyst.commons.model.nip46Signer.Nip46SignerState import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcInfoCache @@ -185,6 +187,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.service.upload.FileHeader import com.vitorpamplona.amethyst.commons.util.logTime import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode +import com.vitorpamplona.quartz.buzz.media.BuzzImeta import com.vitorpamplona.quartz.buzz.threading.buzzThread import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat @@ -532,6 +535,11 @@ class Account( // channel list. Restored/persisted per account by BuzzChannelStarStore. val buzzChannelStars = BuzzChannelStars() + // Whether THIS account shares what the user listens to as its NIP-38 music status, and from + // which apps. Per account because the status is signed and published by this key. Restored/ + // persisted per account by NowPlayingSettingsStore on Android. + val nowPlayingSettings = NowPlayingSettingsState() + // The NIP-OA attestation an owner issued to THIS account's key, attached to its Buzz-relay // AUTH so the relay grants virtual membership. Restored/persisted per account by // BuzzAttestationStore. @@ -2201,7 +2209,9 @@ class Account( // quick reply is notified (`p`) and their reference resolves. The reply-parent author is // already tagged by each builder below, so drop it from the body mentions to avoid a // duplicate `p`. - val tagger = NewMessageTagger(text, dao = LocalCache) + // Buzz shows a `nostr:` reference verbatim, so a Buzz thread names people as plain `@Name`. + val buzzGroup = gatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }?.takeIf { BuzzRelayDialect.isBuzz(it.groupId.relayUrl) } + val tagger = NewMessageTagger(text, dao = LocalCache, userMentionsAsNames = buzzGroup != null) tagger.run() val mentions = tagger.pTags?.mapNotNull { it.pubkeyHex.takeIf { pk -> pk != rootEvent.pubKey } }.orEmpty() val finalText = appendMediaUrls(tagger.message, imetas) @@ -2237,15 +2247,18 @@ class Account( // their clients no longer thread on. Reading 40002 stays supported (see // [com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply]). // - // Attached media rides as URLs appended to the content. + // Attached media rides as URLs appended to the content, plus Buzz-shaped `imeta`. val root = rootEvent.tags.buzzThreadRootForReplyTo(rootEvent.id) signer.sign( - ChatEvent.build(finalText) { + // Buzz draws an attachment only where the body links it as `![image](url)`. + ChatEvent.build(BuzzImeta.markdownMediaBody(finalText, imetas)) { hTag(group.groupId.id) buzzThread(root, rootEvent.id) rootNote.author?.pubkeyHex?.let { pTag(PTag(it)) } pTags(mentions.map { PTag(it) }) previous(group.previousEventRefs(pubKey)) + // Buzz refuses the whole event over any imeta key it doesn't allow. + imetas(BuzzImeta.sanitize(imetas)) }, ) } else { @@ -3345,6 +3358,13 @@ class Account( suspend fun deleteStatus(oldStatus: AddressableNote) = sendMyPublicAndPrivateOutbox(UserStatusAction.delete(oldStatus, signer)) + suspend fun publishNowPlaying( + track: NowPlaying, + expiration: Long, + ) = sendMyPublicAndPrivateOutbox(UserStatusAction.createMusic(track, expiration, signer)) + + suspend fun clearNowPlaying() = sendMyPublicAndPrivateOutbox(UserStatusAction.clearMusic(signer)) + suspend fun removeEmojiPack(emojiPack: Note) = sendMyPublicAndPrivateOutbox(emoji.removeEmojiPack(emojiPack)) suspend fun addEmojiPack(emojiPack: Note) = sendMyPublicAndPrivateOutbox(emoji.addEmojiPack(emojiPack)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt index b9f51ea649..1abf949bab 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks @@ -82,6 +83,8 @@ import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.uuid.ExperimentalUuidApi @@ -111,14 +114,59 @@ class AccountRelayGroupActions( account.follow(channel) } + // "relay|profile content" pairs already pushed this session, so each profile version goes to each + // workspace once. Keyed on content, not event id: the copy we sign comes back from the relay as a + // newer kind-0 with the same content, and must not trigger another signature. + private val profileSharedWith = MutableStateFlow>(emptySet()) + + /** + * Push my current kind-0 profile to the Buzz workspace at [relay]. Buzz reads member names and + * pictures only from its own relay, and its people search (mentions, Add people, new DM) is by + * name — without this, Buzz showed me as a bare npub and nobody could find or add me. + * + * Buzz refuses an event whose timestamp is far from its clock ("event timestamp too far from + * server time"), so the already-signed profile — usually days old — can't be relayed as-is. Like + * Buzz's own client, this signs a fresh copy with the same content and tags, and sends it ONLY to + * that workspace, once per profile version. + */ + suspend fun shareProfileWithBuzzWorkspace(relay: NormalizedRelayUrl) { + if (!account.isWriteable()) return + val profile = account.userMetadata.getUserMetadataEvent() ?: return + val key = "${relay.url}|${profile.content.hashCode()}|${profile.tags.contentDeepHashCode()}" + while (true) { + val current = profileSharedWith.value + if (key in current) return + if (profileSharedWith.compareAndSet(current, current + key)) break + } + try { + val fresh = + if (TimeUtils.now() - profile.createdAt < BUZZ_FRESH_PROFILE_SECS) { + profile + } else { + account.signer.sign(TimeUtils.now(), MetadataEvent.KIND, profile.tags, profile.content) + } + account.client.publish(fresh, setOf(relay)) + } catch (e: Throwable) { + // Not shared (signer refused, timed out, …): forget the attempt so a later visit retries. + profileSharedWith.update { it - key } + throw e + } + } + /** * Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral * (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter * our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS]. + * [threadRootId]/[replyToId] scope it to a thread, as Buzz's thread composer does. */ - suspend fun sendBuzzTyping(channel: RelayGroupChannel) { + suspend fun sendBuzzTyping( + channel: RelayGroupChannel, + threadRootId: HexKey? = null, + replyToId: HexKey? = null, + ) { if (!account.isWriteable()) return - val signed = account.signer.sign(TypingIndicatorEvent.build(channel.groupId.id)) + // In a thread, the `e` markers scope the signal to it: Buzz shows it in that thread's pane. + val signed = account.signer.sign(TypingIndicatorEvent.build(channel.groupId.id, threadRootId, replyToId)) account.client.publish(signed, setOf(channel.groupId.relayUrl)) } @@ -663,3 +711,6 @@ internal fun buzzRoleFor(roles: List): String { else -> BUZZ_ROLE_MEMBER } } + +/** A profile signed this recently is within Buzz's timestamp window and can be relayed as-is. */ +private const val BUZZ_FRESH_PROFILE_SECS = 60L diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ClientTag.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ClientTag.kt new file mode 100644 index 0000000000..e480bcc672 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ClientTag.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model + +/** The name this app signs into NIP-89 `client` tags, and recognizes on events it wrote. */ +const val AMETHYST_CLIENT_TAG_NAME = "Amethyst" diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index eab486d0d5..24b28d472c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -163,6 +163,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.ballots.BallotEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent @@ -191,6 +192,7 @@ import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent import com.vitorpamplona.quartz.experimental.publications.PublicationContentEvent @@ -388,6 +390,7 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.UnrecognizedKind38000Event import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -1382,6 +1385,13 @@ open class EventCache : listOfNotNull(event.parentPullRequestId()?.let { checkGetOrCreateNote(it) }) } + is ForumCommentEvent -> { + // A Buzz forum comment counts as a reply of its thread's root post (and of the comment + // it answers, when nested), so the forum list's reply count sees it. A direct reply + // carries only the `reply` marker, which then IS the root. + listOfNotNull(event.threadRoot(), event.replyTo()).distinct().mapNotNull { checkGetOrCreateNote(it) } + } + is GitStatusEvent -> { // A status event roots itself at a patch/PR/issue via a // marked-`root` `e` tag; link only that so the transition @@ -3962,6 +3972,11 @@ open class EventCache : is CashuMintEvent, is FedimintEvent, is MintRecommendationEvent, + // Other apps' kind-38000 formats, split from mint recommendations by EventFactory. + is BallotEvent, + is PredictionMarketEvent, + // Unread junk on 38000 still supersedes an older version at its address. + is UnrecognizedKind38000Event, -> consumeBaseReplaceable(event, relay, wasVerified) // ============================================================ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/AudienceSelection.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/AudienceSelection.kt index e5fdb7dd1f..6b869c1ff6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/AudienceSelection.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/AudienceSelection.kt @@ -31,7 +31,7 @@ import kotlinx.collections.immutable.persistentListOf * of the Account so they can be unit tested on the JVM. * * The picker never mutates anything itself: it produces a set of pubkeys the - * screen hands to [com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel.addAllToReplyList]. + * screen hands to [com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ShortNotePostViewModel.addAllToReplyList]. */ enum class AudienceListKind { /** NIP-51 kind 30000 people list. Can carry encrypted (private) members. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTagger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTagger.kt index 368c2f6d1f..f97915f85b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTagger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTagger.kt @@ -39,11 +39,17 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.toNpub import kotlinx.coroutines.CancellationException +/** + * @param userMentionsAsNames write a user mention as plain `@Name` text instead of a `nostr:` + * reference — the form Buzz clients send and render (the cited user still lands in [pTags], which is + * what notifies them). Buzz shows a `nostr:nprofile…` body verbatim. + */ class NewMessageTagger( var message: String, var pTags: List? = null, var eTags: List? = null, var dao: Dao, + val userMentionsAsNames: Boolean = false, ) { val directMentions = mutableSetOf() val directMentionsNotes = mutableSetOf() @@ -115,11 +121,11 @@ class NewMessageTagger( val results = parseDirtyWordForKey(word) when (val entity = results?.key?.entity) { is NPub -> { - getNostrAddress(dao.getOrCreateUser(entity.hex).toNProfile(), results.restOfWord) + userMention(dao.getOrCreateUser(entity.hex), results.restOfWord) } is NProfile -> { - getNostrAddress(dao.getOrCreateUser(entity.hex).toNProfile(), results.restOfWord) + userMention(dao.getOrCreateUser(entity.hex), results.restOfWord) } is com.vitorpamplona.quartz.nip19Bech32.entities.NNote -> { @@ -159,6 +165,19 @@ class NewMessageTagger( }.joinToString("\n") } + private fun userMention( + user: User, + restOfTheWord: String?, + ): String { + if (!userMentionsAsNames) return getNostrAddress(user.toNProfile(), restOfTheWord) + val name = "@" + user.toBestDisplayName() + return when { + restOfTheWord.isNullOrEmpty() -> name + restOfTheWord[0].isLetterOrDigit() -> "$name $restOfTheWord" + else -> name + restOfTheWord + } + } + fun getNostrAddress( bechAddress: String, restOfTheWord: String?, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/composer/PreviewState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/PreviewState.kt similarity index 98% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/composer/PreviewState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/PreviewState.kt index b5c748ecd5..b8f4939fd9 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/composer/PreviewState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/PreviewState.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.richtext.CachedUrlParser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/location/DeviceLocation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/location/DeviceLocation.kt new file mode 100644 index 0000000000..bc21155841 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/location/DeviceLocation.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.location + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * The device's position as geohashes, for "around me" feeds, location chats and the composers' + * location tag. The platform decides when it actually listens; readers only collect. + */ +interface DeviceLocation { + /** A roughly 5 km geohash cell, or why there is none. */ + val geohashStateFlow: StateFlow + + /** The finest geohash the platform offers, for location channels that truncate it per level. */ + val preciseGeohashStateFlow: StateFlow + + /** Tells the source whether the user has granted location access, so it can start or stop. */ + fun setLocationPermission(newValue: Boolean) + + /** No location source on this platform: always reports a missing permission. */ + object None : DeviceLocation { + private val lacking = MutableStateFlow(LocationResult.LackPermission) + + override val geohashStateFlow: StateFlow = lacking + override val preciseGeohashStateFlow: StateFlow = lacking + + override fun setLocationPermission(newValue: Boolean) = Unit + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/ServerName.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/ServerName.kt index 9fdfb3fafd..8c75a91a48 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/ServerName.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/ServerName.kt @@ -33,6 +33,22 @@ enum class ServerType { Blossom, NIP95, NIP96, + + /** + * A Buzz workspace's own Blossom endpoint (`https:///upload`). Blobs there are + * private to the workspace and the relay only accepts `imeta` URLs under its `/media/`, so an + * upload to it never falls back to, or mirrors onto, a public server. + */ + BuzzWorkspace, +} + +/** The media server of the Buzz workspace served at [relayUrl] (`wss://host` → `https://host`). */ +fun buzzWorkspaceServer(relayUrl: String): ServerName { + val host = + relayUrl + .substringAfter("://") + .substringBefore("/") + return ServerName(host, "https://$host", ServerType.BuzzWorkspace) } val DEFAULT_MEDIA_SERVERS: List = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt index 70b8daf092..f2a0d2e832 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt @@ -585,6 +585,8 @@ sealed class Route { @Serializable object CallSettings : Route() + @Serializable object NowPlayingSettings : Route() + @Serializable object NotificationSettings : Route() @Serializable object CalendarReminderSettings : Route() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt index 4fac63e096..57499eb3cb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent @@ -34,6 +35,25 @@ class UserStatusAction { type: String = UserStatusEvent.GENERAL, ): UserStatusEvent = UserStatusEvent.create(newStatus, type, signer = signer) + /** A `music` status for [track] that expires at [expiration], when the track ends. */ + suspend fun createMusic( + track: NowPlaying, + expiration: Long, + signer: NostrSigner, + ): UserStatusEvent = + UserStatusEvent.create( + msg = track.statusText(), + type = UserStatusEvent.MUSIC, + expiration = expiration, + url = track.url, + eventId = track.eventId, + addressableId = track.addressId, + signer = signer, + ) + + /** Clears the `music` status: NIP-38 clears a status by replacing it with blank content. */ + suspend fun clearMusic(signer: NostrSigner): UserStatusEvent = UserStatusEvent.create("", UserStatusEvent.MUSIC, signer = signer) + suspend fun update( oldStatus: AddressableNote, newStatus: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt index fc04d0352a..c5a548cc31 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt @@ -42,11 +42,16 @@ class UserStatusCache : UserDependencies { ) fun addStatus(note: AddressableNote) { - // if it's already there, quick exit - if (statuses.value.contains(note) || note.isEmptyStatus()) return + // A newer version of a listed status can be blank (NIP-38 clears a status that way, and + // a music status is cleared every time playback stops) or already expired: the note is + // the same object, so it has to leave the list rather than be skipped as already there. + if (note.isEmptyStatus() || note.event?.isExpired() == true) { + removeStatus(note) + return + } - // don't add expired statuses - if (note.event?.isExpired() == true) return + // if it's already there, quick exit + if (statuses.value.contains(note)) return statuses.update { (it + note).sortedWith(sortModel).toImmutableList() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlaying.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlaying.kt new file mode 100644 index 0000000000..32287b1968 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlaying.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.math.abs + +/** Where a track is being played. */ +@Immutable +sealed interface NowPlayingSource { + /** Amethyst's own player (a music track or podcast episode opened in the app). */ + data object InApp : NowPlayingSource + + /** + * Another app on this device. [id] is stable across sessions (an Android package name, an + * MPRIS bus name, a Windows app id) and is what the per-app block list keys on; [label] is + * what the settings screen shows. + */ + data class OtherApp( + val id: String, + val label: String, + ) : NowPlayingSource +} + +/** + * A track (song or podcast episode) the user is playing right now, ready to become a NIP-38 + * `music` status. + * + * [endsAt] is the Unix time (seconds) the track will finish at its current position and speed, + * which becomes the status' NIP-40 expiration, as NIP-38 asks for music. It is null when the + * length is unknown (a live stream, an app that does not report a duration). + * + * [url], [addressId] and [eventId] become the status' `r`, `a` and `e` tags, so readers can open + * what is playing: in-app tracks point at their Nostr event. + */ +@Immutable +data class NowPlaying( + val title: String, + val artist: String? = null, + val source: NowPlayingSource, + val endsAt: Long? = null, + val url: String? = null, + val addressId: String? = null, + val eventId: HexKey? = null, +) { + /** The status line, in the `Title - Artist` shape of the NIP-38 example. */ + fun statusText(): String = if (artist.isNullOrBlank()) title else "$title - $artist" + + /** Same track, regardless of the playback position. */ + fun isSameTrack(other: NowPlaying): Boolean = + title == other.title && + artist == other.artist && + url == other.url && + addressId == other.addressId && + eventId == other.eventId + + /** + * Same track from the same source, ending within [toleranceSeconds] of [other]. Sources call + * this to drop the position ticks some players report every second, which would otherwise + * re-emit a "new" value (and re-run everything downstream) for a status that has not changed. + */ + fun isSameMoment( + other: NowPlaying?, + toleranceSeconds: Long = MOMENT_TOLERANCE_SECONDS, + ): Boolean { + if (other == null || source != other.source || !isSameTrack(other)) return false + val a = endsAt + val b = other.endsAt + return if (a == null || b == null) a == b else abs(a - b) <= toleranceSeconds + } + + companion object { + const val MOMENT_TOLERANCE_SECONDS = 5L + + /** + * When a track [durationMs] long, now at [positionMs] and playing at [speed], ends, + * measured from [nowSeconds]. Null when the duration is unknown or the track already ended. + */ + fun endsAt( + nowSeconds: Long, + durationMs: Long?, + positionMs: Long, + speed: Float = 1f, + ): Long? { + if (durationMs == null || durationMs <= 0) return null + val remainingMs = durationMs - positionMs.coerceAtLeast(0) + if (remainingMs <= 0) return null + val effectiveSpeed = if (speed > 0f) speed else 1f + return nowSeconds + (remainingMs / effectiveSpeed / 1000).toLong() + 1 + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingAccess.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingAccess.kt new file mode 100644 index 0000000000..44d3f6db59 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingAccess.kt @@ -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.commons.model.nip38UserStatuses.nowPlaying + +/** + * The OS permission that sharing other apps' playback depends on. On Android that is notification + * access, which only the user can grant in system settings; platforms that need nothing pass no + * port at all. + */ +interface NowPlayingAccess { + fun hasAccess(): Boolean + + /** Takes the user to where the access is granted or revoked. */ + fun requestAccess() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisher.kt new file mode 100644 index 0000000000..55a6899d0e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisher.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.abs + +/** + * Picks what to share out of the tracks each source reports, in priority order (the in-app + * player first), skipping the sources [settings] does not allow. + */ +fun pickNowPlaying( + settings: NowPlayingSettings, + vararg candidates: NowPlaying?, +): NowPlaying? = candidates.firstOrNull { it != null && settings.isAllowed(it.source) } + +/** + * Turns a stream of "what is playing now" (null when nothing is) into NIP-38 `music` statuses. + * + * - A new track is published only after it has played for [Config.startDelayMs], so skipping + * through a playlist does not sign and send an event per skipped song. + * - The status expires when the track ends (NIP-38 asks music statuses for that), so a crash or a + * dead battery never leaves a stale "listening to". When the length is unknown it expires after + * [Config.unknownLengthTtlSeconds] and is refreshed shortly before that while still playing. + * - A seek or a resume that moves the end by more than [Config.toleranceSeconds] republishes the + * same track with the corrected expiration. + * - When playback stops, the status is cleared (blank content, as NIP-38 says) after + * [Config.clearDelayMs], so a short pause or a buffering stall does not clear and re-post it. + * + * Runs on a single coroutine ([run]); a newer value cancels whatever wait the previous one was in. + */ +class NowPlayingPublisher( + private val publish: suspend (track: NowPlaying, expiration: Long) -> Unit, + private val clear: suspend () -> Unit, + private val nowSeconds: () -> Long = TimeUtils::now, + private val config: Config = Config(), +) { + data class Config( + val startDelayMs: Long = 5_000, + val clearDelayMs: Long = 30_000, + val unknownLengthTtlSeconds: Long = 600, + val refreshLeadSeconds: Long = 60, + val toleranceSeconds: Long = 20, + ) + + private var published: NowPlaying? = null + private var publishedExpiration: Long = 0 + + suspend fun run(tracks: Flow) { + tracks + .distinctUntilChanged(::isEquivalent) + .collectLatest { track -> + if (track == null) stopped() else playing(track) + } + } + + private fun isEquivalent( + old: NowPlaying?, + new: NowPlaying?, + ): Boolean = + when { + old == null || new == null -> old == new + old.source != new.source || !old.isSameTrack(new) -> false + else -> isSameEnd(old.endsAt, new.endsAt) + } + + private fun isSameEnd( + a: Long?, + b: Long?, + ) = if (a == null || b == null) a == b else abs(a - b) <= config.toleranceSeconds + + /** The status on relays still shows [track]. */ + private fun isLive(track: NowPlaying) = published?.isSameTrack(track) == true && publishedExpiration > nowSeconds() + + private suspend fun playing(track: NowPlaying) { + if (!isLive(track)) delay(config.startDelayMs) + + while (true) { + val now = nowSeconds() + val expiration = track.endsAt?.takeIf { it > now } ?: (now + config.unknownLengthTtlSeconds) + + if (!isLive(track) || abs(publishedExpiration - expiration) > config.toleranceSeconds) { + send(track, expiration) + } + + // A known end needs nothing more: the next track, a seek or a stop comes in as a new value. + if (track.endsAt != null) return + + val refreshIn = (publishedExpiration - nowSeconds() - config.refreshLeadSeconds).coerceAtLeast(MIN_REFRESH_SECONDS) + delay(refreshIn * 1000) + } + } + + private suspend fun stopped() { + if (published == null) return + + if (publishedExpiration <= nowSeconds()) { + // Relays already dropped it. + published = null + return + } + + delay(config.clearDelayMs) + + try { + clear() + published = null + publishedExpiration = 0 + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("NowPlayingPublisher", "Could not clear the music status", e) + } + } + + private suspend fun send( + track: NowPlaying, + expiration: Long, + ) { + try { + publish(track, expiration) + published = track + publishedExpiration = expiration + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("NowPlayingPublisher", "Could not publish the music status", e) + } + } + + companion object { + private const val MIN_REFRESH_SECONDS = 60L + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolver.kt new file mode 100644 index 0000000000..8d203fc781 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolver.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.podcasts.PodcastEpisode + +/** + * Turns the Nostr event behind an in-app player into a [NowPlaying], or null when the event is + * not something to share as a listening status. + * + * Only music tracks (kind 36787), audio tracks (kind 31337) and podcast episodes (both drafts) + * qualify. Everything else the app plays (feed videos, voice messages, live streams) returns null, + * so autoplaying a clip never announces itself as "listening to". + * + * [fallbackTitle] and [fallbackArtist] are what the player shows (the event's title and the + * author's display name) and fill the gaps an event leaves. + */ +object NowPlayingResolver { + /** + * Resolves the `nostr:` URI the in-app player carries (`Note.toNostrUri()`) to its event with + * [findEvent] (given an event id or an `kind:pubkey:d` address) and then calls [fromEvent]. + */ + fun fromNostrUri( + uri: String, + findEvent: (key: String) -> Event?, + fallbackTitle: String? = null, + fallbackArtist: String? = null, + endsAt: Long? = null, + ): NowPlaying? { + val key = noteKey(uri) ?: return null + val event = findEvent(key) ?: return null + return fromEvent(event, fallbackTitle, fallbackArtist, endsAt) + } + + /** The cache key (event id, or `kind:pubkey:d` address) a `nostr:` note URI points at. */ + fun noteKey(uri: String): String? = + when (val entity = Nip19Parser.uriToRoute(uri)?.entity) { + is NEvent -> entity.hex + is NNote -> entity.hex + is NAddress -> entity.aTag() + else -> null + } + + fun fromEvent( + event: Event, + fallbackTitle: String? = null, + fallbackArtist: String? = null, + endsAt: Long? = null, + ): NowPlaying? { + val title: String? + val artist: String? + + when (event) { + is MusicTrackEvent -> { + title = event.title() + artist = event.artist() ?: fallbackArtist + } + + is AudioTrackEvent -> { + title = event.subject() + artist = fallbackArtist + } + + is PodcastEpisode -> { + title = event.episodeTitle() + artist = fallbackArtist + } + + else -> { + return null + } + } + + val bestTitle = title?.ifBlank { null } ?: fallbackTitle?.ifBlank { null } ?: return null + + return NowPlaying( + title = bestTitle, + artist = artist?.ifBlank { null }, + source = NowPlayingSource.InApp, + endsAt = endsAt, + addressId = (event as? AddressableEvent)?.address()?.toValue(), + eventId = if (event is AddressableEvent) null else event.id, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingSettings.kt new file mode 100644 index 0000000000..95139b35f0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingSettings.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +/** + * What this account shares as its NIP-38 `music` status. Everything is off by default: a listening + * status is public, so the user opts in. + * + * - [shareInApp]: tracks and podcast episodes played in Amethyst. + * - [shareOtherApps]: whatever another app on this device reports through the OS media controls + * (needs a platform permission on Android), except the apps in [blockedApps]. + * - [knownApps]: the other apps seen playing so far (id to label), listed on the settings screen + * so the user can block one. + */ +@Immutable +data class NowPlayingSettings( + val shareInApp: Boolean = false, + val shareOtherApps: Boolean = false, + val blockedApps: Set = emptySet(), + val knownApps: Map = emptyMap(), +) { + fun isEnabled() = shareInApp || shareOtherApps + + fun isAllowed(source: NowPlayingSource): Boolean = + when (source) { + NowPlayingSource.InApp -> shareInApp + is NowPlayingSource.OtherApp -> shareOtherApps && source.id !in blockedApps + } +} + +/** + * Per-account holder for [NowPlayingSettings] (`Account.nowPlayingSettings`). The platform + * restores and persists it (Android: `NowPlayingSettingsStore`). + */ +@Stable +class NowPlayingSettingsState { + private val state = MutableStateFlow(NowPlayingSettings()) + + val flow: StateFlow = state + + fun restore(settings: NowPlayingSettings) = state.update { settings } + + fun setShareInApp(enabled: Boolean) = state.update { it.copy(shareInApp = enabled) } + + fun setShareOtherApps(enabled: Boolean) = state.update { it.copy(shareOtherApps = enabled) } + + fun setAppBlocked( + appId: String, + blocked: Boolean, + ) = state.update { it.copy(blockedApps = if (blocked) it.blockedApps + appId else it.blockedApps - appId) } + + /** Records an app seen playing, so the settings screen can offer to block it. */ + fun rememberApp( + appId: String, + label: String, + ) { + if (state.value.knownApps[appId] == label) return + state.update { it.copy(knownApps = it.knownApps + (appId to label)) } + } + + fun forgetApp(appId: String) = + state.update { + it.copy(knownApps = it.knownApps - appId, blockedApps = it.blockedApps - appId) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingStatusService.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingStatusService.kt new file mode 100644 index 0000000000..cc82f52b05 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingStatusService.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach + +/** + * Wires the platform's playback sources to a [NowPlayingPublisher] under one account's + * [settings]. [sources] are in priority order: the first one playing an allowed track wins (the + * in-app player goes first, since the user is looking at it). + * + * Every other app seen playing while [NowPlayingSettings.shareOtherApps] is on is reported to + * [onOtherAppSeen], so the settings screen can list it and let the user block it. + */ +class NowPlayingStatusService( + private val settings: StateFlow, + private val sources: List>, + private val onOtherAppSeen: (NowPlayingSource.OtherApp) -> Unit, + private val publisher: NowPlayingPublisher, +) { + suspend fun run() { + val observed = + sources.map { source -> + source.onEach { track -> + val app = track?.source as? NowPlayingSource.OtherApp + if (app != null && settings.value.shareOtherApps) onOtherAppSeen(app) + } + } + + val tracks = + combine(listOf(settings) + observed) { values -> + val current = values[0] as NowPlayingSettings + pickNowPlaying(current, *Array(values.size - 1) { values[it + 1] as NowPlaying? }) + } + + publisher.run(tracks) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt similarity index 89% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt index 2c5e7b579b..ece4e7b3b4 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt @@ -20,14 +20,11 @@ */ package com.vitorpamplona.amethyst.commons.model.nip52Calendar +import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter /** * Serialises NIP-52 calendar events to RFC 5545 iCalendar (`.ics`) text. The output is the @@ -47,8 +44,6 @@ import java.time.format.DateTimeFormatter object IcsExport { private const val PRODID = "-//Amethyst//NIP-52//EN" private const val CRLF = "\r\n" - private val UtcStamp: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'") - private val IsoBasicDate: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd") fun appointmentToIcs( event: Any, @@ -189,14 +184,21 @@ object IcsExport { sb.append("CATEGORIES:").append(hashtags.joinToString(",") { escapeText(it) }).append(CRLF) } - private fun formatUtcInstant(unixSeconds: Long): String = UtcStamp.format(Instant.ofEpochSecond(unixSeconds).atOffset(ZoneOffset.UTC)) + /** `yyyyMMdd'T'HHmmss'Z'` in UTC. */ + private fun formatUtcInstant(unixSeconds: Long): String { + val date = SearchDate.civilFromDays(unixSeconds.floorDiv(SECONDS_PER_DAY)) + val secondOfDay = unixSeconds.mod(SECONDS_PER_DAY) + return basicDate(date) + "T" + pad2(secondOfDay / 3600) + pad2(secondOfDay / 60 % 60) + pad2(secondOfDay % 60) + "Z" + } - private fun tryFormatBasicDate(iso: String): String? = - try { - IsoBasicDate.format(LocalDate.parse(iso)) - } catch (_: Throwable) { - null - } + /** `yyyy-MM-dd` as `yyyyMMdd`, or null when [iso] is not a valid date. */ + private fun tryFormatBasicDate(iso: String): String? = SearchDate.parse(iso)?.let(::basicDate) + + private fun basicDate(date: SearchDate): String = date.year.toString().padStart(4, '0') + pad2(date.month.toLong()) + pad2(date.day.toLong()) + + private fun pad2(value: Long): String = value.toString().padStart(2, '0') + + private const val SECONDS_PER_DAY = 86_400L /** * Escapes text per RFC 5545 §3.3.11: backslash, semicolon, comma, newline. Carriage diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStore.kt new file mode 100644 index 0000000000..74da2d9b31 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStore.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Per-account persistence for [NowPlayingSettings]. Keys are namespaced by pubkey because the + * status is published by that account. Loads the saved settings into [state] on construction, then + * writes every later change back. Construct once per account, eagerly. + */ +@Stable +class NowPlayingSettingsStore( + private val store: DataStore, + private val scope: CoroutineScope, + pubKeyHex: HexKey, + private val state: NowPlayingSettingsState, +) { + private val shareInApp = booleanPreferencesKey("$KEY_PREFIX$pubKeyHex.shareInApp") + private val shareOtherApps = booleanPreferencesKey("$KEY_PREFIX$pubKeyHex.shareOtherApps") + private val blockedApps = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex.blockedApps") + private val knownApps = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex.knownApps") + + init { + scope.launch { + restoreFromDisk() + // drop(1) skips the value present at collection start, which restoreFromDisk already wrote. + state.flow.drop(1).collect { persist(it) } + } + } + + private suspend fun restoreFromDisk() { + try { + val prefs = store.data.first() + state.restore( + NowPlayingSettings( + shareInApp = prefs[shareInApp] ?: false, + shareOtherApps = prefs[shareOtherApps] ?: false, + blockedApps = prefs[blockedApps] ?: emptySet(), + knownApps = decodeApps(prefs[knownApps] ?: emptySet()), + ), + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NowPlayingSettingsStore") { "Error reading now playing settings: ${e.message}" } + } + } + + private suspend fun persist(value: NowPlayingSettings) { + try { + store.edit { prefs -> + prefs[shareInApp] = value.shareInApp + prefs[shareOtherApps] = value.shareOtherApps + prefs[blockedApps] = value.blockedApps + prefs[knownApps] = encodeApps(value.knownApps) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NowPlayingSettingsStore") { "Error writing now playing settings: ${e.message}" } + } + } + + companion object { + private const val KEY_PREFIX = "nowPlaying." + private const val SEPARATOR = '\t' + + fun encodeApps(apps: Map): Set = apps.mapTo(mutableSetOf()) { (id, label) -> "$id$SEPARATOR$label" } + + fun decodeApps(raw: Set): Map = + raw + .mapNotNull { entry -> + val split = entry.indexOf(SEPARATOR) + if (split <= 0) null else entry.substring(0, split) to entry.substring(split + 1) + }.toMap() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStore.kt index 1adbf9ab06..b895dfd75a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStore.kt @@ -25,6 +25,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import okio.IOException @@ -64,3 +65,11 @@ class ChessDismissedGamesStore( } } } + +/** + * Where the app keeps the dismissed-chess-games store. + * + * A new file rather than a migration of `chess_dismissed_games`: anyone who had dismissed a game + * sees it once more and dismisses it again. + */ +fun AppPreferenceStores.chessDismissedGamesStore(): ChessDismissedGamesStore = ChessDismissedGamesStore(getDataStore("chess_dismissed_games_v2")) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessEventBroadcaster.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessEventBroadcaster.kt similarity index 94% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessEventBroadcaster.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessEventBroadcaster.kt index 47a66c3f6c..c157b01c38 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessEventBroadcaster.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessEventBroadcaster.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.currentTimeMillis import kotlinx.coroutines.delay /** @@ -84,7 +85,7 @@ class ChessEventBroadcaster( val dummyFilter = Filter( kinds = listOf(JesterProtocol.KIND), - since = (System.currentTimeMillis() / 1000) + 3600, // 1 hour in future = no results + since = (currentTimeMillis() / 1000) + 3600, // 1 hour in future = no results limit = 1, ) @@ -136,10 +137,10 @@ class ChessEventBroadcaster( targetRelays: Set, timeoutMs: Long, ): Set { - val startTime = System.currentTimeMillis() + val startTime = currentTimeMillis() val pollInterval = 200L - while (System.currentTimeMillis() - startTime < timeoutMs) { + while (currentTimeMillis() - startTime < timeoutMs) { val connected = client.connectedRelaysFlow().value val targetConnected = targetRelays.intersect(connected) @@ -148,7 +149,7 @@ class ChessEventBroadcaster( } // At least one connected is good enough after half the timeout - if (targetConnected.isNotEmpty() && System.currentTimeMillis() - startTime > timeoutMs / 2) { + if (targetConnected.isNotEmpty() && currentTimeMillis() - startTime > timeoutMs / 2) { return targetConnected } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/CardFeedContentState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/CardFeedContentState.kt index 6569bf14c7..c16d050ac9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/CardFeedContentState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/CardFeedContentState.kt @@ -429,8 +429,16 @@ class CardFeedContentState( } } + /** + * Drops cards built from [deletedNotes] — e.g. a reaction its author took back, which used to + * linger on the notification card after it vanished from the message. A card can bundle many + * notes (all reactions to one post), so this rebuilds from the filter rather than editing cards. + */ fun deleteFromFeed(deletedNotes: Set) { - // TODO: Implement deletion of notes from the notification feed + val shown = lastNotes ?: return + if (deletedNotes.none { it in shown }) return + clear() + invalidateData() } fun trimToSize(maxItems: Int) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/NotificationContent.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/NotificationContent.kt index 9c44871db6..b34b57dfb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/NotificationContent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.notifications +package com.vitorpamplona.amethyst.commons.notifications import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/dal/NotificationFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/dal/NotificationFeedFilter.kt index a6c26603e3..4a9979e5e1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/dal/NotificationFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/dal/NotificationFeedFilter.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.feeds.sortedByDefaultFeedOrder import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel @@ -474,6 +475,23 @@ class NotificationFeedFilter( ?.pubkeyHex == me } + /** + * A Buzz workspace message that `p`-tags me — Buzz only `p`-tags @mentions. A workspace is a + * closed, membership-gated space, so a coworker's @mention is relevant whether or not I follow + * them (like Concord), and it should not depend on the per-kind reply heuristics either: Buzz + * mentions name no event of mine. + */ + private fun isBuzzMentionOfMe( + note: Note, + me: HexKey, + ): Boolean { + val event = note.event + if (event !is ChatEvent && event !is StreamMessageV2Event) return false + if (!event.isTaggedUser(me)) return false + val group = LocalCache.getRelayGroupChannelForContent(note) ?: return false + return BuzzRelayDialect.isBuzz(group.groupId.relayUrl) + } + /** * A Buzz chat **thread reply** into one of my messages, when the reply carries no `p` tag. * @@ -666,7 +684,7 @@ class NotificationFeedFilter( val isReactionToMe = isReactionToMyEvent(it, loggedInUserHex) // Same no-`p`-tag rescue for Buzz thread replies into my messages. - val isThreadReplyToMe = isBuzzThreadReplyToMyEvent(it, loggedInUserHex) + val isThreadReplyToMe = isBuzzThreadReplyToMyEvent(it, loggedInUserHex) || isBuzzMentionOfMe(it, loggedInUserHex) // Concord CHAT (a message/reply) honors the "Messages in notifications" toggle that silences DMs // and Marmot groups above. A reaction isn't a message — regular reactions ignore that toggle, so diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt index 823862bf31..27571bebae 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt @@ -49,7 +49,7 @@ class HtmlParser { ?: bodyBytes.bomCharsetName() ?: HtmlCharsetParser.detectCharset(bodyBytes) val content = decodeBytes(bodyBytes, name) - MetaTagsParser.parse(content) + MetaTagsParser.parse(content, includeTitleAndIcons = true) } private fun ByteArray.bomCharsetName(): String? { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt index 62e46ced82..5f4bd4cd4b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt @@ -22,8 +22,21 @@ package com.vitorpamplona.amethyst.commons.preview import com.vitorpamplona.amethyst.commons.util.codePointToChars +/** The `` element a [MetaTag] was read from. */ +enum class HeadElement { + /** A `` tag, the only kind [MetaTagsParser.parse] yields unless asked for more. */ + META, + + /** The document's ``, its text carried as the `content` attribute. */ + TITLE, + + /** A `<link>` whose `rel` names an icon (`icon`, `shortcut icon`, `apple-touch-icon`, …). */ + LINK, +} + data class MetaTag( private val attrs: Map<String, String>, + val element: HeadElement = HeadElement.META, ) { /** * Returns a value of an attribute specified by its name (case insensitive), or empty string if it doesn't exist. @@ -43,7 +56,17 @@ object MetaTagsParser { private const val NO_QUOTE = ' ' private const val META = "meta" + private const val LINK = "link" private const val HEAD = "head" + private const val BODY = "body" + private const val ICON = "icon" + private const val REL = "rel" + private const val CONTENT = "content" + + // A `<title>` is only a fallback label, so one that runs on (an unclosed tag swallowing the + // page, a spam keyword dump) is cut rather than carried into the preview cache whole. + private const val MAX_TITLE_LENGTH = 300 + private val WHITESPACE_RUN = Regex("\\s+") // Elements whose content is text rather than markup: script and style hold raw text, title and // textarea hold character data. A `<` inside any of them is not a tag. @@ -66,26 +89,99 @@ object MetaTagsParser { /** The `</head>` that ends the interesting part of the document. */ HEAD_END, + /** + * A `<body>` start tag. `</head>` is optional, so on a page that omits it this is the only + * sign the head is over. + */ + BODY_START, + + /** A closed `<title>`: its text is [TagScanner.textStart]..<[TagScanner.textEnd]. */ + TITLE, + + /** A `<link …>` whose attribute span mentions `icon`; same span fields as [META]. */ + LINK, + /** Anything else: other elements, comments, declarations, unparseable markup. */ OTHER, } /** * Lazily parse a partial HTML document and extract meta tags. + * + * With [includeTitleAndIcons], the document's `<title>` and its icon `<link>`s are yielded + * too, tagged by [MetaTag.element]. They are what a page without OpenGraph still offers for a + * preview: browsers show exactly these two in a tab. */ - fun parse(input: String): Sequence<MetaTag> = + fun parse( + input: String, + includeTitleAndIcons: Boolean = false, + ): Sequence<MetaTag> = sequence { val s = TagScanner(input) + // Meta tags past an implicit end of head are still read, as they always were. The + // title and icons are not: past `<body>`, a `<title>` is an inline SVG's tooltip + // ("Close", "Menu"), not the page's name. + var inBody = false while (!s.exhausted()) { - val kind = s.nextTag() - if (kind == TagKind.HEAD_END) break - if (kind == TagKind.META) { - val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue - yield(MetaTag(attrs)) + when (s.nextTag()) { + TagKind.HEAD_END -> { + break + } + + TagKind.BODY_START -> { + inBody = true + } + + TagKind.META -> { + val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue + yield(MetaTag(attrs)) + } + + TagKind.TITLE -> { + if (includeTitleAndIcons && !inBody) { + val text = titleText(input, s.textStart, s.textEnd) + if (text.isNotEmpty()) yield(MetaTag(mapOf(CONTENT to text), HeadElement.TITLE)) + } + } + + TagKind.LINK -> { + if (includeTitleAndIcons && !inBody) { + val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue + if (isIconRel(attrs[REL])) yield(MetaTag(attrs, HeadElement.LINK)) + } + } + + TagKind.OTHER -> {} } } } + /** + * Whether a `rel` names an icon: one of its space-separated tokens is `icon` or an + * `apple-touch-icon` variant. `mask-icon` is deliberately not one -- it is Safari's monochrome + * pinned-tab silhouette, which renders as a black blob anywhere else. + */ + private fun isIconRel(rel: String?): Boolean = + rel != null && + rel.split(' ', '\t', '\n', '\r', '\u000C').any { + it.equals(ICON, ignoreCase = true) || it.startsWith("apple-touch-icon", ignoreCase = true) + } + + /** The `<title>`'s character data, with references resolved and whitespace collapsed. */ + private fun titleText( + input: String, + from: Int, + to: Int, + ): String { + val raw = input.substring(from, to) + val decoded = if (raw.indexOf('&') < 0) raw else raw.replace(Attrs.RE_CHAR_REF, Attrs.Companion::replaceCharRefs) + val title = decoded.replace(WHITESPACE_RUN, " ").trim() + if (title.length <= MAX_TITLE_LENGTH) return title + // Never end on half of a surrogate pair: a lone high surrogate renders as tofu. + val cut = if (title[MAX_TITLE_LENGTH - 1].isHighSurrogate()) MAX_TITLE_LENGTH - 1 else MAX_TITLE_LENGTH + return title.substring(0, cut) + } + private class TagScanner( private val input: String, ) { @@ -98,6 +194,12 @@ object MetaTagsParser { var attrsEnd = 0 private set + /** Character-data span of the `<title>` [nextTag] last reported as [TagKind.TITLE]. */ + var textStart = 0 + private set + var textEnd = 0 + private set + fun exhausted(): Boolean = p >= length /** @@ -128,6 +230,20 @@ object MetaTagsParser { p = if (end < 0) length else end + 1 } + /** True when `input[from..<to]` contains [lower], ASCII-case-insensitively. */ + private fun spanContains( + from: Int, + to: Int, + lower: String, + ): Boolean { + var i = from + while (i + lower.length <= to) { + if (input.regionMatches(i, lower, 0, lower.length, ignoreCase = true)) return true + i++ + } + return false + } + /** Leaves [p] on the `</name` that closes a raw-text element, or at the end of the input. */ private fun skipRawText(endTag: String) { var i = p @@ -215,14 +331,25 @@ object MetaTagsParser { // the same way an unbalanced quote inside a comment does. Switching on the name length // first keeps the common tag (a `<div>`, a `<link>`) down to one comparison. when (nameEnd - nameStart) { - META.length -> if (nameIs(nameStart, nameEnd, META)) return TagKind.META + META.length -> { + if (nameIs(nameStart, nameEnd, META)) return TagKind.META + if (nameIs(nameStart, nameEnd, BODY)) return TagKind.BODY_START + // Most `<link>`s are stylesheets and preloads; checking the raw span for `icon` + // keeps their attributes from ever being parsed into a map. + if (nameIs(nameStart, nameEnd, LINK) && spanContains(attrsStart, attrsEnd, ICON)) return TagKind.LINK + } - STYLE.length -> + STYLE.length -> { if (nameIs(nameStart, nameEnd, STYLE)) { skipRawText(STYLE_END) } else if (nameIs(nameStart, nameEnd, TITLE)) { + textStart = p skipRawText(TITLE_END) + textEnd = p + // An unclosed title runs to the end of the input: that is not a title. + if (p < length) return TagKind.TITLE } + } SCRIPT.length -> if (nameIs(nameStart, nameEnd, SCRIPT)) skipRawText(SCRIPT_END) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt index 9e4714756b..3c0dfe3667 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.preview class OpenGraphParser { class Result( + /** The OpenGraph/Twitter/meta title, or the document's `<title>` when it declares none. */ val title: String, val description: String, val image: String, @@ -35,6 +36,8 @@ class OpenGraphParser { val videoType: String = "", /** `og:type` — what the page says it *is*, e.g. `music.song`, `video.other`, `article`. */ val type: String = "", + /** The best icon `<link>` the page declares, verbatim (may be relative). Empty when none. */ + val icon: String = "", ) companion object { @@ -112,6 +115,17 @@ class OpenGraphParser { ) private val CONTENT = "content" + private val HREF = "href" + private val REL = "rel" + private val SIZES = "sizes" + private val TYPE = "type" + + // How an icon with no `sizes` is ranked. An `apple-touch-icon` is 180px by Apple's + // convention; a plain `icon` without sizes is usually the 16/32px tab favicon. A scalable + // one (SVG, or `sizes="any"`) beats every bitmap: it is sharp at any card size. + private const val APPLE_TOUCH_ICON_DEFAULT_SIZE = 180 + private const val ICON_DEFAULT_SIZE = 32 + private const val SCALABLE_ICON_SIZE = Int.MAX_VALUE } /** Which field of [Result] a meta tag's key fills, or null when the key is not one we read. */ @@ -156,8 +170,33 @@ class OpenGraphParser { var video = "" var videoType = "" var type = "" + var documentTitle = "" + var icon = "" + var iconSize = -1 metaTags.forEach { + when (it.element) { + HeadElement.META -> {} + + HeadElement.TITLE -> { + if (documentTitle.isEmpty()) documentTitle = it.attr(CONTENT) + return@forEach + } + + HeadElement.LINK -> { + val href = it.attr(HREF) + if (href.isNotBlank()) { + val size = iconSize(it) + // Strictly greater, so among equals the first declared wins. + if (size > iconSize) { + icon = href + iconSize = size + } + } + return@forEach + } + } + // A meta tag names its key in exactly one of these three attributes, but which one // varies by site, so each is tried in turn until one is a key we read. val field = @@ -179,6 +218,32 @@ class OpenGraphParser { null -> Unit } } - return Result(title, description, image, audio, audioType, video, videoType, type) + return Result(title.ifEmpty { documentTitle }, description, image, audio, audioType, video, videoType, type, icon) + } + + /** The pixel size an icon `<link>` is ranked by: its largest declared size, or a default. */ + private fun iconSize(link: MetaTag): Int { + val sizes = link.attr(SIZES) + if (sizes.contains("any", ignoreCase = true)) return SCALABLE_ICON_SIZE + if (link.attr(TYPE).equals("image/svg+xml", ignoreCase = true)) return SCALABLE_ICON_SIZE + if (link + .attr(HREF) + .substringBefore('?') + .substringBefore('#') + .endsWith(".svg", ignoreCase = true) + ) { + return SCALABLE_ICON_SIZE + } + + // `sizes="16x16 32x32"`: rank by the largest, which is the one a decoder will pick. + val declared = + sizes + .split(' ') + .mapNotNull { it.lowercase().substringBefore('x', "").toIntOrNull() } + .maxOrNull() + if (declared != null) return declared + + val isAppleTouch = link.attr(REL).contains("apple-touch-icon", ignoreCase = true) + return if (isAppleTouch) APPLE_TOUCH_ICON_DEFAULT_SIZE else ICON_DEFAULT_SIZE } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt index 5cb955845d..4977a6cd67 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt @@ -43,6 +43,8 @@ class UrlInfoItem( val videoType: String = "", /** `og:type` — what the page says it is, e.g. `music.song`, `video.other`, `article`. */ val type: String = "", + /** The page's best declared icon `<link>` (favicon / apple-touch-icon). Empty when none. */ + val icon: String = "", ) { /** The page's host, or null when [url] is not an absolute URL. */ val verifiedHost = absoluteUrlHost(url) @@ -66,6 +68,24 @@ class UrlInfoItem( // which is what it did before. Only the playable gate below treats unresolvable as refusal. val imageUrlFullPath = resolve(image) ?: image + /** + * The page's icon, absolute: the one it declared, else `/favicon.ico` at its origin -- the + * same guess every browser makes, since most sites that declare nothing still serve one. + * + * A `data:` icon is used as-is when it is an image (the image loader decodes those inline), + * and taken at its word when it is not: `href="data:,"` is how a site tells browsers it has no + * favicon, so guessing `/favicon.ico` there is a request that is known to miss. + * + * Null for a non-HTML URL (an image or a video is its own preview) or an unresolvable one. + */ + val iconUrlFullPath: String? = + when { + !mimeType.startsWith("text/html", ignoreCase = true) -> null + icon.startsWith("data:image/", ignoreCase = true) -> if (icon.length <= MAX_INLINE_ICON_LENGTH) icon else resolve(DEFAULT_FAVICON) + icon.startsWith("data:", ignoreCase = true) -> null + else -> resolve(icon) ?: resolve(DEFAULT_FAVICON) + } + /** * The declared `og:video`, absolute, but only when the declaration holds up: a [videoType] the * renderer can actually play (a `video/` MIME, an `audio/` one, or an HLS playlist type), or @@ -119,13 +139,29 @@ class UrlInfoItem( else -> audioType.ifEmpty { null } } + /** + * Whether the page has text to show without an image: a title (OpenGraph's or the document's + * own `<title>`) or a description. Such a page gets the compact icon + text card rather than + * a bare link. + */ + val hasTextPreview: Boolean = title.isNotBlank() || description.isNotBlank() + /** * Whether the fetch produced something worth rendering. An image is the usual evidence, but a * page that declared playable media counts too — a track page that ships no cover art would * otherwise be thrown away as Empty and fall back to a bare link, which is exactly the player - * we went to the trouble of finding. + * we went to the trouble of finding. So does a page with no OpenGraph at all that still has a + * title or description: it renders as the compact card instead of a bare link. */ - fun fetchComplete(): Boolean = url.isNotEmpty() && (image.isNotEmpty() || playableMediaUrl != null) + fun fetchComplete(): Boolean = url.isNotEmpty() && (image.isNotEmpty() || playableMediaUrl != null || hasTextPreview) fun allFetchComplete(): Boolean = title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty() + + companion object { + private const val DEFAULT_FAVICON = "/favicon.ico" + + // Previews are cached, so an inline icon is held for as long as its card is. Real inline + // favicons are a few KB; anything far past that is not worth keeping in memory. + private const val MAX_INLINE_ICON_LENGTH = 64 * 1024 + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTracker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTracker.kt index d30a82fb24..db78798f8d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTracker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTracker.kt @@ -76,7 +76,19 @@ data class ChatDelivery( val acceptedRelays: Set<NormalizedRelayUrl> = emptySet(), val recipients: List<RecipientDelivery>? = null, val sendState: ChatSendState = ChatSendState.SENT, + // Relays that answered `OK false`, with their reason. A later acceptance clears the entry. + val rejectedRelays: Map<NormalizedRelayUrl, String> = emptyMap(), ) { + /** + * No relay took the message and every targeted relay refused it: it will never arrive, so the + * UI must say so (with the relay's reason) instead of showing it as still pending. + */ + val isRejected: Boolean + get() = + acceptedRelays.isEmpty() && + rejectedRelays.isNotEmpty() && + rejectedRelays.keys.containsAll(targetRelays) + /** The other participants' wraps (self-copy excluded); null for rooms. */ val otherRecipients: List<RecipientDelivery>? get() = recipients?.filterNot { it.isSelf } @@ -137,7 +149,10 @@ class ChatDeliveryTracker( private val retries = mutableMapOf<HexKey, suspend () -> Unit>() private val okCollector = - RelayInsertConfirmationCollector(client) { eventId, relay -> + RelayInsertConfirmationCollector( + client, + onRelayRejected = { eventId, relay, reason -> onRejected(eventId, relay.url, reason) }, + ) { eventId, relay -> onAccepted(eventId, relay.url) } @@ -297,7 +312,7 @@ class ChatDeliveryTracker( } } - private fun onAccepted( + internal fun onAccepted( eventId: HexKey, relay: NormalizedRelayUrl, ) { @@ -318,6 +333,7 @@ class ChatDeliveryTracker( // send path recorded. sendState = ChatSendState.SENT, acceptedRelays = delivery.acceptedRelays + relay, + rejectedRelays = delivery.rejectedRelays - relay, recipients = delivery.recipients?.map { if (it.recipient == recipient) { @@ -330,11 +346,28 @@ class ChatDeliveryTracker( } else { val flow = deliveries[eventId] ?: return val delivery = flow.value ?: return - flow.value = delivery.copy(sendState = ChatSendState.SENT, acceptedRelays = delivery.acceptedRelays + relay) + flow.value = delivery.copy(sendState = ChatSendState.SENT, acceptedRelays = delivery.acceptedRelays + relay, rejectedRelays = delivery.rejectedRelays - relay) } } } + internal fun onRejected( + eventId: HexKey, + relay: NormalizedRelayUrl, + reason: String, + ) { + if (eventId !in wrapIndex && eventId !in knownIds) return + + lock.withLock { + val noteId = wrapIndex[eventId]?.first ?: eventId + val flow = deliveries[noteId] ?: return + val delivery = flow.value ?: return + // A relay that already accepted the event (e.g. a resend after AUTH) keeps its tick. + if (relay in delivery.acceptedRelays) return + flow.value = delivery.copy(sendState = ChatSendState.SENT, rejectedRelays = delivery.rejectedRelays + (relay to reason)) + } + } + // Must run under [lock]. Also creates entries for ids queried by the UI // before their send registers (compose can win that race), so both paths // share the same flow instance. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DataStoreSearchHistoryStorage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DataStoreSearchHistoryStorage.kt index 80f9de7353..0039f1160a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DataStoreSearchHistoryStorage.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DataStoreSearchHistoryStorage.kt @@ -24,6 +24,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import kotlinx.coroutines.flow.first /** @@ -54,3 +55,6 @@ class DataStoreSearchHistoryStorage( const val FILE_NAME = "search_history" } } + +/** The search history kept in this app's own preference file. */ +fun AppPreferenceStores.searchHistoryStorage(): DataStoreSearchHistoryStorage = DataStoreSearchHistoryStorage(getDataStore(DataStoreSearchHistoryStorage.FILE_NAME)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.kt index c776a8d4d2..cc21649e3d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.kt @@ -41,6 +41,9 @@ expect object LocalClock { /** Today, in the reader's timezone. */ fun today(): SearchDate + /** How many seconds this zone is ahead of UTC at [unixSeconds]; a clock change moves it. */ + fun utcOffsetSeconds(unixSeconds: Long): Long + /** * Which local day a unix second falls on, with the zone read once for the whole counter * rather than per timestamp: for grouping a batch of events by day in one pass. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/AppServices.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/AppServices.kt index 37ca48c1a4..ec851b8f1e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/AppServices.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/AppServices.kt @@ -24,9 +24,13 @@ 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.ai.AltTextSuggester import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver +import kotlinx.coroutines.flow.Flow /** * App-wide stores that outlive any one account and that screens read directly: the favorites @@ -51,6 +55,24 @@ interface AppServices { val torSettings: TorSettingsFlow + /** Whether the embedded Tor service has fully bootstrapped, so Tor-routed relays can answer. */ + val torBootstrapped: Flow<Boolean> + + /** The app's preference files, one DataStore per name. */ + val appStores: AppPreferenceStores + /** Resolves `.bit` names and `d/`/`id/` identifiers over the configured ElectrumX servers. */ val namecoinResolver: NamecoinNameResolver + + /** Finds the server behind a `blossom:` URI (BUD-10). */ + val blossomServerFinder: BlossomServerFinder get() = BlossomServerFinder.None + + /** The device's position as geohashes. */ + val deviceLocation: DeviceLocation get() = DeviceLocation.None + + /** The place name already reverse-geocoded for [geohash], or null when none is cached yet. */ + fun cachedPlaceName(geohash: String): String? = null + + /** A new on-device alt-text suggester for image uploads, or null where the platform has none. */ + fun createAltTextSuggester(): AltTextSuggester? = null } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BlossomServerFinder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BlossomServerFinder.kt new file mode 100644 index 0000000000..8e74f719a9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BlossomServerFinder.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service + +/** + * Resolves a BUD-10 `blossom:` URI to the URL of a server that holds the blob, probing the + * author's (and the viewer's) Blossom servers. + */ +interface BlossomServerFinder { + /** The server URL already resolved for [blossomUri], without probing. */ + fun cachedServerUrl(blossomUri: String): String? + + /** Probes until a server answers for [blossomUri]; null when none does. */ + suspend fun findServerUrl(blossomUri: String): String? + + /** Resolves nothing: front ends without a Blossom client yet. */ + object None : BlossomServerFinder { + override fun cachedServerUrl(blossomUri: String): String? = null + + override suspend fun findServerUrl(blossomUri: String): String? = null + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/ai/AltTextSuggester.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/ai/AltTextSuggester.kt new file mode 100644 index 0000000000..3e97c254b6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/ai/AltTextSuggester.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service.ai + +import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri + +/** Proposes alt text for an image the user is about to upload, using an on-device model. */ +interface AltTextSuggester { + /** A one-line description of the image at [uri], or null when none could be produced. */ + suspend fun suggestAltText(uri: MediaUri): String? + + /** Releases the model. */ + fun close() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt index a20a1de7bc..8313475070 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.service.uploads import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName +import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder +import com.vitorpamplona.amethyst.commons.service.upload.FileHeader import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.ciphers.NostrCipher @@ -74,6 +76,22 @@ interface MediaUploader { useH265: Boolean, convertGifToMp4: Boolean, ): MediaCompressorResult + + /** + * Downloads the already-hosted file at [url] and computes its [FileHeader] (hash, size, + * dimensions, preview hashes) for an `imeta` tag, guessing the MIME type from the URL's + * extension. Null when it cannot be downloaded or decoded. + */ + suspend fun remoteFileHeader( + url: String, + httpClients: IRoleBasedHttpClientBuilder, + ): FileHeader? + + /** The file name the picker reported for [uri] (Android's `DISPLAY_NAME`), if any. */ + fun displayName(uri: MediaUri): String? = uri.lastPathSegmentOrNull() + + /** Deletes a temporary copy [compressIfNeeded] wrote, once the caller is done with it. */ + fun discardTempFile(uri: MediaUri) = Unit } /** No media pipeline on this front end yet: every upload fails with a clear message. */ @@ -121,4 +139,9 @@ object UnavailableMediaUploader : MediaUploader { useH265: Boolean, convertGifToMp4: Boolean, ): MediaCompressorResult = MediaCompressorResult(uri, mimeType, null) + + override suspend fun remoteFileHeader( + url: String, + httpClients: IRoleBasedHttpClientBuilder, + ): FileHeader? = null } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.kt index b4de3123e9..c11127132b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.service.uploads +import okio.Path + /** * A picked file's address as the platform hands it over: Android's `content://` [android.net.Uri] * (a typealias there, so app code keeps reading it as a `Uri`), a string-backed subclass @@ -30,6 +32,9 @@ expect abstract class MediaUri { abstract override fun toString(): String } +/** The address of a local file, such as a voice recording the app wrote itself. */ +expect fun mediaUriOfFile(path: Path): MediaUri + /** The last path segment of this address, if it has one (on Android, `Uri.lastPathSegment`). */ expect fun MediaUri.lastPathSegmentOrNull(): String? diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/AvailableProcessors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/AvailableProcessors.kt new file mode 100644 index 0000000000..20c1a5e166 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/AvailableProcessors.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +/** How many processor cores this process may use right now. */ +expect fun availableProcessors(): Int diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.kt index 89e3f1a75e..3f3c034ad3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.kt @@ -22,3 +22,9 @@ package com.vitorpamplona.amethyst.commons.util /** [value] with the locale's digit grouping: `1,234,567` in English, `1.234.567` in German. */ expect fun formatGrouped(value: Long): String + +/** [value] with the locale's decimal mark and at most [maxFractionDigits] digits after it: `4.21` in English, `4,21` in German. */ +expect fun formatDecimal( + value: Double, + maxFractionDigits: Int, +): String diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.kt new file mode 100644 index 0000000000..8be7292975 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.kt @@ -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.commons.util + +/** A language the user can pick: its ISO code and its name in the user's own language. */ +data class LanguageOption( + val language: String, + val displayName: String, +) + +/** Every language the platform can name, one entry per language code (no regional variants). */ +expect fun availableLanguages(): List<LanguageOption> + +/** The name of the language [tag] (a BCP-47 tag such as `pt` or `pt-BR`) in the user's language. */ +expect fun languageDisplayName(tag: String): String diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/video/HlsMedia.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/video/HlsMedia.kt new file mode 100644 index 0000000000..7918b678e3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/video/HlsMedia.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.video + +/** The mime HLS playlists are normalized to — the value of media3's `MimeTypes.APPLICATION_M3U8`. */ +const val HLS_MIME_TYPE = "application/x-mpegURL" + +/** + * The mime a player should be handed for [mimeType] on [url]: an explicit mime wins (the four HLS + * aliases map onto [HLS_MIME_TYPE]); without one, a path-anchored `.m3u8` means HLS; otherwise + * null, letting the player infer the format. + */ +fun normalizeStreamMimeType( + mimeType: String?, + url: String? = null, +): String? { + if (!mimeType.isNullOrBlank()) { + return when (mimeType.lowercase()) { + "application/vnd.apple.mpegurl", + "application/x-mpegurl", + "audio/x-mpegurl", + "audio/mpegurl", + -> HLS_MIME_TYPE + + else -> mimeType + } + } + if (url != null && hasM3u8PathExtension(url)) { + return HLS_MIME_TYPE + } + return null +} + +/** + * Whether a URL plus its imeta mime identifies HLS, for code that holds a URL and a mime but no + * player item yet. Defined through [normalizeStreamMimeType] so it cannot drift from the mime that + * actually reaches the player. + * + * 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*: an on-demand HLS + * playlist also answers true. Liveness is only truly knowable from `#EXT-X-ENDLIST` once the + * playlist is loaded. + */ +fun isHlsMedia( + url: String, + mimeType: String?, +): Boolean = normalizeStreamMimeType(mimeType, url) == HLS_MIME_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 the HLS source. +private fun hasM3u8PathExtension(uri: String): Boolean { + val path = uri.substringBefore('?').substringBefore('#') + return path.endsWith(".m3u8", ignoreCase = true) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModelHost.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModelHost.kt index 8f497e176e..e5d4bca6d6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModelHost.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModelHost.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.viewmodels +import com.vitorpamplona.amethyst.commons.audio.AnonymizedResult +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.pow.PoWJobFailure import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader @@ -32,7 +36,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder import kotlinx.collections.immutable.PersistentMap import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import okio.Path /** * What the account's ViewModel needs from the process it runs in: app-wide services that outlive @@ -70,6 +76,39 @@ interface AccountViewModelHost { /** Compresses, strips, encrypts and uploads picked media for the composers. */ val mediaUploader: MediaUploader + /** The device's position as geohashes. */ + val deviceLocation: DeviceLocation get() = DeviceLocation.None + + /** Posts signed now and published later by a background worker; null where scheduling is unsupported. */ + val scheduledPostStore: ScheduledPostStore? get() = null + + /** + * Re-voices the recording at [input] with the voice preset named [presetName], into a new file + * beside it. + */ + suspend fun anonymizeVoice( + input: Path, + presetName: String, + ): Result<AnonymizedResult> = Result.failure(UnsupportedOperationException("Voice anonymization is not available on this platform")) + + /** Whether this build can offer an on-device writing assistant at all (it shows the setting). */ + val supportsWritingAssistant: Boolean get() = false + + /** A new on-device writing assistant, or null where the platform has none. */ + fun createWritingAssistant(): WritingAssistant? = null + + /** + * Whether the account [npub] has saved its secret key, as a live flag. Absent reads as true, + * so a platform that does not track it never shows the backup nudge. + */ + suspend fun hasBackedUpKeys(npub: String): StateFlow<Boolean> = MutableStateFlow(true) + + /** Records whether the account [npub] has saved its secret key. */ + suspend fun setHasBackedUpKeys( + npub: String, + value: Boolean, + ) = Unit + /** Public keys of every account saved on this device. */ val savedAccounts: Flow<Set<HexKey>> diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCacheTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCacheTest.kt new file mode 100644 index 0000000000..d175a52fb8 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCacheTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent +import kotlin.test.Test +import kotlin.test.assertEquals + +class UserStatusCacheTest { + private val pubKey = "b".repeat(64) + private val sig = "c".repeat(128) + private val author = User(pubKey, UserContext { addr -> AddressableNote(addr) }) + + private fun status( + id: Char, + content: String, + createdAt: Long, + ) = UserStatusEvent(id.toString().repeat(64), pubKey, createdAt, arrayOf(arrayOf("d", UserStatusEvent.MUSIC)), content, sig) + + @Test + fun aClearedStatusLeavesTheList() { + val cache = UserStatusCache() + val note = AddressableNote(Address(UserStatusEvent.KIND, pubKey, UserStatusEvent.MUSIC)) + + note.loadEvent(status('1', "Song - Band", 100), author, emptyList()) + cache.addStatus(note) + assertEquals(listOf(note), cache.statuses.value) + + // The same address now holds the newer, blank version: nothing to show any more. + note.loadEvent(status('2', "", 200), author, emptyList()) + cache.addStatus(note) + assertEquals(emptyList(), cache.statuses.value) + + // And a new track brings it back. + note.loadEvent(status('3', "Other Song - Band", 300), author, emptyList()) + cache.addStatus(note) + assertEquals(listOf(note), cache.statuses.value) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisherTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisherTest.kt new file mode 100644 index 0000000000..c4761cb6b1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisherTest.kt @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class NowPlayingPublisherTest { + private val base = 1_700_000_000L + + private class Recorder { + val published = mutableListOf<Pair<NowPlaying, Long>>() + var clears = 0 + } + + private fun TestScope.now() = base + testScheduler.currentTime / 1000 + + private fun TestScope.start(source: MutableStateFlow<NowPlaying?>): Recorder { + val recorder = Recorder() + val publisher = + NowPlayingPublisher( + publish = { track, expiration -> recorder.published.add(track to expiration) }, + clear = { recorder.clears++ }, + nowSeconds = { now() }, + ) + backgroundScope.launch { publisher.run(source) } + runCurrent() + return recorder + } + + private fun TestScope.track( + title: String, + remainingSeconds: Long? = 180, + ) = NowPlaying( + title = title, + artist = "Artist", + source = NowPlayingSource.InApp, + endsAt = remainingSeconds?.let { now() + it }, + ) + + @Test + fun publishesAfterTheStartDelayWithTheTrackEndAsExpiration() = + runTest { + val source = MutableStateFlow<NowPlaying?>(null) + val recorder = start(source) + + val song = track("Song") + source.value = song + advanceTimeBy(4_000) + assertEquals(0, recorder.published.size) + + advanceTimeBy(2_000) + assertEquals(1, recorder.published.size) + assertEquals(song, recorder.published[0].first) + assertEquals(song.endsAt, recorder.published[0].second) + } + + @Test + fun skippingThroughTracksOnlyPublishesTheOneThatKeepsPlaying() = + runTest { + val source = MutableStateFlow<NowPlaying?>(null) + val recorder = start(source) + + source.value = track("One") + advanceTimeBy(2_000) + source.value = track("Two") + advanceTimeBy(2_000) + source.value = track("Three") + advanceTimeBy(10_000) + + assertEquals(listOf("Three"), recorder.published.map { it.first.title }) + } + + @Test + fun aShortPauseNeitherClearsNorRepublishes() = + runTest { + val source = MutableStateFlow<NowPlaying?>(null) + val recorder = start(source) + + source.value = track("Song", remainingSeconds = 180) + advanceTimeBy(6_000) + assertEquals(1, recorder.published.size) + + source.value = null + advanceTimeBy(10_000) + // Resumed 10s later: the end moved by 10s, within the tolerance. + source.value = track("Song", remainingSeconds = 174) + advanceTimeBy(60_000) + + assertEquals(0, recorder.clears) + assertEquals(1, recorder.published.size) + } + + @Test + fun stoppingClearsTheStatusAfterTheClearDelay() = + runTest { + val source = MutableStateFlow<NowPlaying?>(null) + val recorder = start(source) + + source.value = track("Song") + advanceTimeBy(6_000) + source.value = null + advanceTimeBy(29_000) + assertEquals(0, recorder.clears) + + advanceTimeBy(2_000) + assertEquals(1, recorder.clears) + } + + @Test + fun aStatusThatAlreadyExpiredIsNotCleared() = + runTest { + val source = MutableStateFlow<NowPlaying?>(null) + val recorder = start(source) + + source.value = track("Short", remainingSeconds = 20) + advanceTimeBy(30_000) + source.value = null + advanceTimeBy(120_000) + + assertEquals(1, recorder.published.size) + assertEquals(0, recorder.clears) + } + + @Test + fun seekingRepublishesWithTheNewEnd() = + runTest { + val source = MutableStateFlow<NowPlaying?>(null) + val recorder = start(source) + + source.value = track("Song", remainingSeconds = 180) + advanceTimeBy(6_000) + val seeked = track("Song", remainingSeconds = 60) + source.value = seeked + runCurrent() + + assertEquals(2, recorder.published.size) + assertEquals(seeked.endsAt, recorder.published[1].second) + } + + @Test + fun anUnknownLengthIsRefreshedBeforeItExpires() = + runTest { + val source = MutableStateFlow<NowPlaying?>(null) + val recorder = start(source) + + source.value = track("Radio", remainingSeconds = null) + advanceTimeBy(6_000) + assertEquals(1, recorder.published.size) + assertEquals(now() - 1 + 600, recorder.published[0].second) + + // Refreshed a minute before the 10-minute expiration. + advanceTimeBy(541_000) + assertEquals(2, recorder.published.size) + } + + @Test + fun pickHonorsSettingsAndTheBlockList() { + val inApp = NowPlaying("In app", source = NowPlayingSource.InApp) + val spotify = NowPlaying("Other", source = NowPlayingSource.OtherApp("com.spotify.music", "Spotify")) + + assertNull(pickNowPlaying(NowPlayingSettings(), inApp, spotify)) + assertEquals(inApp, pickNowPlaying(NowPlayingSettings(shareInApp = true, shareOtherApps = true), inApp, spotify)) + assertEquals(spotify, pickNowPlaying(NowPlayingSettings(shareOtherApps = true), inApp, spotify)) + assertEquals(spotify, pickNowPlaying(NowPlayingSettings(shareInApp = true, shareOtherApps = true), null, spotify)) + assertNull( + pickNowPlaying( + NowPlayingSettings(shareOtherApps = true, blockedApps = setOf("com.spotify.music")), + null, + spotify, + ), + ) + } + + @Test + fun statusTextFollowsTheNipExample() { + assertEquals("Intergalactic - Beastie Boys", NowPlaying("Intergalactic", "Beastie Boys", NowPlayingSource.InApp).statusText()) + assertEquals("Intergalactic", NowPlaying("Intergalactic", null, NowPlayingSource.InApp).statusText()) + } + + @Test + fun aPositionTickIsTheSameMomentButASeekIsNot() { + val song = NowPlaying("Song", "Band", NowPlayingSource.InApp, endsAt = 1_000) + + assertTrue(song.isSameMoment(song.copy(endsAt = 1_004))) + assertFalse(song.isSameMoment(song.copy(endsAt = 1_060))) + assertFalse(song.isSameMoment(song.copy(title = "Other"))) + assertFalse(song.isSameMoment(song.copy(source = NowPlayingSource.OtherApp("x", "X")))) + assertFalse(song.isSameMoment(null)) + assertTrue(song.copy(endsAt = null).isSameMoment(song.copy(endsAt = null))) + } + + @Test + fun endsAtAccountsForPositionAndSpeed() { + assertEquals(1_000L + 60 + 1, NowPlaying.endsAt(1_000, durationMs = 180_000, positionMs = 120_000)) + assertEquals(1_000L + 30 + 1, NowPlaying.endsAt(1_000, durationMs = 180_000, positionMs = 120_000, speed = 2f)) + assertNull(NowPlaying.endsAt(1_000, durationMs = null, positionMs = 0)) + assertNull(NowPlaying.endsAt(1_000, durationMs = 1_000, positionMs = 2_000)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolverTest.kt new file mode 100644 index 0000000000..d09a83b28f --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolverTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NowPlayingResolverTest { + private val id = "a".repeat(64) + private val pubKey = "b".repeat(64) + private val sig = "c".repeat(128) + + @Test + fun musicTrackPointsAtItsAddress() { + val event = + MusicTrackEvent( + id, + pubKey, + 1L, + arrayOf(arrayOf("d", "song"), arrayOf("title", "Song"), arrayOf("artist", "Band")), + "", + sig, + ) + + val track = NowPlayingResolver.fromEvent(event, fallbackArtist = "Uploader", endsAt = 10L)!! + + assertEquals("Song - Band", track.statusText()) + assertEquals("${MusicTrackEvent.KIND}:$pubKey:song", track.addressId) + assertNull(track.eventId) + assertEquals(10L, track.endsAt) + assertEquals(NowPlayingSource.InApp, track.source) + } + + @Test + fun podcastEpisodeUsesTheShowAsArtistAndPointsAtTheEvent() { + val event = PodcastEpisodeEvent(id, pubKey, 1L, arrayOf(arrayOf("title", "Episode 1")), "", sig) + + val track = NowPlayingResolver.fromEvent(event, fallbackArtist = "The Show")!! + + assertEquals("Episode 1 - The Show", track.statusText()) + assertEquals(id, track.eventId) + assertNull(track.addressId) + } + + @Test + fun otherEventsAreNotShared() { + val event = TextNoteEvent(id, pubKey, 1L, emptyArray(), "a video", sig) + + assertNull(NowPlayingResolver.fromEvent(event, fallbackTitle = "video", fallbackArtist = "someone")) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt new file mode 100644 index 0000000000..4822fceaa4 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.preview + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A page with no OpenGraph still has what a browser tab shows: its `<title>` and its icon + * `<link>`s. These are read only when asked for, and only ever as a fallback. + */ +class TitleAndIconFallbackTest { + private fun headTags(html: String) = MetaTagsParser.parse(html, includeTitleAndIcons = true).toList() + + private fun extract(html: String) = OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(html, includeTitleAndIcons = true)) + + // Trimmed from the nsite that prompted this: no og:*, no twitter:*, only a title, a meta + // description and an SVG favicon. + private val noOpenGraphPage = + """ + |<!DOCTYPE html> + |<html lang="en"> + |<head> + |<meta charset="utf-8"> + |<meta name="viewport" content="width=device-width, initial-scale=1"> + |<title>Private Provider — Confidential AI + | + | + | + | + |

Hi

+ | + """.trimMargin() + + @Test + fun aPageWithoutOpenGraphStillYieldsTitleDescriptionAndIcon() { + val info = extract(noOpenGraphPage) + + assertEquals("Private Provider — Confidential AI", info.title) + assertEquals("A phone-first AI workspace for Android.", info.description) + assertEquals("favicon.svg", info.icon) + assertEquals("", info.image) + } + + @Test + fun theDefaultParseStillYieldsOnlyMetaTags() { + val tags = MetaTagsParser.parse(noOpenGraphPage).toList() + + assertEquals(3, tags.size) + assertTrue(tags.all { it.element == HeadElement.META }) + } + + @Test + fun onlyIconLinksAreYielded() { + val tags = headTags(noOpenGraphPage) + + val links = tags.filter { it.element == HeadElement.LINK } + assertEquals(1, links.size) + assertEquals("favicon.svg", links[0].attr("href")) + } + + @Test + fun openGraphTitleWinsOverTheDocumentTitle() { + val info = + extract( + """ + | + | Document Title + | + | + """.trimMargin(), + ) + + assertEquals("OG Title", info.title) + } + + @Test + fun titleWhitespaceIsCollapsed() { + val info = extract("\n Spread\n\t Out ") + + assertEquals("Spread Out", info.title) + } + + @Test + fun anUnclosedTitleIsNotATitle() { + val info = extract("runs to the end of the truncated body") + + assertEquals("", info.title) + } + + @Test + fun aTitleOutsideTheHeadIsIgnored() { + // An SVG in the body carries its own <title>; the scan stops at </head> before it. + val info = extract("<head></head><body><svg><title>Close") + + assertEquals("", info.title) + } + + @Test + fun theFirstTitleWins() { + val info = extract("FirstSecond") + + assertEquals("First", info.title) + } + + @Test + fun theLargestIconWins() { + val info = + extract( + """ + | + | + | + | + | + | + """.trimMargin(), + ) + + assertEquals("/apple-touch-icon.png", info.icon) + } + + @Test + fun aScalableIconBeatsBitmaps() { + val info = + extract( + """ + | + | + | + | + """.trimMargin(), + ) + + assertEquals("/icon.svg?v=2", info.icon) + } + + @Test + fun maskIconIsNotUsed() { + // Safari's monochrome pinned-tab silhouette renders as a black blob anywhere else. + val info = + extract( + """ + | + | + | + | + """.trimMargin(), + ) + + assertEquals("/favicon-32.png", info.icon) + } + + @Test + fun iconLinksAfterTheHeadAreIgnored() { + val info = extract("""""") + + assertEquals("", info.icon) + } + + @Test + fun aBodyTitleIsIgnoredWhenTheHeadIsNeverClosed() { + // `` is optional: `` closes the head implicitly. An inline SVG's + // ("Close", "Menu") is the classic thing to then mistake for the page title. + val info = extract("""<head><meta name="description" content="D"><body><svg><title>Close""") + + assertEquals("", info.title) + assertEquals("D", info.description) + } + + @Test + fun aBodyIconLinkIsIgnoredWhenTheHeadIsNeverClosed() { + val info = extract("""T""") + + assertEquals("T", info.title) + assertEquals("", info.icon) + } + + @Test + fun aTitleWithAttributesIsRead() { + val info = extract("""Helmet""") + + assertEquals("Helmet", info.title) + } + + @Test + fun aLongTitleIsNotCutInsideASurrogatePair() { + // 299 ASCII chars then an emoji: a plain take(300) keeps only its high surrogate. + val info = extract("" + "a".repeat(299) + "\uD83D\uDE00 tail") + + assertTrue(info.title.isNotEmpty()) + assertTrue(!info.title.last().isHighSurrogate(), "title ends in a dangling high surrogate") + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTrackerRejectionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTrackerRejectionTest.kt new file mode 100644 index 0000000000..07909aae16 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTrackerRejectionTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.chatDelivery + +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** A relay's refusal (OK false) must surface with its reason, not as a message still pending. */ +class ChatDeliveryTrackerRejectionTest { + private val noteId = "a".repeat(64) + private val relayA = RelayUrlNormalizer.normalizeOrNull("wss://a.example.com")!! + private val relayB = RelayUrlNormalizer.normalizeOrNull("wss://b.example.com")!! + + @Test + fun everyTargetRefusing_isRejectedWithTheReason() { + val tracker = ChatDeliveryTracker(EmptyNostrClient()) + tracker.trackPublic(noteId, setOf(relayA)) + + tracker.onRejected(noteId, relayA, "invalid: imeta url must be a local /media/ path") + + val delivery = tracker.currentFor(noteId)!! + assertTrue(delivery.isRejected) + assertEquals("invalid: imeta url must be a local /media/ path", delivery.rejectedRelays[relayA]) + } + + @Test + fun oneRelayStillOpen_isNotRejectedYet() { + val tracker = ChatDeliveryTracker(EmptyNostrClient()) + tracker.trackPublic(noteId, setOf(relayA, relayB)) + + tracker.onRejected(noteId, relayA, "blocked: no") + + assertFalse(tracker.currentFor(noteId)!!.isRejected) + } + + @Test + fun aLaterAcceptanceClearsTheRefusal() { + val tracker = ChatDeliveryTracker(EmptyNostrClient()) + tracker.trackPublic(noteId, setOf(relayA)) + + tracker.onRejected(noteId, relayA, "rate-limited: slow down") + tracker.onAccepted(noteId, relayA) + + val delivery = tracker.currentFor(noteId)!! + assertFalse(delivery.isRejected) + assertTrue(delivery.rejectedRelays.isEmpty()) + } + + @Test + fun aRefusalAfterAcceptanceKeepsTheTick() { + val tracker = ChatDeliveryTracker(EmptyNostrClient()) + tracker.trackPublic(noteId, setOf(relayA)) + + tracker.onAccepted(noteId, relayA) + tracker.onRejected(noteId, relayA, "duplicate: already have it") + + assertTrue(tracker.currentFor(noteId)!!.rejectedRelays.isEmpty()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/IsHlsMediaTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/video/IsHlsMediaTest.kt similarity index 89% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/IsHlsMediaTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/video/IsHlsMediaTest.kt index 9b34587518..14e69d1f8c 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/IsHlsMediaTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/video/IsHlsMediaTest.kt @@ -18,11 +18,11 @@ * 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 +package com.vitorpamplona.amethyst.commons.video -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * The two cases that motivated replacing the old `.m3u8`-substring predicate are @@ -39,7 +39,7 @@ class IsHlsMediaTest { assertTrue(isHlsMedia(blossom, "application/x-mpegurl")) assertTrue(isHlsMedia(blossom, "application/vnd.apple.mpegurl")) - assertFalse("no mime and no extension leaves nothing to go on", isHlsMedia(blossom, null)) + assertFalse(isHlsMedia(blossom, null), "no mime and no extension leaves nothing to go on") } @Test @@ -54,7 +54,7 @@ class IsHlsMediaTest { fun recognisesAPlainM3u8Path() { assertTrue(isHlsMedia("https://host/live.m3u8", null)) assertTrue(isHlsMedia("https://host/live.M3U8", null)) - assertTrue("query strings don't hide the path", isHlsMedia("https://host/live.m3u8?vt=abc", null)) + assertTrue(isHlsMedia("https://host/live.m3u8?vt=abc", null), "query strings don't hide the path") } @Test diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.ios.kt index cfebd03c92..f535752b38 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.ios.kt @@ -67,6 +67,8 @@ actual object LocalClock { actual fun today(): SearchDate = ZoneMath.dayAt(currentTimeSeconds(), localZone) + actual fun utcOffsetSeconds(unixSeconds: Long): Long = localZone.at(unixSeconds) + actual fun epochDayCounter(): EpochDayCounter = EpochDayCounter { ZoneMath.epochDayAt(it, localZone) } /** diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.ios.kt index a8e33c608e..0fe4f5cc41 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.ios.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.service.uploads +import okio.Path + actual abstract class MediaUri { actual abstract override fun toString(): String } @@ -38,3 +40,5 @@ class StringMediaUri( } actual fun MediaUri.lastPathSegmentOrNull(): String? = lastPathSegmentOf(toString()) + +actual fun mediaUriOfFile(path: Path): MediaUri = StringMediaUri(path.toString()) diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/AvailableProcessors.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/AvailableProcessors.ios.kt new file mode 100644 index 0000000000..ae39103329 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/AvailableProcessors.ios.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import platform.Foundation.NSProcessInfo + +actual fun availableProcessors(): Int = NSProcessInfo.processInfo.activeProcessorCount.toInt() diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.ios.kt index 22e852b588..8cb15ad3f1 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.ios.kt @@ -23,9 +23,22 @@ package com.vitorpamplona.amethyst.commons.util import platform.Foundation.NSNumber import platform.Foundation.NSNumberFormatter import platform.Foundation.NSNumberFormatterDecimalStyle +import platform.Foundation.numberWithDouble import platform.Foundation.numberWithLongLong actual fun formatGrouped(value: Long): String { val formatter = NSNumberFormatter().apply { numberStyle = NSNumberFormatterDecimalStyle } return formatter.stringFromNumber(NSNumber.numberWithLongLong(value)) ?: value.toString() } + +actual fun formatDecimal( + value: Double, + maxFractionDigits: Int, +): String { + val formatter = + NSNumberFormatter().apply { + numberStyle = NSNumberFormatterDecimalStyle + maximumFractionDigits = maxFractionDigits.toULong() + } + return formatter.stringFromNumber(NSNumber.numberWithDouble(value)) ?: value.toString() +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.ios.kt new file mode 100644 index 0000000000..15d0fe71f1 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.ios.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import platform.Foundation.NSLocale +import platform.Foundation.availableLocaleIdentifiers +import platform.Foundation.currentLocale +import platform.Foundation.localizedStringForLanguageCode + +actual fun availableLanguages(): List = + NSLocale.availableLocaleIdentifiers + .mapNotNull { (it as? String)?.substringBefore('_')?.substringBefore('-') } + .filter { it.isNotBlank() } + .distinct() + .map { LanguageOption(it, languageDisplayName(it)) } + +actual fun languageDisplayName(tag: String): String = NSLocale.currentLocale.localizedStringForLanguageCode(tag) ?: tag diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt index 78a918541f..3410eec81c 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt @@ -77,6 +77,7 @@ class UrlPreview { data.video, data.videoType, data.type, + data.icon, ) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.jvmAndroid.kt index f7288c0cad..cd836faa47 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.jvmAndroid.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.jvmAndroid.kt @@ -54,6 +54,13 @@ actual object LocalClock { SearchDate(it.year, it.monthValue, it.dayOfMonth) } + actual fun utcOffsetSeconds(unixSeconds: Long): Long = + zone() + .rules + .getOffset(Instant.ofEpochSecond(unixSeconds)) + .totalSeconds + .toLong() + actual fun epochDayCounter(): EpochDayCounter { val rules = zone().rules val offsets = ZoneOffsets { rules.getOffset(Instant.ofEpochSecond(it)).totalSeconds.toLong() } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/AvailableProcessors.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/AvailableProcessors.jvmAndroid.kt new file mode 100644 index 0000000000..caa154c409 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/AvailableProcessors.jvmAndroid.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +actual fun availableProcessors(): Int = Runtime.getRuntime().availableProcessors() diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.jvmAndroid.kt index b2e00ab2b1..c145f8ecf0 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.jvmAndroid.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.jvmAndroid.kt @@ -23,3 +23,8 @@ package com.vitorpamplona.amethyst.commons.util import java.text.NumberFormat actual fun formatGrouped(value: Long): String = NumberFormat.getNumberInstance().format(value) + +actual fun formatDecimal( + value: Double, + maxFractionDigits: Int, +): String = NumberFormat.getNumberInstance().apply { maximumFractionDigits = maxFractionDigits }.format(value) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt index bda5b0c2a9..b27eb59efe 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt @@ -29,7 +29,7 @@ actual fun resolveHttpUrl( reference: String, ): String? = runCatching { - val baseUri = base?.let { runCatching { URI(it).toURL().toURI() }.getOrNull() } + val baseUri = base?.let { runCatching { URI(it).toURL().toURI().withRootPath() }.getOrNull() } val resolved = if (baseUri != null) baseUri.resolve(reference) else URI(reference) if (!resolved.scheme.equals("http", ignoreCase = true) && !resolved.scheme.equals("https", ignoreCase = true) @@ -39,3 +39,23 @@ actual fun resolveHttpUrl( resolved.toURL().toString() } }.getOrNull() + +/** + * `https://host` as `https://host/`. RFC 3986 resolves a relative reference against an empty base path + * as if the path were `/`, and the JDK does, but Android's `java.net.URI` does not: it glued + * `y18.svg` onto `https://news.ycombinator.com` as `https://news.ycombinator.comy18.svg`, so the + * page's icon (or a relative og:image) pointed at a host that does not exist. + */ +private fun URI.withRootPath(): URI = + if (isOpaque || !rawPath.isNullOrEmpty() || rawAuthority == null) { + this + } else { + // From the raw (still-encoded) parts: the multi-argument constructor would encode them again. + URI( + buildString { + append(scheme).append("://").append(rawAuthority).append('/') + rawQuery?.let { append('?').append(it) } + rawFragment?.let { append('#').append(it) } + }, + ) + } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.jvmAndroid.kt new file mode 100644 index 0000000000..e3ee56e4e0 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.jvmAndroid.kt @@ -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.commons.util + +import java.util.Locale + +actual fun availableLanguages(): List { + val seen = mutableSetOf() + return Locale + .getAvailableLocales() + .filter { it.language.isNotBlank() && it.country.isBlank() && seen.add(it.language) } + .map { LanguageOption(it.language, it.displayName) } +} + +actual fun languageDisplayName(tag: String): String = Locale.forLanguageTag(tag).displayName diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.jvm.kt index a8e33c608e..0fe4f5cc41 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.jvm.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.jvm.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.service.uploads +import okio.Path + actual abstract class MediaUri { actual abstract override fun toString(): String } @@ -38,3 +40,5 @@ class StringMediaUri( } actual fun MediaUri.lastPathSegmentOrNull(): String? = lastPathSegmentOf(toString()) + +actual fun mediaUriOfFile(path: Path): MediaUri = StringMediaUri(path.toString()) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTaggerKeyParseTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTaggerKeyParseTest.kt index 3cc2675f47..582c55e918 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTaggerKeyParseTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/composer/NewMessageTaggerKeyParseTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache.getOrCreateAddr import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -184,4 +185,28 @@ class NewMessageTaggerKeyParseTest { ) assertEquals(",", result?.restOfWord) } + + @Test + fun userMentionsAsNamesWritesPlainAtNameAndStillTags() = + runBlocking { + val npub = "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + val hex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + val user = dao.getOrCreateUser(hex) + + val tagger = NewMessageTagger(message = "hi @$npub, there", dao = dao, userMentionsAsNames = true) + tagger.run() + + assertEquals("hi @${user.toBestDisplayName()}, there", tagger.message) + assertEquals(listOf(hex), tagger.pTags?.map { it.pubkeyHex }) + } + + @Test + fun defaultStillWritesNostrUri() = + runBlocking { + val npub = "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + val tagger = NewMessageTagger(message = "hi @$npub", dao = dao) + tagger.run() + + assertTrue(tagger.message.startsWith("hi nostr:nprofile1")) + } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStoreTest.kt new file mode 100644 index 0000000000..e133edfa5a --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStoreTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class NowPlayingSettingsStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private fun raw(): DataStore { + val file = File(folder.root, "shared.preferences_pb") + return PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { file.toOkioPath() }, + ) + } + + @Test + fun appLabelsSurviveTheEncoding() { + val apps = mapOf("com.spotify.music" to "Spotify", "org.mpris.MediaPlayer2.vlc" to "VLC media player") + + assertEquals(apps, NowPlayingSettingsStore.decodeApps(NowPlayingSettingsStore.encodeApps(apps))) + assertEquals(emptyMap(), NowPlayingSettingsStore.decodeApps(setOf("no-separator", "\tno-id"))) + } + + @Test + fun settingsAreWrittenAndRestoredPerAccount() = + runBlocking { + val store = raw() + val expected = + NowPlayingSettings( + shareInApp = true, + shareOtherApps = true, + blockedApps = setOf("com.google.android.youtube"), + knownApps = mapOf("com.google.android.youtube" to "YouTube", "com.spotify.music" to "Spotify"), + ) + + val writerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val written = NowPlayingSettingsState() + NowPlayingSettingsStore(store, writerScope, "alice", written) + // Let the store finish its initial restore before changing anything. + withTimeout(5_000) { store.data.first() } + Thread.sleep(200) + written.setShareInApp(true) + written.setShareOtherApps(true) + written.rememberApp("com.google.android.youtube", "YouTube") + written.rememberApp("com.spotify.music", "Spotify") + written.setAppBlocked("com.google.android.youtube", true) + + withTimeout(5_000) { + // The block is the last change made, so once it is on disk everything is. + while (store.data.first()[stringSetPreferencesKey("nowPlaying.alice.blockedApps")].isNullOrEmpty()) Thread.sleep(20) + } + writerScope.cancel() + + val readerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val restored = NowPlayingSettingsState() + NowPlayingSettingsStore(store, readerScope, "alice", restored) + withTimeout(5_000) { restored.flow.first { it.isEnabled() } } + assertEquals(expected, restored.flow.value) + + // Another account on the same file starts from the defaults. + val other = NowPlayingSettingsState() + NowPlayingSettingsStore(store, readerScope, "bob", other) + Thread.sleep(200) + assertEquals(NowPlayingSettings(), other.flow.value) + readerScope.cancel() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt new file mode 100644 index 0000000000..5998927fbe --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.preview + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A page with no OpenGraph image is still worth a card when it has text: the compact icon + text + * card, built from its title, description and favicon. + */ +class UrlInfoItemTextPreviewTest { + private fun page( + url: String = "https://example.nsite.lol/", + title: String = "", + description: String = "", + image: String = "", + icon: String = "", + mimeType: String = "text/html; charset=utf-8", + ) = UrlInfoItem(url = url, title = title, description = description, image = image, mimeType = mimeType, icon = icon) + + @Test + fun aTitleAloneIsEnoughToKeepThePreview() { + assertTrue(page(title = "Private Provider").fetchComplete()) + } + + @Test + fun aDescriptionAloneIsEnoughToKeepThePreview() { + assertTrue(page(description = "A workspace").fetchComplete()) + } + + @Test + fun aPageWithNothingStaysEmpty() { + assertFalse(page().fetchComplete()) + assertFalse(page(title = " ").fetchComplete()) + } + + @Test + fun aRelativeIconIsResolvedAgainstThePage() { + assertEquals( + "https://example.nsite.lol/docs/favicon.svg", + page(url = "https://example.nsite.lol/docs/index.html", icon = "favicon.svg").iconUrlFullPath, + ) + } + + @Test + fun noDeclaredIconFallsBackToTheOriginFavicon() { + assertEquals( + "https://example.nsite.lol/favicon.ico", + page(url = "https://example.nsite.lol/a/b/c.html").iconUrlFullPath, + ) + } + + @Test + fun aNonHttpIconFallsBackToTheOriginFavicon() { + assertEquals( + "https://example.nsite.lol/favicon.ico", + page(icon = "file:///etc/passwd").iconUrlFullPath, + ) + } + + @Test + fun nonHtmlUrlsHaveNoIcon() { + assertNull(page(url = "https://example.com/a.png", image = "https://example.com/a.png", mimeType = "image/png").iconUrlFullPath) + } + + @Test + fun anUppercaseHtmlMimeStillGetsAnIcon() { + // UrlPreview stores MediaType.toString(), which keeps the server's spelling. + assertEquals( + "https://example.nsite.lol/favicon.ico", + page(mimeType = "Text/HTML; charset=UTF-8").iconUrlFullPath, + ) + } + + @Test + fun anEmptyDataIconMeansTheSiteHasNoIcon() { + // `` is the common way to tell browsers not to request + // /favicon.ico. Requesting it anyway is a guaranteed miss. + assertNull(page(icon = "data:,").iconUrlFullPath) + } + + @Test + fun anInlineImageIconIsUsedAsIs() { + val inline = "data:image/png;base64,iVBORw0KGgo=" + assertEquals(inline, page(icon = inline).iconUrlFullPath) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt index 228498fdf1..2a2d0f70db 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt @@ -160,6 +160,19 @@ class MetaTagsParserBenchmark { } bench("no , whole doc", open, 2_000) { MetaTagsParser.parse(it).count() } + // What production runs (HtmlParser asks for the and icon <link>s too). Should sit + // on top of the rows above: the 60 preload links never reach the attribute parser. + bench("spa head, +title/icons", spa, 50_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() } + bench("spa head, og: + fallbacks", spa, 50_000) { + OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(it, includeTitleAndIcons = true)).title.length + } + bench("heavy head, +title/icons", heavy, 2_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() } + bench("no </head>, +title/icons", open, 2_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() } + + val spaFallbacks = OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(spa, includeTitleAndIcons = true)) + assertEquals("Example — Your Network. Your Rules.", spaFallbacks.title) + assertEquals("/favicon.svg", spaFallbacks.icon) + assertTrue(MetaTagsParser.parse(open).count() >= 64) } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParityFixture.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParityFixture.kt index 1d74d5811d..70b09d6eef 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParityFixture.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParityFixture.kt @@ -30,7 +30,7 @@ import kotlinx.serialization.json.jsonPrimitive import java.io.File /** - * Real answers from a live vespa-relay (`search-staging.brainstorm.world`), recorded once by + * Real answers from a live vespa-relay (`search.brainstorm.world`), recorded once by * `tools/search-parity/fetch_fixtures.py` and read from disk here. * * Recorded rather than fetched so `./gradlew test` stays offline and deterministic — a suite that diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchRelayParityTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchRelayParityTest.kt index 83631e1c32..a3bcff9bc3 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchRelayParityTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchRelayParityTest.kt @@ -31,7 +31,7 @@ import kotlin.test.assertTrue * * ## What this can and cannot prove * - * The oracle is `search-staging.brainstorm.world`, a vespa-relay — the same software Amethyst's + * The oracle is `search.brainstorm.world`, a vespa-relay — the same software Amethyst's * token language was ported from. It is a **valid oracle for NIP-01 filtering** and **not one for * NIP-50 text matching**, and the difference is the whole design of this file. * @@ -42,8 +42,8 @@ import kotlin.test.assertTrue * pins it. * * NIP-50 is not closed. This relay retrieves topically, not lexically: asked for `bitcoin` it - * returns a block-height summary that never says "bitcoin", and asked for `nostr` it returns "made - * my display name refer to my npub's last characters". Eight of the 64 recorded events — 12% — + * returns a liquidation-levels heatmap that only ever says "BTC", and asked for `"open source"` it + * returns "FOSS you say will save you". Eight of the 64 recorded events — 12% — * contain no literal occurrence of the term that fetched them. * * The fixture is recorded with the `include:spam` lens, which waives the web-of-trust gate. That diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolutionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolutionTest.kt new file mode 100644 index 0000000000..353aacd5d1 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolutionTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * A relative reference against a base with no path resolves as if the path were `/` (RFC 3986). + * Android's `java.net.URI` got this wrong — `y18.svg` against `https://news.ycombinator.com` became + * `https://news.ycombinator.comy18.svg` — so the base is normalized before resolving. + */ +class HttpUrlResolutionTest { + @Test + fun relativeReferenceAgainstAPathlessBase() { + assertEquals("https://news.ycombinator.com/y18.svg", resolveHttpUrl("https://news.ycombinator.com", "y18.svg")) + assertEquals("https://news.ycombinator.com/favicon.ico", resolveHttpUrl("https://news.ycombinator.com", "/favicon.ico")) + } + + @Test + fun pathlessBaseKeepsItsQueryAndEncoding() { + assertEquals("https://a.example/icon.png", resolveHttpUrl("https://a.example?q=a%20b", "icon.png")) + // Encoded characters are carried over as they are, not encoded a second time. + assertEquals("https://a.example/icons/a%20b.png", resolveHttpUrl("https://a.example?q=a%20b", "icons/a%20b.png")) + } + + @Test + fun basesWithAPathAreUnchanged() { + assertEquals("https://a.example/dir/icon.png", resolveHttpUrl("https://a.example/dir/page.html", "icon.png")) + assertEquals("https://cdn.example/x.png", resolveHttpUrl("https://a.example/dir/", "//cdn.example/x.png")) + } + + @Test + fun nonHttpStaysRefused() { + assertNull(resolveHttpUrl("https://a.example", "file:///etc/passwd")) + } +} diff --git a/commonsUI/build.gradle.kts b/commonsUI/build.gradle.kts index 53b1c20463..d592bac103 100644 --- a/commonsUI/build.gradle.kts +++ b/commonsUI/build.gradle.kts @@ -214,6 +214,10 @@ kotlin { implementation(libs.androidx.core.ktx) // BackHandler, behind the shared PlatformBackHandler. Apache-2.0, already in the app. implementation(libs.androidx.activity.compose) + // Camera permission prompt for TakePicture. Apache-2.0, already in the app. + implementation(libs.accompanist.permissions) + // BiometricPrompt behind rememberDeviceAuthenticator. Apache-2.0, already in the app. + implementation(libs.androidx.biometric.ktx) } } diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/SharedMediaResolver.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/SharedMediaResolver.android.kt new file mode 100644 index 0000000000..371121e23e --- /dev/null +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/SharedMediaResolver.android.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.actions.uploads + +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.IntentCompat +import androidx.core.net.toUri +import androidx.core.util.Consumer +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.launch +import kotlinx.coroutines.withContext + +private class ContentResolverSharedMedia( + private val context: Context, +) : SharedMediaResolver { + override suspend fun resolve(uriString: String?): SelectedMedia? = + uriString?.ifBlank { null }?.toUri()?.let { uri -> + withContext(Dispatchers.IO) { + SelectedMedia(uri, context.contentResolver.getType(uri)) + } + } + + // One trip to the IO dispatcher for the whole batch instead of one context switch per file. + override suspend fun resolve(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() + } + } +} + +@Composable +actual fun rememberSharedMediaResolver(): SharedMediaResolver { + val context = LocalContext.current.applicationContext + return remember(context) { ContentResolverSharedMedia(context) } +} + +private tailrec fun Context.findComponentActivity(): ComponentActivity? = + when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.findComponentActivity() + else -> null + } + +@Composable +actual fun OnIncomingShare( + onText: (String) -> Unit, + onMedia: (SelectedMedia) -> Unit, +) { + val context = LocalContext.current + val activity = context.findComponentActivity() ?: return + val scope = rememberCoroutineScope() + val currentOnText by rememberUpdatedState(onText) + val currentOnMedia by rememberUpdatedState(onMedia) + + DisposableEffect(activity) { + val consumer = + Consumer<Intent> { intent -> + if (intent.action == Intent.ACTION_SEND) { + intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }?.let { + currentOnText(it) + } + + // Use the `intent` parameter (the new intent), not activity.intent (the launch intent). + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri -> + scope.launch(Dispatchers.IO) { + val mediaType = context.contentResolver.getType(uri) + currentOnMedia(SelectedMedia(uri, mediaType)) + } + } + } + } + + activity.addOnNewIntentListener(consumer) + onDispose { activity.removeOnNewIntentListener(consumer) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/TakePicture.android.kt similarity index 76% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt rename to commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/TakePicture.android.kt index ecade0bf8c..dc50335ae3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/TakePicture.android.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions.uploads +package com.vitorpamplona.amethyst.commons.ui.actions.uploads import android.Manifest import android.content.ActivityNotFoundException @@ -29,9 +29,6 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.height -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -39,22 +36,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.no_camera_app_found -import com.vitorpamplona.amethyst.commons.resources.record_a_video -import com.vitorpamplona.amethyst.commons.resources.take_a_picture import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.ui.loadStringRes -import com.vitorpamplona.amethyst.commons.ui.stringRes import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers @@ -65,26 +55,11 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -@Composable -fun TakePictureButton(onPictureTaken: (ImmutableList<SelectedMedia>) -> Unit) { - var showCamera by remember { mutableStateOf(false) } - if (showCamera) { - TakePicture( - onPictureTaken = { uri -> - showCamera = false - if (uri.isNotEmpty()) { - onPictureTaken(uri) - } - }, - ) - } - - PictureButton { showCamera = true } -} +actual val canCaptureFromCamera: Boolean = true @OptIn(ExperimentalPermissionsApi::class) @Composable -fun TakePicture(onPictureTaken: (ImmutableList<SelectedMedia>) -> Unit) { +actual fun TakePicture(onPictureTaken: (ImmutableList<SelectedMedia>) -> Unit) { val context = LocalContext.current var cameraUri by remember { mutableStateOf<Uri?>(null) } val scope = rememberCoroutineScope() @@ -134,20 +109,6 @@ fun TakePicture(onPictureTaken: (ImmutableList<SelectedMedia>) -> Unit) { } } -@Composable -fun PictureButton(onClick: () -> Unit) { - IconButton( - onClick = onClick, - ) { - Icon( - symbol = MaterialSymbols.CameraAlt, - contentDescription = stringRes(id = Res.string.take_a_picture), - modifier = Modifier.height(22.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } -} - /** * Launches the camera capture intent, switching to the main thread so the * [ActivityResultLauncher] is invoked safely. Devices without a camera app throw @@ -178,26 +139,9 @@ fun getPhotoUri(context: Context): Uri = storageDir = Environment.DIRECTORY_PICTURES, ) -@Composable -fun TakeVideoButton(onVideoTaken: (ImmutableList<SelectedMedia>) -> Unit) { - var showCamera by remember { mutableStateOf(false) } - if (showCamera) { - TakeVideo( - onVideoTaken = { uri -> - showCamera = false - if (uri.isNotEmpty()) { - onVideoTaken(uri) - } - }, - ) - } - - VideoButton { showCamera = true } -} - @OptIn(ExperimentalPermissionsApi::class) @Composable -fun TakeVideo(onVideoTaken: (ImmutableList<SelectedMedia>) -> Unit) { +actual fun TakeVideo(onVideoTaken: (ImmutableList<SelectedMedia>) -> Unit) { val context = LocalContext.current var videoUri by remember { mutableStateOf<Uri?>(null) } val scope = rememberCoroutineScope() @@ -247,20 +191,6 @@ fun TakeVideo(onVideoTaken: (ImmutableList<SelectedMedia>) -> Unit) { } } -@Composable -fun VideoButton(onClick: () -> Unit) { - IconButton( - onClick = onClick, - ) { - Icon( - symbol = MaterialSymbols.Videocam, - contentDescription = stringRes(id = Res.string.record_a_video), - modifier = Modifier.height(22.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } -} - fun getVideoUri(context: Context): Uri = getMediaUri( context = context, diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/FileSharer.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/FileSharer.android.kt new file mode 100644 index 0000000000..50c6065060 --- /dev/null +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/FileSharer.android.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.FileProvider +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@Composable +actual fun rememberFileSharer(): FileSharer { + val context = LocalContext.current + return remember(context) { AndroidFileSharer(context) } +} + +private class AndroidFileSharer( + val context: Context, +) : FileSharer { + override fun shareTextFilesAsZip( + zipName: String, + files: List<Pair<String, String>>, + title: String, + ) { + val zipFile = File(context.cacheDir, zipName) + ZipOutputStream(FileOutputStream(zipFile)).use { zip -> + files.forEach { (name, content) -> + zip.putNextEntry(ZipEntry(name)) + zip.write(content.toByteArray()) + zip.closeEntry() + } + } + + val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", zipFile) + + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_TITLE, title) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity(Intent.createChooser(sendIntent, title)) + } + + /** + * The file lives in `cacheDir/shared/`, covered by the `<cache-path>` entry in + * `file_paths.xml`; the receiver's read grant lasts only for the share. + */ + override fun shareTextFile( + fileName: String, + mimeType: String, + content: String, + title: String, + ) { + val dir = File(context.cacheDir, "shared").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 = mimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(Intent.createChooser(intent, title).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } +} diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.android.kt new file mode 100644 index 0000000000..6e23c715b5 --- /dev/null +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.android.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import android.Manifest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +actual fun rememberCoarseLocationPermission(): PlatformPermission { + val state = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) + return remember(state) { + object : PlatformPermission { + override val isGranted: Boolean get() = state.status.isGranted + + override fun launchRequest() = state.launchPermissionRequest() + } + } +} diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt index f4c13f3478..f33865d82e 100644 --- a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt @@ -70,6 +70,12 @@ actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String { return remember(context) { { epochMillis -> DateFormat.getTimeFormat(context).format(Date(epochMillis)) } } } +@Composable +actual fun rememberIs24HourClock(): Boolean { + val context = LocalContext.current + return remember(context) { DateFormat.is24HourFormat(context) } +} + actual fun relativeTimeSpanShortOrNull( epochMillis: Long, nowMillis: Long, diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.android.kt new file mode 100644 index 0000000000..d4ef5180a1 --- /dev/null +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.android.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.privacylock + +import android.app.Activity +import android.app.KeyguardManager +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.os.Build +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.FragmentActivity +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.app_name +import com.vitorpamplona.amethyst.commons.resources.biometric_authentication_failed +import com.vitorpamplona.amethyst.commons.resources.biometric_authentication_failed_explainer +import com.vitorpamplona.amethyst.commons.resources.biometric_authentication_failed_explainer_with_error +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** The action waiting on the keyguard activity, which only its result may run or replace. */ +private class PendingApproval { + var action: (() -> Unit)? = null +} + +@Composable +actual fun rememberDeviceAuthenticator(): DeviceAuthenticator { + val context = LocalContext.current + val labels = rememberAuthPromptLabels() + val pending = remember { PendingApproval() } + + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + val action = pending.action + pending.action = null + if (result.resultCode == Activity.RESULT_OK) { + action?.invoke() + } + } + + return remember(context, labels, keyguardLauncher) { + DeviceAuthenticator { title, onApproved, onError -> + pending.action = onApproved + authenticate( + title = title, + context = context, + labels = labels, + keyguardLauncher = keyguardLauncher, + onApproved = { + pending.action = null + onApproved() + }, + onError = onError, + ) + } + } +} + +private fun Context.findFragmentActivity(): FragmentActivity? { + var current: Context = this + while (current is ContextWrapper) { + if (current is FragmentActivity) return current + current = current.baseContext + } + return null +} + +/** + * Labels the device-credential and biometric prompts need. Resolved in + * composition and passed down because [authenticate] runs from an onClick + * lambda and from Android's own biometric callbacks, neither of which can + * reach a Compose resource. + */ +@Immutable +private data class AuthPromptLabels( + val appName: String, + val failedTitle: String, + val failedExplainer: String, + val failedExplainerWithError: String, +) + +@Composable +private fun rememberAuthPromptLabels(): AuthPromptLabels = + AuthPromptLabels( + appName = stringRes(Res.string.app_name), + failedTitle = stringRes(Res.string.biometric_authentication_failed), + failedExplainer = stringRes(Res.string.biometric_authentication_failed_explainer), + failedExplainerWithError = stringRes(Res.string.biometric_authentication_failed_explainer_with_error), + ) + +private fun authenticate( + title: String, + context: Context, + labels: AuthPromptLabels, + keyguardLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>, + onApproved: () -> Unit, + onError: (String, String) -> Unit, +) { + val fragmentContext = context.findFragmentActivity() ?: return onApproved() + val keyguardManager = + fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + + if (!keyguardManager.isDeviceSecure) { + onApproved() + return + } + + @Suppress("DEPRECATION") + fun keyguardPrompt() { + val intent = + keyguardManager.createConfirmDeviceCredentialIntent( + labels.appName, + title, + ) + + keyguardLauncher.launch(intent) + } + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { + keyguardPrompt() + return + } + + val biometricManager = BiometricManager.from(context) + val authenticators = + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + + val promptInfo = + BiometricPrompt.PromptInfo + .Builder() + .setTitle(labels.appName) + .setSubtitle(title) + .setAllowedAuthenticators(authenticators) + .build() + + val biometricPrompt = + BiometricPrompt( + fragmentContext, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence, + ) { + super.onAuthenticationError(errorCode, errString) + + when (errorCode) { + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> { + keyguardPrompt() + } + + BiometricPrompt.ERROR_LOCKOUT -> { + keyguardPrompt() + } + + else -> { + onError( + labels.failedTitle, + labels.failedExplainerWithError.format(errString.toString()), + ) + } + } + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + onError(labels.failedTitle, labels.failedExplainer) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + onApproved() + } + }, + ) + + when (biometricManager.canAuthenticate(authenticators)) { + BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo) + else -> keyguardPrompt() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/AddToPhoneCalendar.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/PhoneCalendar.android.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/AddToPhoneCalendar.kt rename to commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/PhoneCalendar.android.kt index 11a93892f0..00a73473bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/AddToPhoneCalendar.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/PhoneCalendar.android.kt @@ -18,17 +18,26 @@ * 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 +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.provider.CalendarContract +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.commons.model.nip52Calendar.parseIsoDateToUnixSeconds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +@Composable +actual fun rememberPhoneCalendar(): PhoneCalendar { + val context = LocalContext.current + return remember(context) { PhoneCalendar { addToPhoneCalendar(context, it) } } +} + /** * Opens the system "New Event" composer (Google Calendar / Samsung / iCloud / etc.) pre-populated * with this appointment's title, time, location, and description. The user sees their normal @@ -38,7 +47,7 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent * Returns true if a calendar app handled the intent, false if no handler was found — the caller * can decide whether to fall back to the .ics share path. */ -fun addToPhoneCalendar( +private fun addToPhoneCalendar( context: Context, event: Event, ): Boolean { diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/RelativeTimeSpan.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/RelativeTimeSpan.android.kt new file mode 100644 index 0000000000..36dad1e866 --- /dev/null +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/RelativeTimeSpan.android.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars + +import android.text.format.DateUtils + +actual suspend fun relativeTimeSpan( + targetSeconds: Long, + nowSeconds: Long, + dayResolution: Boolean, +): String = + DateUtils + .getRelativeTimeSpanString( + targetSeconds * 1000L, + nowSeconds * 1000L, + if (dayResolution) DateUtils.DAY_IN_MILLIS else DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE, + ).toString() diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.android.kt index 2977a00f85..aba51de5b0 100644 --- a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.android.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.android.kt @@ -31,9 +31,9 @@ import kotlin.coroutines.cancellation.CancellationException actual fun rememberWalletAppLauncher(): WalletAppLauncher { val context = LocalContext.current return remember(context) { - WalletAppLauncher { invoice, noWalletFound, onPaid, onError -> + WalletAppLauncher { uri, noWalletFound, onPaid, onError -> try { - val intent = Intent(Intent.ACTION_VIEW, "lightning:$invoice".toUri()) + val intent = Intent(Intent.ACTION_VIEW, uri.toUri()) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK context.startActivity(intent) diff --git a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml index 82f9a3ce58..cce0cc7504 100644 --- a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml @@ -580,8 +580,8 @@ <string name="concord_invite_revoking">Visszavonás…</string> <string name="concord_name_budget">%1$d / %2$d bájt</string> <string name="concord_invite_revoke_privatize_warning">Ez a közösség utolsó élő meghívási hivatkozása. A visszavonása priváttá teszi a közösséget, és lecseréli a kulcsait, így csak a jelenlegi tagok tartják meg a hozzáférést.</string> - <string name="concord_invite_revoked_privatized">A hivatkozás visszavonva. A közösség most privát, és a kulcsai le lettek cserélve.</string> - <string name="concord_invite_revoked_privatize_pending">A hivatkozás visszavonva. A közösség most privát, de a kulcsai nem lettek lecserélve: egy olyan adminisztrátornak kell lecserélnie őket, aki kitilthat tagokat.</string> + <string name="concord_invite_revoked_privatized">A hivatkozás vissza lett vonva. A közösség most privát, és a kulcsai le lettek cserélve.</string> + <string name="concord_invite_revoked_privatize_pending">A hivatkozás vissza lett vonva. A közösség most privát, de a kulcsai nem lettek lecserélve: egy olyan adminisztrátornak kell lecserélnie őket, aki kitilthat tagokat.</string> <string name="concord_mode_private">Privát</string> <plurals name="concord_mode_public"> <item quantity="one">Nyilvános · %1$d élő meghívási hivatkozás</item> @@ -3638,7 +3638,7 @@ <string name="concord_channel_make_public_message">A közösség minden tagja olvashatja mostantól a(z) #%1$s csatornát. A privát állapotban közzétett üzenetek csak azoknak a tagoknak maradnak olvashatók, akik birtokolták a kulcsát.</string> <string name="concord_channel_name_label">Csatorna neve</string> <string name="concord_channel_no_messages">Még nincsenek üzenetek</string> - <string name="concord_channel_private_hint">Csak azok a tagok olvashatják, akik birtokolják a hozzáférési szerepkörét. Adja meg a szerepkört, hogy beengedje őket; a visszavonása lecseréli a csatornakulcsot.</string> + <string name="concord_channel_private_hint">Csak azok a tagok olvashatják, akik rendelkeznek az ehhez tartozó hozzáférési szerepkörrel. A szerepkör megadása lehetővé teszi számukra a belépést; a visszavonása a csatorna kulcsának frissítését eredményezi.</string> <string name="concord_channel_private_toggle">Privát csatorna</string> <string name="concord_channel_rename">Csatorna átnevezése</string> <string name="concord_channel_rename_save">Átnevezés</string> @@ -5396,6 +5396,7 @@ <string name="marmot_invite_device_rejected">Egyetlen átjátszó sem fogadta el a KeyPackage-et. Ellenőrizze az átjátszó beállításait, és próbálja újra.</string> <string name="chat_delivery_sending">Küldés\u2026</string> <string name="chat_delivery_failed">Nem sikerült elküldeni. Koppintson az újrapróbálkozáshoz.</string> + <string name="chat_delivery_rejected">Az átjátszó elutasította ezt az üzenetet</string> <plurals name="marmot_unread_messages"> <item quantity="one">%1$d új üzenet</item> <item quantity="other">%1$d új üzenet</item> @@ -5464,13 +5465,13 @@ <string name="cordn_preview_video">Videó</string> <string name="cordn_preview_file">Fájl</string> <string name="new_conversation_cordn_title">Cordn csoport</string> - <string name="new_conversation_cordn_tagline">Titkosított csoportos csevegés, amelyet az Ön által választott koordinátor rendez.</string> + <string name="new_conversation_cordn_tagline">Titkosított csoportok egy Ön által választott koordinátoron</string> <string name="new_conversation_cordn_chip">Koordinált</string> - <string name="new_conversation_cordn_best">Azoknak a csapatoknak a legjobb, amelyek a beszélgetés egyetlen megbízható sorrendjét szeretnék.</string> + <string name="new_conversation_cordn_best">Azon csapatok számára, amelyek a beszélgetés egy megbízható sorrendjét szeretnék.</string> <string name="new_conversation_cordn_cta">Cordn csoport létrehozása</string> - <string name="new_conversation_cordn_pro_1">Végponttól végpontig titkosított MLS-sel – a koordinátor soha nem olvashat el egyetlen üzenetet sem.</string> - <string name="new_conversation_cordn_pro_2">Egy mindenki által elfogadott sorrend, így az előzményeket egy hibás óra sem keverheti össze.</string> - <string name="new_conversation_cordn_con_1">A koordinátor tudja, ki van a csoportban és mikor beszélnek, még ha nem is tudja elolvasni, mit mondanak.</string> + <string name="new_conversation_cordn_pro_1">A koordinátor nem tudja elolvasni az üzeneteket</string> + <string name="new_conversation_cordn_pro_2">Egy mindenki által elfogadott üzenetrendezési sorrend</string> + <string name="new_conversation_cordn_con_1">A koordinátor tudja, ki van a csoportban és mikor</string> <string name="cordn_invitations_entry_action">Meghívók áttekintése</string> <!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a one-shot handoff rather than continuous sync. The exposure strings say @@ -6276,4 +6277,30 @@ <string name="browser_pill_download_risky">Ez a fájltípus képes kódot futtatni. Ellenőrizze hogy a név megegyezik-e azzal, amit le akart tölteni.</string> <string name="browser_pill_download_save">Mentés</string> <string name="browser_pill_download_from">Innen: %1$s</string> + <string name="prediction_market">Fogadási piac</string> + <string name="prediction_market_status_open">Nyitva</string> + <string name="prediction_market_status_closed">Zárva</string> + <string name="prediction_market_status_resolved">Megoldva</string> + <string name="prediction_market_status_cancelled">Megszakítva</string> + <string name="prediction_market_demo">Bemutató · játékpénz</string> + <string name="prediction_market_closes_in">Bezár ekkor: %1$s</string> + <string name="prediction_market_closed_on">Bezárt ekkor: %1$s</string> + <string name="prediction_market_bet_range">Fogadások: %1$s-%2$s satoshi</string> + <string name="prediction_market_min_bet">Legkisebb tét: %1$s satoshi</string> + <string name="prediction_market_max_bet">Legnagyobb tét: %1$s satoshi</string> + <string name="prediction_market_fee">Díj: %1$s</string> + <string name="prediction_market_cancel_reason">Megszakítva: %1$s</string> + <string name="prediction_market_winning_outcome">Nyertes eredmény</string> + <plurals name="prediction_market_more_outcomes"> + <item quantity="one">+%1$d további</item> + <item quantity="other">+%1$d további</item> + </plurals> + <string name="ballot">Szavazólap</string> + <string name="ballot_election">Választás: %1$s</string> + <string name="ballot_vote">Szavazás</string> + <string name="ballot_proof_hash">Bizonyíték: %1$s</string> + <plurals name="ballot_more_answers"> + <item quantity="one">+%1$d további válasz</item> + <item quantity="other">+%1$d további válasz</item> + </plurals> </resources> diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 8fae0ac17e..1ec51debdf 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -5469,6 +5469,7 @@ <string name="marmot_invite_device_rejected">No relay accepted the KeyPackage. Check your relay settings and try again.</string> <string name="chat_delivery_sending">Sending\u2026</string> <string name="chat_delivery_failed">Could not be sent. Tap to try again.</string> + <string name="chat_delivery_rejected">The relay refused this message</string> <plurals name="marmot_unread_messages"> <item quantity="one">%1$d new message</item> <item quantity="other">%1$d new messages</item> @@ -6364,4 +6365,51 @@ <string name="audio_card_untitled">%1$s audio</string> <string name="audio_card_untitled_no_format">Audio</string> <string name="playable_media_untitled">Media</string> + <string name="now_playing_settings">Now Playing</string> + <string name="now_playing_search_keywords">music listening status song podcast spotify nip38 share playing</string> + <string name="now_playing_description">Your status is public. It appears under your name and disappears when the track ends or you stop playing.</string> + <string name="now_playing_preview_title">How others see it</string> + <string name="now_playing_preview_example">Song title - Artist</string> + <string name="now_playing_preview_live">Live</string> + <string name="now_playing_preview_sample">Example</string> + <string name="now_playing_preview_off">Off</string> + <string name="now_playing_preview_you">You</string> + <string name="now_playing_section_sources">Share from</string> + <string name="now_playing_share_in_app">Amethyst</string> + <string name="now_playing_share_in_app_description">Music tracks and podcast episodes you play here</string> + <string name="now_playing_share_other_apps">Other apps</string> + <string name="now_playing_share_other_apps_description">Spotify, YouTube Music, podcast players and any app with media controls</string> + <string name="now_playing_access_needed_title">Notification access needed</string> + <string name="now_playing_access_needed">Android only shows other apps’ media controls to apps with notification access. Amethyst reads what is playing, never the content of your notifications.</string> + <string name="now_playing_grant_access">Grant access</string> + <string name="now_playing_manage_access">Manage</string> + <string name="now_playing_access_granted">Notification access is on</string> + <string name="now_playing_apps">Apps</string> + <string name="now_playing_apps_empty">Apps show up here after they play something. Turn one off to stop sharing it.</string> + <string name="prediction_market">Prediction market</string> + <string name="prediction_market_status_open">Open</string> + <string name="prediction_market_status_closed">Closed</string> + <string name="prediction_market_status_resolved">Resolved</string> + <string name="prediction_market_status_cancelled">Cancelled</string> + <string name="prediction_market_demo">Demo · play money</string> + <string name="prediction_market_closes_in">Closes in %1$s</string> + <string name="prediction_market_closed_on">Closed %1$s</string> + <string name="prediction_market_bet_range">Bets %1$s–%2$s sats</string> + <string name="prediction_market_min_bet">Min bet %1$s sats</string> + <string name="prediction_market_max_bet">Max bet %1$s sats</string> + <string name="prediction_market_fee">Fee %1$s</string> + <string name="prediction_market_cancel_reason">Cancelled: %1$s</string> + <string name="prediction_market_winning_outcome">Winning outcome</string> + <plurals name="prediction_market_more_outcomes"> + <item quantity="one">+%1$d more</item> + <item quantity="other">+%1$d more</item> + </plurals> + <string name="ballot">Ballot</string> + <string name="ballot_election">Election %1$s</string> + <string name="ballot_vote">Vote</string> + <string name="ballot_proof_hash">Proof %1$s</string> + <plurals name="ballot_more_answers"> + <item quantity="one">+%1$d more answer</item> + <item quantity="other">+%1$d more answers</item> + </plurals> </resources> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VoiceAnonymizationController.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VoiceAnonymizationController.kt index 3f3a70fb4c..7fe80b0f86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VoiceAnonymizationController.kt @@ -18,22 +18,24 @@ * 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 +package com.vitorpamplona.amethyst.commons.audio import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import com.vitorpamplona.amethyst.commons.audio.VoicePreset +import com.vitorpamplona.amethyst.commons.util.platformFileSystem import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import java.io.File +import okio.Path class VoiceAnonymizationController( private val scope: CoroutineScope, private val logTag: String, private val onError: (Throwable) -> Unit, + /** Re-voices a recording with a preset; the platform's audio pipeline, reached through the host. */ + private val anonymize: suspend (Path, VoicePreset) -> Result<AnonymizedResult>, ) { var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE) private set @@ -44,7 +46,7 @@ class VoiceAnonymizationController( private var processingJob: Job? = null - fun activeFile(originalFile: File?): File? = + fun activeFile(originalFile: Path?): Path? = if (selectedPreset == VoicePreset.NONE) { originalFile } else { @@ -60,7 +62,7 @@ class VoiceAnonymizationController( fun selectPreset( preset: VoicePreset, - originalFile: File?, + originalFile: Path?, ) { Log.d(logTag) { "selectPreset called with: ${preset.name}, pitchFactor: ${preset.pitchFactor}" } if (processingPreset != null || preset == selectedPreset) return @@ -82,8 +84,7 @@ class VoiceAnonymizationController( processingJob = scope.launch { try { - val anonymizer = VoiceAnonymizer() - val result = anonymizer.anonymize(file, preset) + val result = anonymize(file, preset) result .onSuccess { anonymizedResult -> @@ -109,15 +110,10 @@ class VoiceAnonymizationController( fun deleteDistortedFiles() { distortedFiles.values.forEach { result -> try { - if (result.file.exists()) { - if (result.file.delete()) { - Log.d(logTag) { "Deleted distorted file: ${result.file.absolutePath}" } - } else { - Log.w(logTag) { "Failed to delete distorted file: ${result.file.absolutePath}" } - } - } + platformFileSystem.delete(result.file, mustExist = false) + Log.d(logTag) { "Deleted distorted file: ${result.file}" } } catch (e: Exception) { - Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e) + Log.w(logTag, "Failed to delete distorted file: ${result.file}", e) } } distortedFiles = emptyMap() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt index e0b580ef74..ea54209973 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc +package com.vitorpamplona.amethyst.commons.relayClient.reqCommand.nwc import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/searchCommand/TextSearchDataSourceSubscription.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/searchCommand/TextSearchDataSourceSubscription.kt index bd1d0545e7..3d7586b0c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/searchCommand/TextSearchDataSourceSubscription.kt @@ -18,13 +18,13 @@ * 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.relayClient.searchCommand +package com.vitorpamplona.amethyst.commons.relayClient.searchCommand import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.commons.relayClient.searchCommand.SearchFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.search.SearchBarViewModel import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel @Composable fun TextSearchDataSourceSubscription( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/NewMediaModel.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/NewMediaModel.kt index 9ee3618759..7c74829b87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/NewMediaModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions +package com.vitorpamplona.amethyst.commons.ui.actions import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/NewMediaView.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/NewMediaView.kt index ba41954360..70820a51e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/NewMediaView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions +package com.vitorpamplona.amethyst.commons.ui.actions import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -50,7 +50,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.commons.model.VideoPostKind import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.commons.resources.Res @@ -76,6 +75,7 @@ import com.vitorpamplona.amethyst.commons.ui.actions.uploads.ShowImageUploadGall import com.vitorpamplona.amethyst.commons.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.commons.ui.components.edgeToEdgeDialogProperties import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar @@ -111,12 +111,7 @@ fun NewMediaView( Dialog( onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), + properties = edgeToEdgeDialogProperties(dismissOnClickOutside = false), ) { SetDialogToEdgeToEdge() Scaffold( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/UrlUserTagOutputTransformation.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/UrlUserTagOutputTransformation.kt index ba59fe6ca9..490a21197a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/UrlUserTagOutputTransformation.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions +package com.vitorpamplona.amethyst.commons.ui.actions import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.foundation.text.input.TextFieldBuffer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/bolt12Offers/Bolt12OffersScreen.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/bolt12Offers/Bolt12OffersScreen.kt index 61f916c933..6539326b30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/bolt12Offers/Bolt12OffersScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions.bolt12Offers +package com.vitorpamplona.amethyst.commons.ui.actions.bolt12Offers import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -51,7 +51,6 @@ import androidx.compose.ui.text.style.TextAlign 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.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -63,9 +62,12 @@ import com.vitorpamplona.amethyst.commons.resources.bolt12_offers_section_explai import com.vitorpamplona.amethyst.commons.resources.delete_bolt12_offer import com.vitorpamplona.amethyst.commons.resources.invalid_bolt12_offer import com.vitorpamplona.amethyst.commons.resources.no_bolt12_offers_message +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel 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.profile.header.abbreviateBolt12Offer +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -75,15 +77,13 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer 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.screen.loggedIn.profile.header.abbreviateBolt12Offer -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory @Composable fun Bolt12OffersScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val viewModel: Bolt12OffersViewModel = viewModel() + val viewModel: Bolt12OffersViewModel = rememberViewModel { Bolt12OffersViewModel() } viewModel.init(accountViewModel) LaunchedEffect(key1 = accountViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt index bf3c50e88e..9cdcc19f2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions.bolt12Offers +package com.vitorpamplona.amethyst.commons.ui.actions.bolt12Offers import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel @@ -33,7 +33,7 @@ import kotlinx.coroutines.launch /** * Edits the logged-in user's NIP-B1 BOLT12 offer list (kind 10058). Mirrors - * [com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsViewModel]; + * [com.vitorpamplona.amethyst.commons.ui.actions.paymentTargets.PaymentTargetsViewModel]; * each entry is a canonical raw `lno1...` offer string. */ @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/mediaServers/FileServerSelectionRow.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/mediaServers/FileServerSelectionRow.kt index cc37db7d72..98ab16054c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/mediaServers/FileServerSelectionRow.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions.mediaServers +package com.vitorpamplona.amethyst.commons.ui.actions.mediaServers import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/paymentTargets/PaymentTargetsScreen.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/paymentTargets/PaymentTargetsScreen.kt index 1ff0fb807a..722e91734b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/paymentTargets/PaymentTargetsScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions.paymentTargets +package com.vitorpamplona.amethyst.commons.ui.actions.paymentTargets import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -49,7 +49,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -61,9 +60,11 @@ import com.vitorpamplona.amethyst.commons.resources.payment_target_type import com.vitorpamplona.amethyst.commons.resources.payment_targets import com.vitorpamplona.amethyst.commons.resources.payment_targets_explainer import com.vitorpamplona.amethyst.commons.resources.payment_targets_section_explainer +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel 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.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -73,7 +74,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer 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.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget @Composable @@ -81,7 +81,7 @@ fun PaymentTargetsScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val viewModel: PaymentTargetsViewModel = viewModel() + val viewModel: PaymentTargetsViewModel = rememberViewModel { PaymentTargetsViewModel() } viewModel.init(accountViewModel) LaunchedEffect(key1 = accountViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/paymentTargets/PaymentTargetsViewModel.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/paymentTargets/PaymentTargetsViewModel.kt index d7850cb550..5f0b2eeda7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/paymentTargets/PaymentTargetsViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions.paymentTargets +package com.vitorpamplona.amethyst.commons.ui.actions.paymentTargets import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/SharedMediaResolver.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/SharedMediaResolver.kt new file mode 100644 index 0000000000..8949989d98 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/SharedMediaResolver.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.actions.uploads + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import kotlinx.collections.immutable.ImmutableList + +/** + * Turns the file addresses another app shared with this one (an Android SEND intent's + * `content://` URIs) into [SelectedMedia], reading each MIME type off the main thread. + */ +interface SharedMediaResolver { + /** Null for a null or blank address. */ + suspend fun resolve(uriString: String?): SelectedMedia? + + /** Batch form for multi-file shares; blank entries are dropped. */ + suspend fun resolve(uriStrings: List<String>): ImmutableList<SelectedMedia> +} + +@Composable +expect fun rememberSharedMediaResolver(): SharedMediaResolver + +/** + * Calls [onText] and [onMedia] for content shared into the app while this screen is showing (a + * keyboard sending a GIF as a new share, say). Android listens for new SEND intents; the other + * platforms receive none yet. + */ +@Composable +expect fun OnIncomingShare( + onText: (String) -> Unit, + onMedia: (SelectedMedia) -> Unit, +) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/TakePicture.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/TakePicture.kt new file mode 100644 index 0000000000..402335134d --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/TakePicture.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.actions.uploads + +import androidx.compose.foundation.layout.height +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.record_a_video +import com.vitorpamplona.amethyst.commons.resources.take_a_picture +import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.stringRes +import kotlinx.collections.immutable.ImmutableList + +/** Whether this platform can capture a photo or video from a camera app. */ +expect val canCaptureFromCamera: Boolean + +/** + * Asks for the camera permission if needed, then opens the camera to take a picture. Reports the + * captured media, or an empty list when the user cancels or no camera app is available. + */ +@Composable +expect fun TakePicture(onPictureTaken: (ImmutableList<SelectedMedia>) -> Unit) + +/** Like [TakePicture], but records a video. */ +@Composable +expect fun TakeVideo(onVideoTaken: (ImmutableList<SelectedMedia>) -> Unit) + +@Composable +fun TakePictureButton(onPictureTaken: (ImmutableList<SelectedMedia>) -> Unit) { + if (!canCaptureFromCamera) return + + var showCamera by remember { mutableStateOf(false) } + if (showCamera) { + TakePicture( + onPictureTaken = { uri -> + showCamera = false + if (uri.isNotEmpty()) { + onPictureTaken(uri) + } + }, + ) + } + + PictureButton { showCamera = true } +} + +@Composable +fun PictureButton(onClick: () -> Unit) { + IconButton( + onClick = onClick, + ) { + Icon( + symbol = MaterialSymbols.CameraAlt, + contentDescription = stringRes(id = Res.string.take_a_picture), + modifier = Modifier.height(22.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } +} + +@Composable +fun TakeVideoButton(onVideoTaken: (ImmutableList<SelectedMedia>) -> Unit) { + if (!canCaptureFromCamera) return + + var showCamera by remember { mutableStateOf(false) } + if (showCamera) { + TakeVideo( + onVideoTaken = { uri -> + showCamera = false + if (uri.isNotEmpty()) { + onVideoTaken(uri) + } + }, + ) + } + + VideoButton { showCamera = true } +} + +@Composable +fun VideoButton(onClick: () -> Unit) { + IconButton( + onClick = onClick, + ) { + Icon( + symbol = MaterialSymbols.Videocam, + contentDescription = stringRes(id = Res.string.record_a_video), + modifier = Modifier.height(22.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/UploadProgressIndicator.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/UploadProgressIndicator.kt index 0a8c4439ea..0da947cfed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/UploadProgressIndicator.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions.uploads +package com.vitorpamplona.amethyst.commons.ui.actions.uploads import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationSection.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/VoiceAnonymizationSection.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationSection.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/VoiceAnonymizationSection.kt index 28b2cfe15a..8f10a60d63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationSection.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/VoiceAnonymizationSection.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions.uploads +package com.vitorpamplona.amethyst.commons.ui.actions.uploads import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePresetSelector.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/VoicePresetSelector.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePresetSelector.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/VoicePresetSelector.kt index 3e0ca3fbd4..4a9faa8802 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePresetSelector.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/VoicePresetSelector.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions.uploads +package com.vitorpamplona.amethyst.commons.ui.actions.uploads import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -32,7 +32,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.audio.VoicePreset import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -44,7 +43,6 @@ fun VoicePresetSelector( onPresetSelected: (VoicePreset) -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current val isProcessing = processingPreset != null Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/InlineQuoteRenderer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/DefaultInlineQuoteRenderer.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/InlineQuoteRenderer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/DefaultInlineQuoteRenderer.kt index 728be96715..0e576a45b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/InlineQuoteRenderer.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/DefaultInlineQuoteRenderer.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.components +package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.material3.MaterialTheme import com.vitorpamplona.amethyst.commons.ui.components.InlineQuoteRenderer diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/FileSharer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/FileSharer.kt new file mode 100644 index 0000000000..10d368ec52 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/FileSharer.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.runtime.Composable + +/** Hands generated files to the platform's share sheet. */ +interface FileSharer { + /** + * Packs [files] (name to text content) into a zip named [zipName] and shares it, behind a + * chooser titled [title] where the platform has one. + */ + fun shareTextFilesAsZip( + zipName: String, + files: List<Pair<String, String>>, + title: String, + ) + + /** + * Writes [content] to a file named [fileName] and shares it as [mimeType], behind a chooser + * titled [title] where the platform has one. + */ + fun shareTextFile( + fileName: String, + mimeType: String, + content: String, + title: String, + ) +} + +/** + * The platform's file sharing: Android writes the zip to its cache and shares it through the + * app's `FileProvider`. Desktop and iOS have no share sheet wired yet and do nothing. + */ +@Composable +expect fun rememberFileSharer(): FileSharer diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.kt new file mode 100644 index 0000000000..d49f8e84ab --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.runtime.Composable + +/** + * The approximate-location permission the geohash features need. Android asks for + * `ACCESS_COARSE_LOCATION`; desktop and iOS have no location source wired yet and report it denied. + */ +@Composable +expect fun rememberCoarseLocationPermission(): PlatformPermission diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/PlatformPermission.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/PlatformPermission.kt new file mode 100644 index 0000000000..d2ebb6b6a2 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/PlatformPermission.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.runtime.Stable + +/** A runtime permission the user may grant or deny. [isGranted] is observable from composition. */ +@Stable +interface PlatformPermission { + val isGranted: Boolean + + /** Asks the user for the permission; [isGranted] updates when they answer. */ + fun launchRequest() +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.kt index 5542f13703..0112f8656c 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.kt @@ -67,6 +67,7 @@ inline fun <reified VM : ViewModel> rememberViewModel( * when the store has no view model yet. */ @Composable +@PublishedApi internal fun <VM : ViewModel> rememberLambdaFactory(factory: () -> VM): ViewModelProvider.Factory { val currentFactory by rememberUpdatedState(factory) return remember { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ThinPaddingTextField.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ThinPaddingTextField.kt index 00bd0ac935..8680b626a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ThinPaddingTextField.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.components +package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.interaction.MutableInteractionSource diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt index a084cb0edf..66853c6bac 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt @@ -20,26 +20,37 @@ */ package com.vitorpamplona.amethyst.commons.ui.components +import androidx.collection.LruCache import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import coil3.network.HttpException import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem @@ -50,9 +61,12 @@ import com.vitorpamplona.amethyst.commons.resources.link_actions_dialog_title import com.vitorpamplona.amethyst.commons.resources.url_preview_open_in_browser import com.vitorpamplona.amethyst.commons.ui.components.util.setText import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.MaxWidthWithHorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.Size14Modifier +import com.vitorpamplona.amethyst.commons.ui.theme.Size24Modifier +import com.vitorpamplona.amethyst.commons.ui.theme.SmallBorder import com.vitorpamplona.amethyst.commons.ui.theme.innerPostModifier import com.vitorpamplona.amethyst.commons.ui.theme.previewCardImageModifier import kotlinx.coroutines.launch @@ -101,22 +115,50 @@ fun UrlPreviewCard( } } - Column( - modifier = - MaterialTheme.colorScheme.innerPostModifier - .combinedClickable( - onClick = { - if (onCardClick != null) { - onCardClick() - } else { - runCatching { uri.openUri(url) } - } - }, - onLongClick = { - popupExpanded.value = true - }, - ), - ) { + val cardModifier = + MaterialTheme.colorScheme.innerPostModifier + .combinedClickable( + onClick = { + if (onCardClick != null) { + onCardClick() + } else { + runCatching { uri.openUri(url) } + } + }, + onLongClick = { + popupExpanded.value = true + }, + ) + + // Only meaningful when the card's own tap does something else (e.g. opening the comment + // thread); otherwise it would duplicate the card's open-in-browser tap. + val hasCardClick = onCardClick != null + val onOpenInBrowser: (() -> Unit)? = + remember(hasCardClick, url, uri) { + if (hasCardClick) { + { runCatching { uri.openUri(url) } } + } else { + null + } + } + + // A page with no picture to lead with -- most often one with no OpenGraph at all, previewed + // from its `<title>`, meta description and favicon -- gets the short, wide card. Painting the + // big layout without its image would leave a text block that only looks broken. + if (previewInfo.imageUrlFullPath.isBlank() && previewInfo.hasTextPreview) { + CompactUrlPreviewCard(previewInfo, cardModifier, onOpenInBrowser) + } else { + LargeUrlPreviewCard(previewInfo, cardModifier, onOpenInBrowser) + } +} + +@Composable +private fun LargeUrlPreviewCard( + previewInfo: UrlInfoItem, + modifier: Modifier, + onOpenInBrowser: (() -> Unit)?, +) { + Column(modifier = modifier) { // A Loaded preview no longer implies an image: a player page that ships no cover art is // kept (it has media to play), and painting its empty string left a blank 180dp box. if (previewInfo.imageUrlFullPath.isNotBlank()) { @@ -141,20 +183,7 @@ fun UrlPreviewCard( overflow = TextOverflow.Ellipsis, ) - // Only meaningful when the card's own tap does something else (e.g. opening the - // comment thread); otherwise it would duplicate the card's open-in-browser tap. - if (onCardClick != null) { - IconButton( - onClick = { runCatching { uri.openUri(url) } }, - ) { - Icon( - symbol = MaterialSymbols.AutoMirrored.OpenInNew, - contentDescription = stringRes(Res.string.url_preview_open_in_browser), - modifier = Size14Modifier, - tint = Color.Gray, - ) - } - } + onOpenInBrowser?.let { OpenInBrowserButton(it) } } Text( @@ -177,3 +206,121 @@ fun UrlPreviewCard( Spacer(modifier = DoubleVertSpacer) } } + +/** + * The short, wide card: the site's icon on the left, its title, description and host on the + * right. Built from what every page has even without OpenGraph -- what a browser tab shows. + */ +@Composable +private fun CompactUrlPreviewCard( + previewInfo: UrlInfoItem, + modifier: Modifier, + onOpenInBrowser: (() -> Unit)?, +) { + val host = previewInfo.verifiedHost ?: previewInfo.url + + Row( + modifier = modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UrlPreviewIcon(previewInfo.iconUrlFullPath, host) + + Spacer(modifier = DoubleHorzSpacer) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = previewInfo.title.ifBlank { host }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (previewInfo.description.isNotBlank()) { + Text( + text = previewInfo.description, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + // When there is no title the host already stands in for it on the first line. + if (previewInfo.title.isNotBlank()) { + Text( + text = host, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + onOpenInBrowser?.let { OpenInBrowserButton(it) } + } +} + +/** + * Icon URLs the server refused (404 and the like) in this process. The image loader caches + * successes but not failures, so without this a site with no `/favicon.ico` would be asked for it + * again -- and 404 again -- every time its card scrolled back into view. + */ +private val failedIconUrls = LruCache<String, Unit>(200) + +/** + * The site's icon in a fixed square. A generic globe sits there until the icon actually loads, + * so a site whose favicon is missing (the `/favicon.ico` guess is only a guess) or undecodable + * still gets a tidy card instead of an empty hole. + */ +@Composable +private fun UrlPreviewIcon( + iconUrl: String?, + contentDescription: String, +) { + Box( + modifier = + UrlPreviewIconModifier + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center, + ) { + var loaded by remember(iconUrl) { mutableStateOf(false) } + + if (!loaded) { + Icon( + symbol = MaterialSymbols.Language, + contentDescription = null, + modifier = Size24Modifier, + tint = Color.Gray, + ) + } + + if (iconUrl != null && failedIconUrls[iconUrl] == null) { + AsyncImage( + model = iconUrl, + contentDescription = contentDescription, + contentScale = ContentScale.Fit, + modifier = UrlPreviewIconImageModifier, + onSuccess = { loaded = true }, + // Only a server's answer is remembered. A network error (offline, a timeout) says + // nothing about the icon and must be retried once the connection is back. + onError = { if (it.result.throwable is HttpException) failedIconUrls.put(iconUrl, Unit) }, + ) + } + } +} + +@Composable +private fun OpenInBrowserButton(onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon( + symbol = MaterialSymbols.AutoMirrored.OpenInNew, + contentDescription = stringRes(Res.string.url_preview_open_in_browser), + modifier = Size14Modifier, + tint = Color.Gray, + ) + } +} + +private val UrlPreviewIconModifier = Modifier.size(48.dp).clip(SmallBorder) +private val UrlPreviewIconImageModifier = Modifier.fillMaxSize().padding(6.dp) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/WindowViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/WindowViewModel.kt new file mode 100644 index 0000000000..f2084bd3c6 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/WindowViewModel.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import kotlin.reflect.KClass + +/** + * The store that outlives every navigation destination in this window: Android's Activity. The + * platform entry point provides it above the nav host, whose destinations each get a store of + * their own. + */ +val LocalWindowViewModelStoreOwner: ProvidableCompositionLocal<ViewModelStoreOwner?> = staticCompositionLocalOf { null } + +/** + * A view model shared by every destination of this window, for state that has to survive + * navigating between screens (a draft filled in over two screens, a game the lobby and the board + * both watch). Without a window store it falls back to [rememberViewModel], scoped to the caller. + */ +@Composable +fun <VM : ViewModel> rememberWindowViewModel( + modelClass: KClass<VM>, + key: String, + factory: ViewModelProvider.Factory, +): VM { + val owner = LocalWindowViewModelStoreOwner.current ?: return rememberViewModel(modelClass, key, factory) + return remember(owner, key) { ViewModelProvider.create(owner, factory).get(key, modelClass) } +} + +@Composable +inline fun <reified VM : ViewModel> rememberWindowViewModel( + key: String, + factory: ViewModelProvider.Factory, +): VM = rememberWindowViewModel(VM::class, key, factory) + +@Composable +inline fun <reified VM : ViewModel> rememberWindowViewModel( + key: String, + noinline factory: () -> VM, +): VM = rememberWindowViewModel(VM::class, key, rememberLambdaFactory(factory)) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/RememberExtendedNav.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/RememberExtendedNav.kt new file mode 100644 index 0000000000..9fafd83a95 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/RememberExtendedNav.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.navigation.navs + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope + +/** [nav], calling [onBeforeNavigate] first on every navigation (to close a sheet or dialog). */ +@Composable +fun rememberExtendedNav( + nav: INav, + onBeforeNavigate: () -> Unit, +): INav { + val scope = rememberCoroutineScope() + return remember(nav, scope) { + ObservableNav(nav, scope, onBeforeNavigate) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitStatusPill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitStatusPill.kt index 5ad02ef4bf..f61655fc25 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitStatusPill.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitStatusPill.kt @@ -104,6 +104,22 @@ fun GitStatusPill( } } + StatusPill(label, symbol, container, content, modifier) +} + +/** + * The filled, GitHub-style state badge behind [GitStatusPill]: an icon and a short label in + * [content] on a [container] fill. Shared by the other cards that show a lifecycle state + * (prediction markets) so every status badge in a feed looks alike. + */ +@Composable +fun StatusPill( + label: String, + symbol: MaterialSymbol, + container: Color, + content: Color, + modifier: Modifier = Modifier, +) { Row( modifier = modifier @@ -124,6 +140,7 @@ fun GitStatusPill( style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, color = content, + maxLines = 1, ) } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt index 682ee75b5a..55adb2159f 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt @@ -170,6 +170,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestationRequest import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestorProficiency import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestorRecommendation import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBadgeAward +import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBallot import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBlossomPieceIndex import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBolt12Zap import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBookshelfDirectory @@ -211,6 +212,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPodcastEpisode import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPodcastMetadata import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPostApproval +import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPredictionMarket import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPrivateMessage import com.vitorpamplona.amethyst.commons.ui.note.types.RenderProfileCard import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPs1Save @@ -309,6 +311,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.ballots.BallotEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward @@ -329,6 +332,7 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent import com.vitorpamplona.quartz.experimental.publications.PublicationContentEvent import com.vitorpamplona.quartz.experimental.publications.PublicationIndexEvent @@ -1492,6 +1496,14 @@ private fun RenderNoteRow( RenderMintRecommendation(noteEvent) } + is PredictionMarketEvent -> { + RenderPredictionMarket(baseNote, makeItShort, accountViewModel) + } + + is BallotEvent -> { + RenderBallot(baseNote, accountViewModel) + } + is ClassifiedsEvent -> { RenderClassifieds( noteEvent, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt index 4b88cbbbff..d8962cc592 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt @@ -45,6 +45,10 @@ expect fun calendarYearAndDay(epochMillis: Long): Int @Composable expect fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String +/** Whether times should be shown on a 24-hour clock, under the same rules as [rememberTimeOfDayFormatter]. */ +@Composable +expect fun rememberIs24HourClock(): Boolean + /** * The platform's abbreviated relative span ("5 min. ago", "Yesterday"), [nowLabel] for an * instant that rounds to now or lies ahead, or null where the platform has no such formatter. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UpdateZapAmountDialog.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UpdateZapAmountDialog.kt index d2f83d0952..cd525449a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UpdateZapAmountDialog.kt @@ -18,16 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note +package com.vitorpamplona.amethyst.commons.ui.note -import android.app.KeyguardManager -import android.content.Context -import android.content.Intent -import android.os.Build -import androidx.activity.compose.ManagedActivityResultLauncher -import androidx.activity.result.ActivityResult -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring @@ -59,7 +51,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateMapOf @@ -81,15 +72,10 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.add -import com.vitorpamplona.amethyst.commons.resources.app_name -import com.vitorpamplona.amethyst.commons.resources.biometric_authentication_failed -import com.vitorpamplona.amethyst.commons.resources.biometric_authentication_failed_explainer -import com.vitorpamplona.amethyst.commons.resources.biometric_authentication_failed_explainer_with_error import com.vitorpamplona.amethyst.commons.resources.new_amount_in_sats import com.vitorpamplona.amethyst.commons.resources.quick_zap_amounts import com.vitorpamplona.amethyst.commons.resources.quick_zap_amounts_explainer @@ -110,6 +96,7 @@ import com.vitorpamplona.amethyst.commons.resources.zap_type_public_explainer import com.vitorpamplona.amethyst.commons.resources.zap_type_section_explainer import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder @@ -121,17 +108,16 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.util.showAmount import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.getFragmentActivity import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.utils.BigDecimal import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.launch @OptIn(ExperimentalLayoutApi::class) @Composable @Preview(device = "spec:width=1900px,height=2340px,dpi=440") fun UpdateZapAmountContentPreview() { val accountViewModel = mockAccountViewModel() - val vm: UpdateZapAmountViewModel = viewModel() + val vm: UpdateZapAmountViewModel = rememberViewModel { UpdateZapAmountViewModel() } vm.init(accountViewModel) ThemeComparisonRow { @@ -428,7 +414,7 @@ private fun ZapAmountPresetChip( ZapRailIcon(rails.first(), colored = true) Spacer(Modifier.width(5.dp)) Text( - text = showAmount(amountInSats.toBigDecimal().setScale(1)), + text = showAmount(BigDecimal(amountInSats)), color = MaterialTheme.colorScheme.onPrimaryContainer, fontWeight = FontWeight.SemiBold, ) @@ -449,118 +435,3 @@ private fun ZapAmountPresetChip( } } } - -/** - * Labels the device-credential and biometric prompts need. Resolved in - * composition and passed down because [authenticate] runs from an onClick - * lambda and from Android's own biometric callbacks, neither of which can - * reach a Compose resource. - */ -@Immutable -data class AuthPromptLabels( - val appName: String, - val failedTitle: String, - val failedExplainer: String, - val failedExplainerWithError: String, -) - -@Composable -fun rememberAuthPromptLabels(): AuthPromptLabels = - AuthPromptLabels( - appName = stringRes(Res.string.app_name), - failedTitle = stringRes(Res.string.biometric_authentication_failed), - failedExplainer = stringRes(Res.string.biometric_authentication_failed_explainer), - failedExplainerWithError = stringRes(Res.string.biometric_authentication_failed_explainer_with_error), - ) - -fun authenticate( - title: String, - context: Context, - labels: AuthPromptLabels, - keyguardLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>, - onApproved: () -> Unit, - onError: (String, String) -> Unit, -) { - val fragmentContext = context.getFragmentActivity()!! - val keyguardManager = - fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager - - if (!keyguardManager.isDeviceSecure) { - onApproved() - return - } - - @Suppress("DEPRECATION") - fun keyguardPrompt() { - val intent = - keyguardManager.createConfirmDeviceCredentialIntent( - labels.appName, - title, - ) - - keyguardLauncher.launch(intent) - } - - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { - keyguardPrompt() - return - } - - val biometricManager = BiometricManager.from(context) - val authenticators = - BiometricManager.Authenticators.BIOMETRIC_STRONG or - BiometricManager.Authenticators.DEVICE_CREDENTIAL - - val promptInfo = - BiometricPrompt.PromptInfo - .Builder() - .setTitle(labels.appName) - .setSubtitle(title) - .setAllowedAuthenticators(authenticators) - .build() - - val biometricPrompt = - BiometricPrompt( - fragmentContext, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError( - errorCode: Int, - errString: CharSequence, - ) { - super.onAuthenticationError(errorCode, errString) - - when (errorCode) { - BiometricPrompt.ERROR_NEGATIVE_BUTTON -> { - keyguardPrompt() - } - - BiometricPrompt.ERROR_LOCKOUT -> { - keyguardPrompt() - } - - else -> { - onError( - labels.failedTitle, - labels.failedExplainerWithError.format(errString.toString()), - ) - } - } - } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - onError(labels.failedTitle, labels.failedExplainer) - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - onApproved() - } - }, - ) - - when (biometricManager.canAuthenticate(authenticators)) { - BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo) - else -> keyguardPrompt() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UpdateZapAmountViewModel.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UpdateZapAmountViewModel.kt index d55e9e0ba5..1edf8e604d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UpdateZapAmountViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note +package com.vitorpamplona.amethyst.commons.ui.note import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ZapAmountText.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ZapAmountText.kt new file mode 100644 index 0000000000..4c5e02f31e --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ZapAmountText.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.note + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.ZapraiserStatus +import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteZaps +import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.sats_to_complete +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.Font14SP +import com.vitorpamplona.amethyst.commons.ui.theme.Height24dpFilledModifier +import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpFilledModifier +import com.vitorpamplona.amethyst.commons.ui.theme.NoSoTinyBorders +import com.vitorpamplona.amethyst.commons.ui.theme.TinyBorders +import com.vitorpamplona.amethyst.commons.ui.theme.fundraiserProgressColor +import com.vitorpamplona.amethyst.commons.util.showAmount +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.quartz.utils.BigDecimal +import com.vitorpamplona.quartz.utils.plus +import kotlin.math.roundToInt + +@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 ?: BigDecimal(0)) + + BigDecimal(note?.extraOwnPendingOnchainSats(ownPubKey) ?: 0L) + inner(showAmount(total)) + } +} + +@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, + ) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ZapRail.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ZapRail.kt new file mode 100644 index 0000000000..21398ce8c6 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ZapRail.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.note + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +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.resources.Res +import com.vitorpamplona.amethyst.commons.resources.nutzap +import com.vitorpamplona.amethyst.commons.resources.reload_mint_title +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange +import androidx.compose.material3.Icon as Material3Icon + +/** Payment rails a zap-amount chip can offer, in canonical display order. */ +enum class ZapRail { CASHU, RELOAD, LIGHTNING, ONCHAIN } + +/** Below this, the default rail is cashu (Lightning min/fees make tiny zaps awkward). */ +const val CASHU_PREFERRED_BELOW_SATS = 10L + +/** Above this, the default rail is an on-chain transaction. */ +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. + */ +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. */ +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 +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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt index faa85d2e68..9a916f936d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions +package com.vitorpamplona.amethyst.commons.ui.note.creators.emojiSuggestions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/expiration/ExpirationDatePicker.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/expiration/ExpirationDatePicker.kt index 2e6e2421d0..19784c96ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/expiration/ExpirationDatePicker.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.expiration +package com.vitorpamplona.amethyst.commons.ui.note.creators.expiration -import android.text.format.DateFormat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -49,7 +48,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -64,14 +62,15 @@ import com.vitorpamplona.amethyst.commons.resources.expiration_date_select import com.vitorpamplona.amethyst.commons.resources.expiration_expires_in import com.vitorpamplona.amethyst.commons.resources.expiration_time import com.vitorpamplona.amethyst.commons.resources.next +import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock +import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate +import com.vitorpamplona.amethyst.commons.ui.note.rememberIs24HourClock import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.quartz.utils.TimeUtils -import java.time.Instant -import java.time.ZoneId -import java.time.ZoneOffset +import com.vitorpamplona.quartz.utils.currentTimeMillis @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -79,24 +78,26 @@ fun ExpirationDatePicker(model: IExpiration) { var showDatePicker by remember { mutableStateOf(false) } var showTimePicker by remember { mutableStateOf(false) } - val currentTime = Instant.ofEpochMilli(model.expirationDate * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() + // The expiration as a local wall-clock time: seconds since the epoch shifted by this zone's offset. + val localSeconds = model.expirationDate + LocalClock.utcOffsetSeconds(model.expirationDate) + val localSecondOfDay = localSeconds.mod(TimeUtils.ONE_DAY) + val currentYear = SearchDate.civilFromDays(localSeconds.floorDiv(TimeUtils.ONE_DAY)).year val datePickerState = rememberDatePickerState( initialSelectedDateMillis = model.expirationDate * 1000, - yearRange = currentTime.year..2050, + yearRange = currentYear..2050, selectableDates = object : SelectableDates { - override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= System.currentTimeMillis() - 86400000 + override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= currentTimeMillis() - 86400000 }, ) - val context = LocalContext.current val timePickerState = rememberTimePickerState( - initialHour = currentTime.hour, - initialMinute = currentTime.minute, - is24Hour = DateFormat.is24HourFormat(context), + initialHour = localSecondOfDay / TimeUtils.ONE_HOUR, + initialMinute = (localSecondOfDay % TimeUtils.ONE_HOUR) / TimeUtils.ONE_MINUTE, + is24Hour = rememberIs24HourClock(), ) Column(Modifier.fillMaxWidth()) { @@ -183,9 +184,7 @@ fun ExpirationDatePicker(model: IExpiration) { (timePickerState.minute * TimeUtils.ONE_MINUTE) } ?: TimeUtils.oneDayAhead() - val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) - - model.expirationDate = datetimeLocalTimeZone - offset.totalSeconds + model.expirationDate = datetimeLocalTimeZone - LocalClock.utcOffsetSeconds(TimeUtils.now()) showTimePicker = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/invoice/InvoiceRequest.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/invoice/InvoiceRequest.kt index 7b8673f993..35418e08bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/invoice/InvoiceRequest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.invoice +package com.vitorpamplona.amethyst.commons.ui.note.creators.invoice import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/invoice/NewPostInvoiceRequest.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/invoice/NewPostInvoiceRequest.kt index fabde4f787..3b73a95426 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/invoice/NewPostInvoiceRequest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.invoice +package com.vitorpamplona.amethyst.commons.ui.note.creators.invoice import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/location/DisplayLocationObserver.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/location/DisplayLocationObserver.kt index 222befd9b0..fae9760e1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/location/DisplayLocationObserver.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.location +package com.vitorpamplona.amethyst.commons.ui.note.creators.location import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding @@ -35,6 +35,8 @@ 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.creators.location.ILocationGrabber +import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeoHashPostSection.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/location/GeoHashPostSection.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeoHashPostSection.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/location/GeoHashPostSection.kt index 583b757747..7e36b79445 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeoHashPostSection.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/location/GeoHashPostSection.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.location +package com.vitorpamplona.amethyst.commons.ui.note.creators.location import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.commons.resources.geohash_title import com.vitorpamplona.amethyst.commons.resources.location_change_place import com.vitorpamplona.amethyst.commons.resources.location_pick_on_map import com.vitorpamplona.amethyst.commons.resources.remove_location +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.commons.ui.platform.GeohashLocationPickerDialog import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/location/ILocationGrabber.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/location/ILocationGrabber.kt index 9f78db5852..5df7c2f8fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/location/ILocationGrabber.kt @@ -18,14 +18,14 @@ * 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.note.creators.location +package com.vitorpamplona.amethyst.commons.ui.note.creators.location +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult -import com.vitorpamplona.amethyst.service.location.LocationState import kotlinx.coroutines.flow.StateFlow interface ILocationGrabber { - fun locationManager(): LocationState + fun locationManager(): DeviceLocation fun locationFlow(): StateFlow<LocationResult> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationAsHash.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/location/LocationAsHash.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationAsHash.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/location/LocationAsHash.kt index 4a74cc7907..ed2077e964 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationAsHash.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/location/LocationAsHash.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.location +package com.vitorpamplona.amethyst.commons.ui.note.creators.location -import android.Manifest import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -39,37 +38,32 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.geohash_explainer import com.vitorpamplona.amethyst.commons.resources.geohash_title +import com.vitorpamplona.amethyst.commons.ui.components.rememberCoarseLocationPermission +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText -@OptIn(ExperimentalPermissionsApi::class) @Composable fun LocationAsHash( postViewModel: ILocationGrabber, innerContent: @Composable () -> Unit = {}, ) { - val locationPermissionState = - rememberPermissionState( - Manifest.permission.ACCESS_COARSE_LOCATION, - ) + val locationPermission = rememberCoarseLocationPermission() - LaunchedEffect(locationPermissionState.status.isGranted) { - postViewModel.locationManager().setLocationPermission(locationPermissionState.status.isGranted) + LaunchedEffect(locationPermission.isGranted) { + postViewModel.locationManager().setLocationPermission(locationPermission.isGranted) } - if (locationPermissionState.status.isGranted) { + if (locationPermission.isGranted) { DisplayLocationBox(postViewModel, innerContent) } else { - LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } + LaunchedEffect(locationPermission) { locationPermission.launchRequest() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/messagefield/MessageField.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/messagefield/MessageField.kt index bb58f52b24..486a56ca86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/messagefield/MessageField.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.messagefield +package com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield -import android.net.Uri import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.LocalTextStyle @@ -40,12 +39,13 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri +import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation +import com.vitorpamplona.amethyst.commons.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation -import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource @@ -55,7 +55,7 @@ fun MessageField( placeholder: StringResource, viewModel: IMessageField, requestFocus: Boolean = true, - onContentReceived: ((Uri, String?) -> Unit)? = null, + onContentReceived: ((MediaUri, String?) -> Unit)? = null, ) { val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/notify/AudienceFlap.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/notify/AudienceFlap.kt index 2e140db2f9..ba8be71638 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/notify/AudienceFlap.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.notify +package com.vitorpamplona.amethyst.commons.ui.note.creators.notify import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/notify/AudienceSheet.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/notify/AudienceSheet.kt index d0078663bc..33fb90a02c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/notify/AudienceSheet.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.notify +package com.vitorpamplona.amethyst.commons.ui.note.creators.notify import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -53,7 +53,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -184,7 +185,7 @@ private fun AudienceCatalog( // Fixed dp caps (220 + 400) overflow a short screen or a large font scale, // and a bottom sheet clips rather than scrolls — the confirm button would be // unreachable. Budgeting against the actual screen keeps it in view. - val screenHeight = LocalConfiguration.current.screenHeightDp + val screenHeight = windowHeightDp() val query = searchState.text.toString() val sets = @@ -336,7 +337,7 @@ private fun AudienceReview( // Leaves room for the header, the select-all row, the cap notes and the // confirm button, which all have to stay on screen for the sheet to work. - val listMaxHeight = (LocalConfiguration.current.screenHeightDp * 0.42f).dp + val listMaxHeight = (windowHeightDp() * 0.42f).dp val members = remember(list, alreadyInAudience, hidden) { @@ -567,3 +568,13 @@ private fun PeopleList.toAudienceList(kind: AudienceListKind) = publicMembers = publicMembersList, privateMembers = privateMembersList, ) + +/** The window's height in dp, which the sheet budgets its lists against. */ +@Composable +private fun windowHeightDp(): Int = + with(LocalDensity.current) { + LocalWindowInfo.current.containerSize.height + .toDp() + .value + .toInt() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/polls/PollDeadlinePicker.kt similarity index 83% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/polls/PollDeadlinePicker.kt index 7ec5a1187d..9c08df9531 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/polls/PollDeadlinePicker.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.polls +package com.vitorpamplona.amethyst.commons.ui.note.creators.polls -import android.text.format.DateFormat import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -45,7 +44,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.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -57,13 +55,14 @@ import com.vitorpamplona.amethyst.commons.resources.confirm import com.vitorpamplona.amethyst.commons.resources.next import com.vitorpamplona.amethyst.commons.resources.poll_closing_date_time import com.vitorpamplona.amethyst.commons.resources.poll_closing_in +import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock +import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate +import com.vitorpamplona.amethyst.commons.ui.note.rememberIs24HourClock import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.quartz.utils.TimeUtils -import java.time.Instant -import java.time.ZoneId -import java.time.ZoneOffset +import com.vitorpamplona.quartz.utils.currentTimeMillis @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -72,26 +71,28 @@ fun PollDeadlinePicker(model: ShortNotePostViewModel) { var showTimePicker by remember { mutableStateOf(false) } // Get current time details - val currentTime = Instant.ofEpochMilli(model.closedAt * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() + // The deadline as a local wall-clock time: seconds since the epoch shifted by this zone's offset. + val localSeconds = model.closedAt + LocalClock.utcOffsetSeconds(model.closedAt) + val localSecondOfDay = localSeconds.mod(TimeUtils.ONE_DAY) + val currentYear = SearchDate.civilFromDays(localSeconds.floorDiv(TimeUtils.ONE_DAY)).year val datePickerState = rememberDatePickerState( initialSelectedDateMillis = model.closedAt * 1000, - yearRange = currentTime.year..2050, + yearRange = currentYear..2050, selectableDates = object : SelectableDates { override fun isSelectableDate(utcTimeMillis: Long): Boolean { // Only allow today and future dates - return utcTimeMillis >= System.currentTimeMillis() - 86400000 // minus 24h buffer + return utcTimeMillis >= currentTimeMillis() - 86400000 // minus 24h buffer } }, ) - val context = LocalContext.current val timePickerState = rememberTimePickerState( - initialHour = currentTime.hour, - initialMinute = currentTime.minute, - is24Hour = DateFormat.is24HourFormat(context), + initialHour = localSecondOfDay / TimeUtils.ONE_HOUR, + initialMinute = (localSecondOfDay % TimeUtils.ONE_HOUR) / TimeUtils.ONE_MINUTE, + is24Hour = rememberIs24HourClock(), ) OutlinedCard( @@ -148,10 +149,8 @@ fun PollDeadlinePicker(model: ShortNotePostViewModel) { (timePickerState.minute * TimeUtils.ONE_MINUTE) } ?: TimeUtils.oneDayAhead() - // Get the offset from UTC for the current instant in the local time zone - val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) - - model.closedAt = datetimeLocalTimeZone - offset.totalSeconds + // Subtract this zone's current offset from UTC + model.closedAt = datetimeLocalTimeZone - LocalClock.utcOffsetSeconds(TimeUtils.now()) showTimePicker = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/polls/PollOptionsField.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/polls/PollOptionsField.kt index abb263f2dc..6346c97147 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/polls/PollOptionsField.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.polls +package com.vitorpamplona.amethyst.commons.ui.note.creators.polls import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement @@ -49,9 +49,9 @@ import com.vitorpamplona.amethyst.commons.resources.poll_multiple_choice import com.vitorpamplona.amethyst.commons.resources.poll_option_description import com.vitorpamplona.amethyst.commons.resources.poll_option_index import com.vitorpamplona.amethyst.commons.resources.poll_single_choice +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 com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/pow/PowOverrideButton.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/pow/PowOverrideButton.kt index 984fb6242e..cc48bfe61b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/pow/PowOverrideButton.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.pow +package com.vitorpamplona.amethyst.commons.ui.note.creators.pow import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -38,7 +38,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.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -53,10 +52,10 @@ import com.vitorpamplona.amethyst.commons.resources.pow_option_off import com.vitorpamplona.amethyst.commons.resources.pow_settings_title import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.pow.deviceHashesPerSecond +import com.vitorpamplona.amethyst.commons.ui.pow.formatApproxDuration import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow -import com.vitorpamplona.amethyst.service.pow.deviceHashesPerSecond -import com.vitorpamplona.amethyst.service.pow.formatApproxDuration val POW_PRESETS = listOf(16, 20, 24, 28) @@ -120,7 +119,6 @@ fun PowOverrideButton( ) { // benchmarked on first open (~250 ms, cached after): each option // shows what it would cost on THIS device, e.g. "24 bits · ≈ 45 seconds". - val context = LocalContext.current val hashRate by produceState<Double?>(initialValue = null) { value = deviceHashesPerSecond() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/previews/DisplayPreviews.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/previews/DisplayPreviews.kt index e0bbc50d32..bc0f04b909 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/previews/DisplayPreviews.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.previews +package com.vitorpamplona.amethyst.commons.ui.note.creators.previews import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Box diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/previews/PreviewUrl.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/previews/PreviewUrl.kt index 72aa4b9509..5cf4b69cab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/previews/PreviewUrl.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.previews +package com.vitorpamplona.amethyst.commons.ui.note.creators.previews import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -26,19 +26,20 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -50,11 +51,10 @@ import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.commons.ui.components.WaitAndDisplay import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose +import com.vitorpamplona.amethyst.commons.ui.note.platform.VideoView +import com.vitorpamplona.amethyst.commons.ui.note.platform.rememberUrlPreviewState import com.vitorpamplona.amethyst.commons.ui.state.produceCachedState import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.model.UrlCachedPreviewer -import com.vitorpamplona.amethyst.service.playback.composable.VideoView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.urlPreview @Composable fun PreviewUrl( @@ -79,7 +79,6 @@ fun PreviewUrl( myUrlPreview, mimeType = null, roundedCorner = false, - gallery = false, contentScale = ContentScale.FillHeight, accountViewModel = accountViewModel, ) @@ -133,7 +132,6 @@ fun PreviewUrlFillWidth( myUrlPreview, mimeType = null, roundedCorner = false, - gallery = false, contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, ) @@ -204,16 +202,7 @@ private fun MyLoadUrlPreviewDirect( accountViewModel: AccountViewModel, nav: INav, ) { - @Suppress("ProduceStateDoesNotAssignValue") - val urlPreviewState by - produceState( - initialValue = UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading, - key1 = url, - ) { - if (value == UrlPreviewState.Loading) { - accountViewModel.urlPreview(url) { value = it } - } - } + val urlPreviewState = rememberUrlPreviewState(url, accountViewModel) CrossfadeIfEnabled( targetState = urlPreviewState, @@ -233,17 +222,18 @@ private fun MyLoadUrlPreviewDirect( state.previewInfo.url, mimeType = state.previewInfo.mimeType, roundedCorner = false, - gallery = false, contentScale = ContentScale.Crop, accountViewModel = accountViewModel, ) } else { Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.aspectRatio(1f)) { + // A page with no OpenGraph image still has its icon: shown whole, not cropped. + val hasImage = state.previewInfo.imageUrlFullPath.isNotBlank() AsyncImage( - model = state.previewInfo.imageUrlFullPath, + model = if (hasImage) state.previewInfo.imageUrlFullPath else state.previewInfo.iconUrlFullPath, contentDescription = state.previewInfo.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), + contentScale = if (hasImage) ContentScale.Crop else ContentScale.Fit, + modifier = if (hasImage) Modifier.fillMaxSize() else Modifier.fillMaxSize().padding(20.dp), ) Text( @@ -272,16 +262,7 @@ private fun MyLoadUrlPreviewDirectFillWidth( accountViewModel: AccountViewModel, nav: INav, ) { - @Suppress("ProduceStateDoesNotAssignValue") - val urlPreviewState by - produceState( - initialValue = UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading, - key1 = url, - ) { - if (value == UrlPreviewState.Loading) { - accountViewModel.urlPreview(url) { value = it } - } - } + val urlPreviewState = rememberUrlPreviewState(url, accountViewModel) CrossfadeIfEnabled( targetState = urlPreviewState, @@ -301,7 +282,6 @@ private fun MyLoadUrlPreviewDirectFillWidth( state.previewInfo.url, mimeType = state.previewInfo.mimeType, roundedCorner = false, - gallery = false, contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/scheduling/ScheduleAtPicker.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/scheduling/ScheduleAtPicker.kt index b7b183eedd..ee9973ce6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/scheduling/ScheduleAtPicker.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.scheduling +package com.vitorpamplona.amethyst.commons.ui.note.creators.scheduling -import android.text.format.DateFormat import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -54,7 +53,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.unit.dp import androidx.compose.ui.unit.sp @@ -76,18 +74,15 @@ import com.vitorpamplona.amethyst.commons.resources.schedule_post_time_label import com.vitorpamplona.amethyst.commons.resources.schedule_post_warning_multi import com.vitorpamplona.amethyst.commons.resources.schedule_post_warning_single import com.vitorpamplona.amethyst.commons.resources.schedule_post_warning_title +import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock +import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate +import com.vitorpamplona.amethyst.commons.ui.note.rememberIs24HourClock import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.quartz.utils.TimeUtils -import java.time.DayOfWeek -import java.time.Instant -import java.time.LocalDate -import java.time.LocalTime -import java.time.ZoneId -import java.time.ZoneOffset -import java.time.temporal.TemporalAdjusters +import com.vitorpamplona.quartz.utils.currentTimeMillis /** * Two-stage date + time picker for scheduling a post for future publication. @@ -107,29 +102,26 @@ fun ScheduleAtPicker( var showDatePicker by remember { mutableStateOf(false) } var showTimePicker by remember { mutableStateOf(false) } - val currentTime = - Instant - .ofEpochMilli(scheduledForSec * 1000) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime() + // The scheduled time as a local wall-clock time: seconds since the epoch shifted by this zone's offset. + val localSeconds = scheduledForSec + LocalClock.utcOffsetSeconds(scheduledForSec) + val localSecondOfDay = localSeconds.mod(TimeUtils.ONE_DAY) + val currentYear = SearchDate.civilFromDays(localSeconds.floorDiv(TimeUtils.ONE_DAY)).year val datePickerState = rememberDatePickerState( initialSelectedDateMillis = scheduledForSec * 1000, - yearRange = currentTime.year..2050, + yearRange = currentYear..2050, selectableDates = object : SelectableDates { - override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= System.currentTimeMillis() - 86_400_000 + override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= currentTimeMillis() - 86_400_000 }, ) - val context = LocalContext.current - val timePickerState = rememberTimePickerState( - initialHour = currentTime.hour, - initialMinute = currentTime.minute, - is24Hour = DateFormat.is24HourFormat(context), + initialHour = localSecondOfDay / TimeUtils.ONE_HOUR, + initialMinute = (localSecondOfDay % TimeUtils.ONE_HOUR) / TimeUtils.ONE_MINUTE, + is24Hour = rememberIs24HourClock(), ) Column(Modifier.fillMaxWidth()) { @@ -220,8 +212,7 @@ fun ScheduleAtPicker( (timePickerState.minute * TimeUtils.ONE_MINUTE) } ?: TimeUtils.oneDayAhead() - val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) - val rawSec = datetimeLocalTimeZone - offset.totalSeconds + val rawSec = datetimeLocalTimeZone - LocalClock.utcOffsetSeconds(TimeUtils.now()) onChanged(roundUpToNextQuarterHour(rawSec)) showTimePicker = false @@ -300,24 +291,22 @@ private fun PresetChips(onPick: (Long) -> Unit) { } } -private fun presetInOneHour(): Long = (System.currentTimeMillis() / 1000) + 3600 +private fun presetInOneHour(): Long = (currentTimeMillis() / 1000) + 3600 -private fun presetTomorrowMorning(): Long { - val zone = ZoneId.systemDefault() - val tomorrow9am = LocalDate.now(zone).plusDays(1).atTime(LocalTime.of(9, 0)) - return tomorrow9am.atZone(zone).toEpochSecond() -} +private fun presetTomorrowMorning(): Long = nineAm(LocalClock.today().plusDays(1)) private fun presetNextMondayMorning(): Long { - val zone = ZoneId.systemDefault() // Always step at least one day forward — if today is Monday, return next Monday. - val target = - LocalDate - .now(zone) - .plusDays(1) - .with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY)) - .atTime(LocalTime.of(9, 0)) - return target.atZone(zone).toEpochSecond() + val tomorrow = LocalClock.today().plusDays(1) + val monday = 1 + return nineAm(tomorrow.plusDays((monday - tomorrow.dayOfWeek()).mod(7))) +} + +/** 09:00 local time on [date], read with the offset in force at 09:00, not at midnight. */ +private fun nineAm(date: SearchDate): Long { + val midnight = LocalClock.startOfDay(date) + val guess = midnight + 9 * TimeUtils.ONE_HOUR + return guess + LocalClock.utcOffsetSeconds(midnight) - LocalClock.utcOffsetSeconds(guess) } /** @@ -326,9 +315,9 @@ private fun presetNextMondayMorning(): Long { * (rare — only if the user picks the exact current quarter-hour), bump forward * one slot. */ -internal fun roundUpToNextQuarterHour(epochSec: Long): Long { +fun roundUpToNextQuarterHour(epochSec: Long): Long { val quarter = 15 * 60L val rounded = ((epochSec + quarter - 1) / quarter) * quarter - val nowSec = System.currentTimeMillis() / 1000 + val nowSec = currentTimeMillis() / 1000 return if (rounded <= nowSec) rounded + quarter else rounded } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/uploads/ImageVideoDescription.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/uploads/ImageVideoDescription.kt index 10d312c1a5..c33833bb2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/uploads/ImageVideoDescription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.uploads +package com.vitorpamplona.amethyst.commons.ui.note.creators.uploads import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -55,7 +55,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextOverflow @@ -97,6 +96,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer import com.vitorpamplona.amethyst.commons.ui.note.CancelIcon import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.SettingSwitchItem +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder @@ -104,7 +104,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.ai.MLKitImageLabelService import kotlinx.collections.immutable.toImmutableList @Composable @@ -133,7 +132,6 @@ fun ImageVideoDescription( var message by remember { mutableStateOf("") } var sensitiveContent by remember { mutableStateOf(false) } - val context = LocalContext.current val firstImageUri = remember(uris) { uris @@ -142,12 +140,13 @@ fun ImageVideoDescription( ?.media ?.uri } - val labelService = remember { MLKitImageLabelService(context.applicationContext) } + val appServices = LocalAppServices.current + val labelService = remember { appServices.createAltTextSuggester() } var isLabeling by remember { mutableStateOf(false) } var aiSuggested by remember { mutableStateOf(false) } DisposableEffect(labelService) { - onDispose { labelService.close() } + onDispose { labelService?.close() } } LaunchedEffect(firstImageUri) { @@ -155,7 +154,7 @@ fun ImageVideoDescription( isLabeling = true val suggestion = try { - labelService.suggestAltText(firstImageUri) + labelService?.suggestAltText(firstImageUri) } finally { isLabeling = false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt index a961fd4d82..c12c3eadea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.zappolls +package com.vitorpamplona.amethyst.commons.ui.note.creators.zappolls -import android.text.format.DateFormat import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -45,7 +44,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.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -57,13 +55,14 @@ import com.vitorpamplona.amethyst.commons.resources.confirm import com.vitorpamplona.amethyst.commons.resources.next import com.vitorpamplona.amethyst.commons.resources.poll_closing_date_time import com.vitorpamplona.amethyst.commons.resources.poll_closing_in +import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock +import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate +import com.vitorpamplona.amethyst.commons.ui.note.rememberIs24HourClock import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.quartz.utils.TimeUtils -import java.time.Instant -import java.time.ZoneId -import java.time.ZoneOffset +import com.vitorpamplona.quartz.utils.currentTimeMillis @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -72,26 +71,28 @@ fun ZapPollDeadlinePicker(model: ShortNotePostViewModel) { var showTimePicker by remember { mutableStateOf(false) } // Get current time details - val currentTime = Instant.ofEpochMilli(model.zapPollClosedAt * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() + // The deadline as a local wall-clock time: seconds since the epoch shifted by this zone's offset. + val localSeconds = model.zapPollClosedAt + LocalClock.utcOffsetSeconds(model.zapPollClosedAt) + val localSecondOfDay = localSeconds.mod(TimeUtils.ONE_DAY) + val currentYear = SearchDate.civilFromDays(localSeconds.floorDiv(TimeUtils.ONE_DAY)).year val datePickerState = rememberDatePickerState( initialSelectedDateMillis = model.zapPollClosedAt * 1000, - yearRange = currentTime.year..2050, + yearRange = currentYear..2050, selectableDates = object : SelectableDates { override fun isSelectableDate(utcTimeMillis: Long): Boolean { // Only allow today and future dates - return utcTimeMillis >= System.currentTimeMillis() - 86400000 // minus 24h buffer + return utcTimeMillis >= currentTimeMillis() - 86400000 // minus 24h buffer } }, ) - val context = LocalContext.current val timePickerState = rememberTimePickerState( - initialHour = currentTime.hour, - initialMinute = currentTime.minute, - is24Hour = DateFormat.is24HourFormat(context), + initialHour = localSecondOfDay / TimeUtils.ONE_HOUR, + initialMinute = (localSecondOfDay % TimeUtils.ONE_HOUR) / TimeUtils.ONE_MINUTE, + is24Hour = rememberIs24HourClock(), ) OutlinedCard( @@ -148,10 +149,7 @@ fun ZapPollDeadlinePicker(model: ShortNotePostViewModel) { (timePickerState.minute * TimeUtils.ONE_MINUTE) } ?: TimeUtils.oneDayAhead() - // Get the offset from UTC for the current instant in the local time zone - val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) - - model.zapPollClosedAt = datetimeLocalTimeZone - offset.totalSeconds + model.zapPollClosedAt = datetimeLocalTimeZone - LocalClock.utcOffsetSeconds(TimeUtils.now()) showTimePicker = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zappolls/ZapPollField.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zappolls/ZapPollField.kt index 429ff6d7ff..67c32e7e99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zappolls/ZapPollField.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.zappolls +package com.vitorpamplona.amethyst.commons.ui.note.creators.zappolls import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column @@ -35,8 +35,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.add_poll_option_button +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zappolls/ZapPollOption.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zappolls/ZapPollOption.kt index aa66a0e2a4..a6fad228b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zappolls/ZapPollOption.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.zappolls +package com.vitorpamplona.amethyst.commons.ui.note.creators.zappolls import androidx.compose.foundation.layout.Row import androidx.compose.foundation.text.KeyboardOptions @@ -36,9 +36,9 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.clear import com.vitorpamplona.amethyst.commons.resources.poll_option_description import com.vitorpamplona.amethyst.commons.resources.poll_option_index +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 @Composable fun ZapPollOption( @@ -67,7 +67,7 @@ fun ZapPollOption( }, label = { Text( - text = stringRes(Res.string.poll_option_index).format(optionIndex + 1), + text = stringRes(Res.string.poll_option_index, (optionIndex + 1).toString()), color = MaterialTheme.colorScheme.placeholderText, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollVoteValueRange.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zappolls/ZapPollVoteValueRange.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollVoteValueRange.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zappolls/ZapPollVoteValueRange.kt index 14787eedab..9166ab3d9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollVoteValueRange.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zappolls/ZapPollVoteValueRange.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.zappolls +package com.vitorpamplona.amethyst.commons.ui.note.creators.zappolls import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -42,10 +42,10 @@ import com.vitorpamplona.amethyst.commons.resources.poll_zap_value_max import com.vitorpamplona.amethyst.commons.resources.poll_zap_value_min import com.vitorpamplona.amethyst.commons.resources.poll_zap_value_min_max_explainer import com.vitorpamplona.amethyst.commons.resources.sats +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel @Composable fun ZapPollVoteValueRange(pollViewModel: ShortNotePostViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zapsplits/ForwardZapTo.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zapsplits/ForwardZapTo.kt index de91bf32bc..04e6ef42c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/zapsplits/ForwardZapTo.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.zapsplits +package com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.resources.zap_split_explainer import com.vitorpamplona.amethyst.commons.resources.zap_split_search_and_add_user import com.vitorpamplona.amethyst.commons.resources.zap_split_search_and_add_user_placeholder import com.vitorpamplona.amethyst.commons.resources.zap_split_title +import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.commons.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.commons.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay @@ -58,7 +59,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import kotlin.math.round @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/elements/AddInboxRelayForDMCard.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/elements/AddInboxRelayForDMCard.kt index 6113e1c9a1..5cf4605403 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/elements/AddInboxRelayForDMCard.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.elements +package com.vitorpamplona.amethyst.commons.ui.note.elements import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.resources.dm_relays_not_found_descript import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.dm.AddDMRelayListDialog import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.BigPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding @@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.AddDMRelayListDialog import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/nip22Comments/CommentPostViewModel.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/nip22Comments/CommentPostViewModel.kt index bb649a217a..c5786ce0a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/nip22Comments/CommentPostViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.nip22Comments +package com.vitorpamplona.amethyst.commons.ui.note.nip22Comments import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText @@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.PreviewState import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState @@ -61,10 +61,13 @@ import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.IAudience import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.IZapField +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor @@ -73,10 +76,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.text.setTextAndPlaceCursorAtBeginning import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -136,6 +135,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest @@ -452,7 +452,7 @@ open class CommentPostViewModel : CommunityRulesValidator(rules).validate( author = author, kind = CommentEvent.KIND, - sizeBytes = draftContent.toByteArray(Charsets.UTF_8).size, + sizeBytes = draftContent.encodeToByteArray().size, postsTodayByKind = null, wot = null, ) @@ -1006,9 +1006,7 @@ open class CommentPostViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -1081,5 +1079,5 @@ open class CommentPostViewModel : return location!! } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/nip22Comments/GenericCommentPostScreen.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/nip22Comments/GenericCommentPostScreen.kt index 484b7aa166..57eb0904c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -18,10 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.nip22Comments +package com.vitorpamplona.amethyst.commons.ui.note.nip22Comments -import android.content.Intent -import android.net.Uri import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box @@ -46,21 +44,14 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.core.content.IntentCompat -import androidx.core.net.toUri -import androidx.core.util.Consumer -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.AddressableNote @@ -79,23 +70,41 @@ import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind import com.vitorpamplona.amethyst.commons.resources.zapraiser import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.OnIncomingShare import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.commons.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.commons.ui.note.creators.expiration.ExpirationDateButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.expiration.ExpirationDatePicker 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.location.AddGeoHashButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.GeoHashPostSection +import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.MessageField +import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.AudienceFlap +import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.AudienceSheet +import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.rememberAudienceLists +import com.vitorpamplona.amethyst.commons.ui.note.creators.pow.PowOverrideButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.SecretEmojiRequest +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.creators.zapraiser.AddZapraiserButton import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.ZapRaiserRequest +import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapToButton import com.vitorpamplona.amethyst.commons.ui.note.nip22Comments.DisplayExternalId import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName @@ -111,30 +120,11 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton -import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton -import com.vitorpamplona.amethyst.ui.components.getActivity -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList -import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker -import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest -import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection -import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField -import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceFlap -import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceSheet -import com.vitorpamplona.amethyst.ui.note.creators.notify.rememberAudienceLists -import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton -import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews -import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext @Composable fun ReplyCommentPostScreen( @@ -144,12 +134,12 @@ fun ReplyCommentPostScreen( quoteId: HexKey? = null, draftId: HexKey? = null, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { - val postViewModel: CommentPostViewModel = viewModel() + val postViewModel: CommentPostViewModel = rememberViewModel { CommentPostViewModel() } postViewModel.init(accountViewModel) - val context = LocalContext.current + val mediaResolver = rememberSharedMediaResolver() LaunchedEffect(postViewModel, accountViewModel) { replyId?.let { LocalCache.getNoteIfExists(it) }?.let { @@ -165,11 +155,8 @@ fun ReplyCommentPostScreen( postViewModel.message.setTextAndPlaceCursorAtEnd(it) postViewModel.onMessageChanged() } - attachment?.ifBlank { null }?.toUri()?.let { - withContext(Dispatchers.IO) { - val mediaType = context.contentResolver.getType(it) - postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) - } + mediaResolver.resolve(attachment)?.let { + postViewModel.selectImage(persistentListOf(it)) } if (draftId == null) { postViewModel.applySignature() @@ -184,36 +171,15 @@ fun ReplyCommentPostScreen( fun GenericCommentPostScreen( postViewModel: CommentPostViewModel, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { WatchAndLoadMyEmojiList(accountViewModel) - val context = LocalContext.current - val activity = context.getActivity() - val scope = rememberCoroutineScope() - - DisposableEffect(nav, activity) { - // Microsoft's swift key sends Gifs as new actions - val consumer = - Consumer<Intent> { intent -> - if (intent.action == Intent.ACTION_SEND) { - intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }?.let { - postViewModel.addToMessage(it) - } - - // Use the `intent` parameter (the new intent), not activity.intent (the launch intent). - IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri -> - scope.launch(Dispatchers.IO) { - val mediaType = context.contentResolver.getType(uri) - postViewModel.selectImage(persistentListOf(SelectedMedia(uri, mediaType))) - } - } - } - } - - activity.addOnNewIntentListener(consumer) - onDispose { activity.removeOnNewIntentListener(consumer) } - } + // Microsoft's swift key sends Gifs as new actions + OnIncomingShare( + onText = postViewModel::addToMessage, + onMedia = { postViewModel.selectImage(persistentListOf(it)) }, + ) // NIP-9B: when replying into a NIP-72 community, mount the community feed // subscription so the latest kind:34551 rules document is fetched and @@ -279,7 +245,7 @@ fun GenericCommentPostScreen( private fun GenericCommentPostBody( postViewModel: CommentPostViewModel, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { val scrollState = rememberScrollState() val audienceLists = rememberAudienceLists(accountViewModel) @@ -466,7 +432,6 @@ private fun GenericCommentPostBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { - val context = LocalContext.current ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatform.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatform.kt index 367dd82ee0..fc66fd59f1 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatform.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatform.kt @@ -31,6 +31,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 @@ -44,8 +45,10 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.EditState import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent 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 /** * The platform halves of the shared note renderer: what a note card needs that only the front end @@ -106,6 +109,20 @@ interface NotePlatform { accountViewModel: AccountViewModel, ) {} + /** + * A video filling a full-screen viewer, playing at once. [controllerVisible] shows and hides + * its playback controls, so the viewer can toggle them with its own chrome. + */ + @Composable + fun FullscreenVideoView( + videoUri: String, + mimeType: String?, + contentScale: ContentScale, + modifier: Modifier, + controllerVisible: MutableState<Boolean>, + accountViewModel: AccountViewModel, + ) {} + /** The link card for [url]: OpenGraph preview, inline media, or a plain link. */ @Composable fun UrlPreview( @@ -142,6 +159,8 @@ interface NotePlatform { pinColor: Color?, pinEmoji: String?, pinAlpha: Float, + modifier: Modifier, + zoom: Double?, ) {} /** Resolves [geohashStr] to a place name for [content], showing [onLoading] meanwhile. */ @@ -410,6 +429,43 @@ interface NotePlatform { callbacks: ZapButtonCallbacks, ) {} + /** + * The user's zap presets as rail-aware amount chips that zap [note] straight away (the chat + * long-press sheet). [onOnchainRequest] hands an on-chain amount to the caller's dialog. + */ + @Composable + fun QuickZapAmountRow( + note: Note, + onDismiss: () -> Unit, + onOnchainRequest: (Long?) -> Unit, + accountViewModel: AccountViewModel, + nav: INav, + ) {} + + /** + * The microphone button: records a voice message (up to [maxDurationSeconds]) and hands the + * finished recording to [onVoiceTaken]. + */ + @Composable + fun RecordVoiceButton( + onVoiceTaken: (RecordingResult) -> Unit, + maxDurationSeconds: Int?, + ) {} + + /** + * Plays back a voice message before it is sent: [localFile] while it is only on the device, + * else [voiceMetadata]'s URL. [onReRecord] replaces it with a new recording. + */ + @Composable + fun VoiceMessagePreview( + voiceMetadata: AudioMeta, + localFile: Path?, + onRemove: () -> Unit, + onReRecord: ((RecordingResult) -> Unit)?, + isUploading: Boolean, + modifier: Modifier, + ) {} + /** The editor for a new version of [edit], shown while [versionLookingAt] is on screen. */ @Composable fun EditPostView( diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatformCalls.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatformCalls.kt index 30f946780d..e3f439102c 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatformCalls.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatformCalls.kt @@ -30,6 +30,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 @@ -47,9 +48,11 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel 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 kotlinx.collections.immutable.persistentListOf +import okio.Path // Call-site shims for [NotePlatform]: the same names and defaults the app's own composables have, // so shared note code reads as it did and only its imports point here. @@ -107,6 +110,16 @@ fun GifVideoView( thumbhash = thumbhash, ) +@Composable +fun FullscreenVideoView( + videoUri: String, + mimeType: String?, + contentScale: ContentScale, + controllerVisible: MutableState<Boolean>, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) = LocalNotePlatform.current.FullscreenVideoView(videoUri, mimeType, contentScale, modifier, controllerVisible, accountViewModel) + @Composable fun VideoView( videoUri: String, @@ -151,6 +164,8 @@ fun LoadUrlPreview( fun LocationPreviewMap( latitude: Double, longitude: Double, + modifier: Modifier = Modifier, + zoom: Double? = null, aspectRatio: Float = 1f, pinColor: Color? = null, pinEmoji: String? = null, @@ -162,6 +177,8 @@ fun LocationPreviewMap( pinColor = pinColor, pinEmoji = pinEmoji, pinAlpha = pinAlpha, + modifier = modifier, + zoom = zoom, ) @Composable @@ -545,6 +562,34 @@ fun ReusableZapButton( callbacks: ZapButtonCallbacks = ZapButtonCallbacks(), ) = LocalNotePlatform.current.ReusableZapButton(baseNote, accountViewModel, nav, config, callbacks) +@Composable +fun QuickZapAmountRow( + note: Note, + onDismiss: () -> Unit, + onOnchainRequest: (Long?) -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) = LocalNotePlatform.current.QuickZapAmountRow(note, onDismiss, onOnchainRequest, accountViewModel, nav) + +/** The longest voice message the recorder allows, in seconds. */ +const val MAX_VOICE_RECORD_SECONDS = 600 + +@Composable +fun RecordVoiceButton( + onVoiceTaken: (RecordingResult) -> Unit, + maxDurationSeconds: Int? = null, +) = LocalNotePlatform.current.RecordVoiceButton(onVoiceTaken, maxDurationSeconds) + +@Composable +fun VoiceMessagePreview( + voiceMetadata: AudioMeta, + localFile: Path? = null, + onRemove: () -> Unit, + onReRecord: ((RecordingResult) -> Unit)? = null, + isUploading: Boolean = false, + modifier: Modifier = Modifier, +) = LocalNotePlatform.current.VoiceMessagePreview(voiceMetadata, localFile, onRemove, onReRecord, isUploading, modifier) + @Composable fun EditPostView( onClose: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/QrPayload.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/share/QrPayload.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/QrPayload.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/share/QrPayload.kt index 63ae327439..20a93d384d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/QrPayload.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/share/QrPayload.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.share +package com.vitorpamplona.amethyst.commons.ui.note.share import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.ui.note.externalLinkForNote diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/share/ShareNoteAsQrScreen.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/share/ShareNoteAsQrScreen.kt index 0932556399..b8a5a2216e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/share/ShareNoteAsQrScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.share +package com.vitorpamplona.amethyst.commons.ui.note.share import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/share/SharedNoteCard.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/share/SharedNoteCard.kt index bf1e6a0050..aa7ac4bf7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/share/SharedNoteCard.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.share +package com.vitorpamplona.amethyst.commons.ui.note.share import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -57,10 +57,10 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.ui.components.collectContentWarningReasons import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.profile.gallery.GalleryThumbnail +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.profile.gallery.UrlImageView import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.GalleryThumbnail -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt new file mode 100644 index 0000000000..8bff6dff38 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.note.types + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteEvent +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.ballot +import com.vitorpamplona.amethyst.commons.resources.ballot_election +import com.vitorpamplona.amethyst.commons.resources.ballot_more_answers +import com.vitorpamplona.amethyst.commons.resources.ballot_proof_hash +import com.vitorpamplona.amethyst.commons.resources.ballot_vote +import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.commons.ui.theme.grayText +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.quartz.experimental.ballots.BallotAnswer +import com.vitorpamplona.quartz.experimental.ballots.BallotEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** How many answers a card lists before collapsing the rest into "+N more answers". */ +private const val MAX_ANSWERS = 4 + +/** + * Renders a kind-38000 ballot from an auditable-voting app: the election it was cast in, its + * answers as question → answer rows, and the proof hash that lets the vote be audited. + * + * Observed rather than read once: a ballot is addressable, so a re-cast vote replaces it in place. + */ +@Composable +fun RenderBallot( + note: Note, + accountViewModel: AccountViewModel, +) { + val observedEvent by observeNoteEvent<BallotEvent>(note, accountViewModel) + val noteEvent = observedEvent ?: return + + BallotCard(noteEvent) +} + +@Immutable +private class BallotCardState( + val election: String?, + val answers: ImmutableList<BallotAnswer>, + val hiddenAnswers: Int, + val proofHash: String?, +) { + companion object { + fun from(event: BallotEvent): BallotCardState { + val answers = event.answers() + return BallotCardState( + election = event.election(), + answers = answers.take(MAX_ANSWERS).toImmutableList(), + hiddenAnswers = (answers.size - MAX_ANSWERS).coerceAtLeast(0), + proofHash = event.proofHash(), + ) + } + } +} + +@Composable +fun BallotCard(event: BallotEvent) { + val state = remember(event) { BallotCardState.from(event) } + + Column(MaterialTheme.colorScheme.replyModifier) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.Checklist, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text( + text = stringRes(Res.string.ballot), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + modifier = Modifier.padding(start = 6.dp), + ) + } + + state.election?.let { + Text( + text = stringRes(Res.string.ballot_election, it), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + if (state.answers.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + // The answers as a small table: one bordered block, a hairline between rows. + Column( + Modifier + .clip(AnswersShape) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, AnswersShape), + ) { + state.answers.forEachIndexed { i, answer -> + if (i > 0) HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.subtleBorder) + AnswerRow(answer) + } + } + + if (state.hiddenAnswers > 0) { + Text( + text = pluralStringRes(Res.plurals.ballot_more_answers, state.hiddenAnswers, state.hiddenAnswers), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } + + state.proofHash?.let { ProofHash(it) } + } + } +} + +@Composable +private fun AnswerRow(answer: BallotAnswer) { + // A lone `vote_choice` has no question of its own; everything else is shown as published. + val question = + if (answer.question == BallotEvent.VOTE_QUESTION) { + stringRes(Res.string.ballot_vote) + } else { + answer.question + } + + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = question, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(0.4f), + ) + Text( + text = answer.answer, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.End, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(0.6f), + ) + } +} + +private val AnswersShape = RoundedCornerShape(10.dp) + +@Composable +private fun ProofHash(hash: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.Shield, + contentDescription = null, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.size(12.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = stringRes(Res.string.ballot_proof_hash, hash), + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } +} + +@Preview +@Composable +fun BallotCardPreview() { + val ballot = + BallotEvent( + id = "78f5f012a9518a0b39134e659ef180eae6df9f6b38ce3b4b86963409837e2776", + pubKey = "caf0b6ef4eb461677ed7bf7b569a0212443d6429f6cc555ca8f00a599e453bb2", + createdAt = 1773998101, + tags = + arrayOf( + arrayOf("election", "spring-2026-council"), + arrayOf("proof_hash", "bb64227a0b57fa6af765881b0952fe9d01a3a18faf52a3e34eeb7a896ee36b95"), + ), + content = + "{\"ballot\":{\"funding_priority\":\"community-grants\",\"audit_policy\":\"every-election\"," + + "\"treasurer\":\"alice\",\"quorum\":\"60%\",\"term_length\":\"1 year\"}}", + sig = "", + ) + + ThemeComparisonColumn { + Column(Modifier.padding(10.dp)) { + BallotCard(ballot) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Chat.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Chat.kt index 5127725c1e..d53d5f2161 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Chat.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Chat.kt @@ -58,14 +58,17 @@ fun RenderChat( isBoostedNote: Boolean = false, ) { val noteEvent = note.event ?: return - val eventContent = remember(noteEvent) { noteEvent.content } + // The newest in-place edit (Buzz, Concord, Marmot) wins — the chat room shows it, so a + // notification card or quote of the same message must too. + val content = observeChatMessageEdit(note)?.event?.content ?: noteEvent.content + val eventContent = content // Content actually handed to the media renderer. The shared renderer only shows media whose URL // appears in the text, but some NIP-C7/Concord clients (e.g. Ditto/Soapbox Armada) attach an // image purely as a NIP-92 `imeta` tag and leave the content empty. Append any imeta URL missing // from the content so those attachments render — symmetric to ChannelChat.imageMessage, which // appends the URL on send. Encrypted-blob key/nonce are already registered by URL, so the shared // pipeline fetches and decrypts them transparently. - val displayContent = remember(noteEvent) { appendMissingImetaUrls(noteEvent.content, noteEvent) } + val displayContent = remember(noteEvent, content) { appendMissingImetaUrls(content, noteEvent) } // A boosted note inside a zap/nutzap/onchain activity card is always shown as a // compact 2-line preview, even when the logged-in user is only a zap-split diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt new file mode 100644 index 0000000000..f811a041e7 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt @@ -0,0 +1,617 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteEvent +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.prediction_market +import com.vitorpamplona.amethyst.commons.resources.prediction_market_bet_range +import com.vitorpamplona.amethyst.commons.resources.prediction_market_cancel_reason +import com.vitorpamplona.amethyst.commons.resources.prediction_market_closed_on +import com.vitorpamplona.amethyst.commons.resources.prediction_market_closes_in +import com.vitorpamplona.amethyst.commons.resources.prediction_market_demo +import com.vitorpamplona.amethyst.commons.resources.prediction_market_fee +import com.vitorpamplona.amethyst.commons.resources.prediction_market_max_bet +import com.vitorpamplona.amethyst.commons.resources.prediction_market_min_bet +import com.vitorpamplona.amethyst.commons.resources.prediction_market_more_outcomes +import com.vitorpamplona.amethyst.commons.resources.prediction_market_status_cancelled +import com.vitorpamplona.amethyst.commons.resources.prediction_market_status_closed +import com.vitorpamplona.amethyst.commons.resources.prediction_market_status_open +import com.vitorpamplona.amethyst.commons.resources.prediction_market_status_resolved +import com.vitorpamplona.amethyst.commons.resources.prediction_market_winning_outcome +import com.vitorpamplona.amethyst.commons.ui.note.DateTimeStyle +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill +import com.vitorpamplona.amethyst.commons.ui.note.StatusPill +import com.vitorpamplona.amethyst.commons.ui.note.formatDateTime +import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +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.grayText +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.ui.theme.redColorOnSecondSurface +import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier +import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder +import com.vitorpamplona.amethyst.commons.ui.theme.warningColorOnSecondSurface +import com.vitorpamplona.amethyst.commons.util.formatDecimal +import com.vitorpamplona.amethyst.commons.util.formatGrouped +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketOutcome +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketStatus +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** How many outcomes a card lists before collapsing the rest into "+N more". */ +private const val MAX_OUTCOMES = 8 + +private val OutcomeShape = RoundedCornerShape(8.dp) + +/** + * Renders a kind-38000 prediction market (BAO Markets): the question, its status, whether real + * money is at stake, the outcomes with the winner marked once resolved, and when betting closes. + * + * Observed rather than read once: a market is addressable, and its publisher replaces it as it + * moves from open to closed to resolved, so a newer version lands on the same Note. + */ +@Composable +fun RenderPredictionMarket( + note: Note, + makeItShort: Boolean, + accountViewModel: AccountViewModel, +) { + val observedEvent by observeNoteEvent<PredictionMarketEvent>(note, accountViewModel) + val noteEvent = observedEvent ?: return + + PredictionMarketCard(noteEvent, makeItShort) +} + +/** Everything the card shows, read off the event once per event rather than on every recomposition. */ +@Immutable +private class PredictionMarketCardState( + val title: String?, + val description: String?, + val outcomes: ImmutableList<PredictionMarketOutcome>, + val status: PredictionMarketStatus?, + val resolution: String?, + val endsAt: Long?, + val category: String?, + val isDemo: Boolean, + /** Bet limits and fee, already formatted for display. */ + val minBetSats: String?, + val maxBetSats: String?, + val feePercent: String?, + val cancelReason: String?, + /** The moment the status was read, so the closing-time line agrees with the status badge. */ + val now: Long, +) { + fun isWinner(outcome: PredictionMarketOutcome) = outcome.isNamedBy(resolution) + + /** Two outcomes, one a yes and one a no — "YES"/"NO", "Yes (stays online)"/"No (goes offline)". */ + val isBinary = outcomes.size == 2 && outcomes.any { isYes(it.label) } && outcomes.any { isNo(it.label) } + + companion object { + fun from( + event: PredictionMarketEvent, + now: Long, + ) = PredictionMarketCardState( + title = event.title() ?: socialPostHeadline(event.content), + description = event.description()?.let(::collapseBlankLines), + outcomes = event.outcomeOptions().toImmutableList(), + status = event.status(now), + resolution = event.resolution(), + endsAt = event.endsAt(), + category = event.category(), + isDemo = event.isDemo(), + minBetSats = event.minBetSats()?.let(::formatGrouped), + maxBetSats = event.maxBetSats()?.let(::formatGrouped), + feePercent = event.feePercent()?.let(::formatPercent), + cancelReason = event.cancelReason(), + now = now, + ) + + fun isYes(outcome: String) = YES.containsMatchIn(outcome) + + fun isNo(outcome: String) = NO.containsMatchIn(outcome) + + private val YES = Regex("""^\s*yes\b""", RegexOption.IGNORE_CASE) + private val NO = Regex("""^\s*no\b""", RegexOption.IGNORE_CASE) + + /** The first line of a social-post `content`, for a market with no title anywhere else. */ + private fun socialPostHeadline(content: String): String? { + if (content.trimStart().startsWith("{")) return null + return content.lineSequence().firstOrNull { it.isNotBlank() }?.trim() + } + + /** 4.21 → "4.21%" ("4,21%" in German), 2.0 → "2%"; nothing for NaN, infinities or a negative. */ + private fun formatPercent(value: Double): String? = if (value.isFinite() && value >= 0) "${formatDecimal(value, 2)}%" else null + + /** + * BAO's descriptions carry paragraph breaks ("…UTC.\n\nSource of Truth: …"); clipped to a + * few lines, a blank line reads as a hole in the card. One line break each, never a gap. + */ + private fun collapseBlankLines(text: String): String = + // Line by line rather than a regex: a run of whitespace in a crafted event made the + // obvious pattern rescan it from every position — seconds on the main thread. + text + .lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString("\n") + } +} + +@Composable +fun PredictionMarketCard( + event: PredictionMarketEvent, + makeItShort: Boolean, +) { + val state = remember(event) { PredictionMarketCardState.from(event, TimeUtils.now()) } + + Column(MaterialTheme.colorScheme.replyModifier) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + MarketKicker(state.status) + + state.title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + + if (state.isDemo || state.category != null) { + MarketTags(state.isDemo, state.category) + } + + state.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = if (makeItShort) 3 else 8, + overflow = TextOverflow.Ellipsis, + ) + } + + if (state.outcomes.isNotEmpty()) { + MarketOutcomes(state) + } + + if (state.status == PredictionMarketStatus.CANCELLED) { + state.cancelReason?.let { CancelReason(it) } + } else { + state.endsAt?.let { ClosingTime(it, state.status, state.now) } + } + + MarketTerms(state.minBetSats, state.maxBetSats, state.feePercent) + } + } +} + +@Composable +private fun MarketKicker(status: PredictionMarketStatus?) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ShowChart, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text( + text = stringRes(Res.string.prediction_market), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + modifier = Modifier.padding(start = 6.dp).weight(1f), + ) + status?.let { MarketStatusPill(it) } + } +} + +@Composable +private fun MarketStatusPill(status: PredictionMarketStatus) { + when (status) { + PredictionMarketStatus.OPEN -> { + StatusPill( + label = stringRes(Res.string.prediction_market_status_open), + symbol = MaterialSymbols.RadioButtonChecked, + container = Color(0xFF1F883D), + content = Color.White, + ) + } + + PredictionMarketStatus.CLOSED -> { + StatusPill( + label = stringRes(Res.string.prediction_market_status_closed), + symbol = MaterialSymbols.Lock, + container = MaterialTheme.colorScheme.surfaceVariant, + content = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + PredictionMarketStatus.RESOLVED -> { + StatusPill( + label = stringRes(Res.string.prediction_market_status_resolved), + symbol = MaterialSymbols.CheckCircle, + container = MaterialTheme.colorScheme.primary, + content = MaterialTheme.colorScheme.onPrimary, + ) + } + + // Called off is not an error: quiet, like Closed. + PredictionMarketStatus.CANCELLED -> { + StatusPill( + label = stringRes(Res.string.prediction_market_status_cancelled), + symbol = MaterialSymbols.Block, + container = MaterialTheme.colorScheme.surfaceVariant, + content = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun MarketTags( + isDemo: Boolean, + category: String?, +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (isDemo) { + val warning = MaterialTheme.colorScheme.warningColorOnSecondSurface + StatusPill( + label = stringRes(Res.string.prediction_market_demo), + symbol = MaterialSymbols.Science, + container = warning.copy(alpha = 0.16f), + content = warning, + ) + } + category?.let { + HeaderPill(symbol = MaterialSymbols.Tag, text = it) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun MarketOutcomes(state: PredictionMarketCardState) { + val shown = if (state.outcomes.size > MAX_OUTCOMES) state.outcomes.subList(0, MAX_OUTCOMES) else state.outcomes + val hidden = state.outcomes.size - shown.size + // A cancelled market has no winner and takes no bets: its outcomes are shown, but muted. + val muted = state.status == PredictionMarketStatus.CANCELLED + + if (state.isBinary) { + BinaryOutcomes(state, muted) + return + } + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + shown.forEach { outcome -> + OutcomeChip(outcome.label, isWinner = state.isWinner(outcome), muted = muted) + } + if (hidden > 0) { + Text( + text = pluralStringRes(Res.plurals.prediction_market_more_outcomes, hidden, hidden), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } + } +} + +/** + * A yes/no market as its two sides, side by side and full width — the way betting apps lay out + * Yes and No: green and red while it is undecided; once it resolves the winner keeps its colour + * and a check, and the loser goes quiet. + */ +@Composable +private fun BinaryOutcomes( + state: PredictionMarketCardState, + muted: Boolean, +) { + val decided = state.resolution != null + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + state.outcomes.forEach { outcome -> + val isWinner = state.isWinner(outcome) + val quiet = muted || (decided && !isWinner) + val side = + if (PredictionMarketCardState.isYes(outcome.label)) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.redColorOnSecondSurface + } + val color = if (quiet) MaterialTheme.colorScheme.placeholderText else side + Row( + modifier = + Modifier + .weight(1f) + .clip(SideShape) + .background(if (quiet) Color.Transparent else side.copy(alpha = if (isWinner) 0.20f else 0.12f)) + .border(1.dp, if (quiet) MaterialTheme.colorScheme.subtleBorder else side.copy(alpha = 0.45f), SideShape) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + if (isWinner) { + Icon( + symbol = MaterialSymbols.CheckCircle, + contentDescription = stringRes(Res.string.prediction_market_winning_outcome), + tint = color, + filled = true, + modifier = Modifier.padding(end = 4.dp).size(16.dp), + ) + } + Text( + text = outcome.label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +private val SideShape = RoundedCornerShape(10.dp) + +@Composable +private fun OutcomeChip( + outcome: String, + isWinner: Boolean, + muted: Boolean, +) { + val success = MaterialTheme.colorScheme.allGoodColor + val borderColor = if (isWinner) success else MaterialTheme.colorScheme.subtleBorder + val textColor = + when { + isWinner -> success + muted -> MaterialTheme.colorScheme.placeholderText + else -> MaterialTheme.colorScheme.onSurface + } + + Row( + modifier = + Modifier + .clip(OutcomeShape) + .background(if (isWinner) success.copy(alpha = 0.14f) else Color.Transparent) + .border(1.dp, borderColor, OutcomeShape) + .padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (isWinner) { + Icon( + symbol = MaterialSymbols.CheckCircle, + contentDescription = stringRes(Res.string.prediction_market_winning_outcome), + tint = success, + filled = true, + modifier = Modifier.size(14.dp), + ) + } + Text( + text = outcome, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isWinner) FontWeight.Bold else FontWeight.Medium, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ClosingTime( + endsAt: Long, + status: PredictionMarketStatus?, + now: Long, +) { + // Read against the same moment as the status badge, so the two never disagree. A market + // declared closed or settled before its end has no closing time worth stating. + val stillOpen = status == PredictionMarketStatus.OPEN && endsAt > now + if (!stillOpen && endsAt > now) return + val text = + if (stillOpen) { + stringRes(Res.string.prediction_market_closes_in, timeAheadNoDot(endsAt)) + } else { + val date = remember(endsAt) { formatDateTime(endsAt * 1000, DateTimeStyle.MEDIUM, DateTimeStyle.NONE) } + stringRes(Res.string.prediction_market_closed_on, date) + } + + MarketFootnote( + symbol = if (stillOpen) MaterialSymbols.Schedule else MaterialSymbols.EventAvailable, + text = text, + color = MaterialTheme.colorScheme.placeholderText, + ) +} + +@Composable +private fun CancelReason(reason: String) { + MarketFootnote( + symbol = MaterialSymbols.Block, + text = stringRes(Res.string.prediction_market_cancel_reason, reason), + color = MaterialTheme.colorScheme.placeholderText, + ) +} + +@Composable +private fun MarketTerms( + minBetSats: String?, + maxBetSats: String?, + feePercent: String?, +) { + val limits = + when { + minBetSats != null && maxBetSats != null -> { + stringRes(Res.string.prediction_market_bet_range, minBetSats, maxBetSats) + } + + minBetSats != null -> { + stringRes(Res.string.prediction_market_min_bet, minBetSats) + } + + maxBetSats != null -> { + stringRes(Res.string.prediction_market_max_bet, maxBetSats) + } + + else -> { + null + } + } + val fee = feePercent?.let { stringRes(Res.string.prediction_market_fee, it) } + + if (limits == null && fee == null) return + + MarketFootnote( + symbol = MaterialSymbols.Paid, + text = listOfNotNull(limits, fee).joinToString(" · "), + color = MaterialTheme.colorScheme.placeholderText, + ) +} + +@Composable +private fun MarketFootnote( + symbol: MaterialSymbol, + text: String, + color: Color, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = symbol, + contentDescription = null, + tint = color, + modifier = Modifier.size(14.dp), + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = color, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 6.dp), + ) + } +} + +@Preview +@Composable +fun PredictionMarketCardPreview() { + val resolved = + PredictionMarketEvent( + id = "3de5a164159cd7b3925c3d7aa16a5c32f8f01e50d296e2a3821bfbf5c22db82e", + pubKey = "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", + createdAt = 1778605676, + tags = + arrayOf( + arrayOf("d", "caec08eca65442725a5bd8f77e6d2666"), + arrayOf("market", "caec08eca65442725a5bd8f77e6d2666"), + arrayOf("category", "ai-llm"), + arrayOf("status", "resolved"), + arrayOf("end", "1777680000"), + arrayOf("network", "demo"), + arrayOf("fee_percent", "4.21"), + arrayOf("min_bet", "100"), + arrayOf("max_bet", "100000"), + arrayOf("outcome", "YES"), + arrayOf("outcome", "NO"), + arrayOf("title", "Will an AI coding tool appear in GitHub top trending repos today?"), + arrayOf("resolution", "NO"), + ), + content = "", + sig = "", + ) + + val open = + PredictionMarketEvent( + id = "31da397fdc3a5c61aaa3fc12e8413279f99175ecdba65443d0a2e2495d73f5df", + pubKey = "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", + createdAt = 1786702119, + tags = + arrayOf( + arrayOf("d", "181f3a7acd28280a49e81bf70ec22998"), + arrayOf("market", "181f3a7acd28280a49e81bf70ec22998"), + arrayOf("status", "active"), + arrayOf("category", "mining"), + arrayOf("end", (TimeUtils.now() + TimeUtils.ONE_DAY * 3).toString()), + arrayOf("network", "mainnet"), + arrayOf("outcome", "Foundry USA"), + arrayOf("outcome", "AntPool"), + arrayOf("outcome", "F2Pool"), + arrayOf("outcome", "ViaBTC"), + arrayOf("outcome", "Other"), + arrayOf("title", "Which mining pool will mine the most Bitcoin blocks today?"), + ), + content = "{\"description\":\"Resolves based on the total block count at midnight UTC.\"}", + sig = "", + ) + + ThemeComparisonColumn { + Column(Modifier.padding(10.dp)) { + PredictionMarketCard(resolved, makeItShort = true) + PredictionMarketCard(open, makeItShort = true) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Text.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Text.kt index 9b4cd4de87..c89ac89230 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Text.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Text.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -36,6 +37,9 @@ import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit +import com.vitorpamplona.amethyst.commons.model.latestConcordEdit +import com.vitorpamplona.amethyst.commons.model.latestMarmotEdit import com.vitorpamplona.amethyst.commons.model.navigation.routeFor import com.vitorpamplona.amethyst.commons.model.replyingDirectlyTo import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists @@ -55,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HalfVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent @@ -62,6 +67,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent enum class ReplyRenderType { FULL, @@ -158,6 +164,10 @@ fun RenderTextEvent( return } + // A chat message edited in place (Buzz kind-40003, Concord, Marmot) shows its newest edit here + // too — notification cards and quotes render it through this path, not the chat bubble. + val chatEdit = if (noteEvent is ChatEvent || noteEvent is StreamMessageV2Event) observeChatMessageEdit(note) else null + LoadDecryptedContent( note, accountViewModel, @@ -171,7 +181,7 @@ fun RenderTextEvent( ?.event ?.content ?: body } else { - body + chatEdit?.event?.content ?: body } val eventContent = remember(newBody) { displayedNoteText(note, newBody) } @@ -223,3 +233,21 @@ fun RenderTextEvent( } } } + +/** The newest in-place edit of a chat message (Concord, Buzz or Marmot), recomposing as edits land. */ +@Composable +fun observeChatMessageEdit(note: Note): Note? { + // Resolved once per note, not on every recomposition: produceState's initialValue argument is + // re-evaluated each time this runs, and these feeds recompose a lot. + val initial = remember(note) { note.latestChatEdit() } + val latest by + produceState(initialValue = initial, note.idHex) { + note + .flow() + .edits.stateFlow + .collect { value = note.latestChatEdit() } + } + return latest +} + +private fun Note.latestChatEdit(): Note? = latestConcordEdit() ?: latestBuzzEdit() ?: latestMarmotEdit() diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/platform/AppPlatform.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/platform/AppPlatform.kt index 830d968259..3df0c16cbb 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/platform/AppPlatform.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/platform/AppPlatform.kt @@ -111,6 +111,12 @@ interface AppPlatform { @Composable fun rememberWebAppIconModel(url: String): String? = null + /** + * Opens the audio room at [addressValue] (a kind-30312 address) in its own window; callers + * hand the account over through `NestBridge` first. Does nothing where rooms are not wired. + */ + fun openNestRoom(addressValue: String) {} + /** Draws nothing: previews, and front ends still wiring their pieces. */ object None : AppPlatform } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/pow/PowDuration.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/pow/PowDuration.kt new file mode 100644 index 0000000000..322d5188c0 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/pow/PowDuration.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.pow + +import androidx.compose.runtime.Composable +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.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.util.availableProcessors +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 the app's PoW publish queue). 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(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) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.kt new file mode 100644 index 0000000000..c06fee8655 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.privacylock + +import androidx.compose.runtime.Composable + +/** Asks the user to prove it is them (biometrics or the device PIN) before a sensitive action. */ +fun interface DeviceAuthenticator { + /** + * Prompts with [title] and runs [onApproved] once the user passes. A device without a screen + * lock approves at once. [onError] gets a title and a message for a failed attempt. + */ + fun authenticate( + title: String, + onApproved: () -> Unit, + onError: (title: String, message: String) -> Unit, + ) +} + +/** + * The platform's device-credential prompt. Android shows BiometricPrompt (strong biometrics or + * the device credential) and falls back to the keyguard screen; desktop and iOS have no prompt + * wired yet and approve at once, as Android does on a device without a screen lock. + */ +@Composable +expect fun rememberDeviceAuthenticator(): DeviceAuthenticator diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/backups/BackupConflictCards.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/backups/BackupConflictCards.kt index de8a201fc7..ca6c952802 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/backups/BackupConflictCards.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.backups import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt index 38524194f0..ae5f291491 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.display import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.fillMaxSize diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index 5c658ad8ce..662a141897 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.display import androidx.compose.runtime.Composable import androidx.compose.runtime.State @@ -67,15 +67,16 @@ import com.vitorpamplona.amethyst.commons.ui.note.copyNoteTextAction import com.vitorpamplona.amethyst.commons.ui.note.elements.DropDownParams import com.vitorpamplona.amethyst.commons.ui.note.elements.observeBookmarksFollowsAndAccount import com.vitorpamplona.amethyst.commons.ui.note.externalLinkForNote +import com.vitorpamplona.amethyst.commons.ui.note.platform.EditPostView import com.vitorpamplona.amethyst.commons.ui.note.types.EditState import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.launch @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt index 837a4b38b1..3bd9c26c95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.display import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -51,7 +51,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkType @@ -71,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.DeletedItemsBanner import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel 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.ShorterTopAppBar @@ -92,7 +92,7 @@ fun BookmarkGroupScreen( nav: INav, ) { val bookmarkGroupViewModel: BookmarkGroupViewModel = - viewModel( + rememberViewModel( factory = BookmarkGroupViewModel.Initializer(accountViewModel.account, bookmarkIdentifier), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt index 7d6a37fe6a..460f964cdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt @@ -18,12 +18,13 @@ * 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.display +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.display import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark @@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlin.reflect.KClass @Stable class BookmarkGroupViewModel( @@ -172,7 +174,10 @@ class BookmarkGroupViewModel( class Initializer( val account: Account, val bookmarkGroupIdentifier: String, - ) : ViewModelProvider.NewInstanceFactory() { - override fun <T : ViewModel> create(modelClass: Class<T>): T = BookmarkGroupViewModel(account, bookmarkGroupIdentifier) as T + ) : ViewModelProvider.Factory { + override fun <T : ViewModel> create( + modelClass: KClass<T>, + extras: CreationExtras, + ): T = BookmarkGroupViewModel(account, bookmarkGroupIdentifier) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt index 1deec14d08..323585295f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.display import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.fillMaxSize diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt index 719bbfe4eb..2a56e23561 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadata +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.list.metadata import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -36,11 +36,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.about_us import com.vitorpamplona.amethyst.commons.resources.bookmark_list_creation_screen_title @@ -54,16 +52,17 @@ import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_ import com.vitorpamplona.amethyst.commons.resources.optional_picture_url import com.vitorpamplona.amethyst.commons.resources.read_only_user import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectSingleFromGallery +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.CreatingTopBar 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.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategoryFirstModifier import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @Composable @@ -72,7 +71,7 @@ fun BookmarkGroupMetadataScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel = viewModel() + val bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel = rememberViewModel { BookmarkGroupMetadataViewModel() } bookmarkGroupInfoViewModel.init(accountViewModel) if (bookmarkGroupIdentifier != null) { @@ -224,13 +223,12 @@ private fun Picture( ) }, leadingIcon = { - val context = LocalContext.current SelectSingleFromGallery( isUploading = bookmarkGroupInfoViewModel.isUploadingImageForPicture, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(start = 2.dp), ) { - bookmarkGroupInfoViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast) + bookmarkGroupInfoViewModel.uploadForPicture(it, onError = accountViewModel.toastManager::toast) } }, ) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt new file mode 100644 index 0000000000..c2a74b30e7 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.bookmarkgroups.list.metadata + +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.nip51Lists.bookmarkSets.BookmarkSet +import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +@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, + onError: (String, String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + upload( + uri, + onUploading = { isUploadingImageForPicture = it }, + onUploaded = { picture.value = TextFieldValue(it) }, + onError = onError, + ) + } + } + + private suspend fun upload( + galleryUri: SelectedMedia, + onUploading: (Boolean) -> Unit, + onUploaded: (String) -> Unit, + onError: (String, String) -> Unit, + ) { + onUploading(true) + val url = uploadToDefaultServer(galleryUri, account, accountViewModel.host.mediaUploader, onError) + onUploading(false) + if (url != null) onUploaded(url) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzForumThreadScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzForumThreadScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzForumThreadScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzForumThreadScreen.kt index c75688b1cf..32b6d57ee1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzForumThreadScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzForumThreadScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -43,7 +43,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -51,16 +50,17 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.buzz_forum_no_replies import com.vitorpamplona.amethyst.commons.resources.buzz_forum_thread_title import com.vitorpamplona.amethyst.commons.resources.chat_minichat_reply_count +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.note.UserPicture import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -120,7 +120,7 @@ fun BuzzForumThreadScreen( val canPost = channel.canPost(accountViewModel.userProfile().pubkeyHex) - val replyModel: ForumReplyNewMessageViewModel = viewModel(key = "forum-reply-$rootId") + val replyModel: ForumReplyNewMessageViewModel = rememberViewModel(key = "forum-reply-$rootId") { ForumReplyNewMessageViewModel() } replyModel.init(accountViewModel) replyModel.loadForumThread(channel, rootId) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/ForumReplyNewMessageViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/ForumReplyNewMessageViewModel.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/ForumReplyNewMessageViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/ForumReplyNewMessageViewModel.kt index d7845566f9..a94eea7147 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/ForumReplyNewMessageViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/ForumReplyNewMessageViewModel.kt @@ -18,11 +18,11 @@ * 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.buzz +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -56,16 +56,16 @@ class ForumReplyNewMessageViewModel : ChannelNewMessageViewModel() { return null } - // Resolve `@`-mentions the same way the chat composer does: the tagger rewrites each name - // into a `nostr:` reference in the body AND collects the cited users (seeded with the reply - // target) so they land as `p` mention tags — without this, a named member was neither - // notified nor linked. + // Resolve `@`-mentions the same way the chat composer does: the tagger collects the cited + // users (seeded with the reply target) so they land as `p` mention tags — without this, a + // named member was neither notified nor linked. The body keeps Buzz's plain `@Name` form. val tagger = NewMessageTagger( message = message.text.toString(), pTags = listOfNotNull(replyTo.value?.author), eTags = listOfNotNull(replyTo.value), dao = accountViewModel, + userMentionsAsNames = true, ) tagger.run() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarCollectionsScreen.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarCollectionsScreen.kt index 294dd1f135..783e53c1ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarCollectionsScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -32,8 +32,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 /** * Top-level screen for browsing NIP-52 kind-31924 calendars (collections of appointments). Reuses diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsTopBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarCollectionsTopBar.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsTopBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarCollectionsTopBar.kt index 380ea64ce6..7b7e4ff0f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsTopBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarCollectionsTopBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt index 1b05e7275d..c5058fd7fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -42,7 +42,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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 @@ -62,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_collections_s import com.vitorpamplona.amethyst.commons.resources.calendar_empty_collections_title import com.vitorpamplona.amethyst.commons.resources.calendar_export_event import com.vitorpamplona.amethyst.commons.resources.calendar_export_share_title +import com.vitorpamplona.amethyst.commons.ui.components.rememberFileSharer import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop @@ -69,10 +69,10 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent @@ -157,7 +157,7 @@ fun CalendarCollectionCard( val title = remember(note.idHex) { event.title() } val description = remember(note.idHex) { event.content.take(180) } val count = remember(note.idHex) { event.calendarEventAddresses().size } - val context = LocalContext.current + val fileSharer = rememberFileSharer() val icsChooserTitle = stringRes(Res.string.calendar_export_share_title) // Calendar collections are addressable (kind 31924); route to the dedicated @@ -223,7 +223,7 @@ fun CalendarCollectionCard( val members = collectMembers(event) val ics = IcsExport.calendarToIcs(event, members, TimeUtils.now()) val filename = IcsExport.calendarFilename(event) - shareIcs(context, filename, ics, icsChooserTitle) + fileSharer.shareTextFile(filename, "text/calendar", ics, icsChooserTitle) }) { Icon( symbol = MaterialSymbols.Share, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarRelativeTime.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarRelativeTime.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarRelativeTime.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarRelativeTime.kt index 5549052e62..d3b0fb32df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarRelativeTime.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarRelativeTime.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars -import android.text.format.DateUtils import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState @@ -29,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.calendar_relative_ongoing import com.vitorpamplona.amethyst.commons.resources.calendar_relative_ongoing_with_end import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.relativeTimeSpan import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -36,14 +36,14 @@ import com.vitorpamplona.quartz.utils.TimeUtils * label for an appointment. Returns null when the event has no parseable start (nothing to * anchor a relative phrase to). * - * Uses [DateUtils.getRelativeTimeSpanString] for the underlying minute/hour/day phrasing — that + * Uses the platform's relative-time formatter ([relativeTimeSpan]) for the underlying minute/hour/day phrasing — that * helper is locale-aware and ages from "just now" through "in N days" to absolute date for * far-out events. For all-day events we extend the resolution to DAY so we get "tomorrow", * "in 3 days" instead of an hour-precision phrase that would lie about the start moment. * * Ongoing events (start ≤ now ≤ end) get a composite "Happening now · ends in X" so a user * mid-event sees how much time is left rather than the misleading "started X minutes ago" that - * DateUtils would produce on its own. + * the formatter would produce on its own. */ suspend fun relativeTimeLabel( view: CalendarAppointmentView, @@ -54,31 +54,11 @@ suspend fun relativeTimeLabel( if (end != null && start <= nowSeconds && nowSeconds <= end) { val ongoing = loadStringRes(Res.string.calendar_relative_ongoing) - val endsIn = - DateUtils - .getRelativeTimeSpanString( - end * 1000L, - nowSeconds * 1000L, - if (view.isAllDay) DateUtils.DAY_IN_MILLIS else DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE, - ).toString() + val endsIn = relativeTimeSpan(end, nowSeconds, view.isAllDay) return loadStringRes(Res.string.calendar_relative_ongoing_with_end, ongoing, endsIn) } - val minResolution = - if (view.isAllDay) { - DateUtils.DAY_IN_MILLIS - } else { - DateUtils.MINUTE_IN_MILLIS - } - - return DateUtils - .getRelativeTimeSpanString( - start * 1000L, - nowSeconds * 1000L, - minResolution, - DateUtils.FORMAT_ABBREV_RELATIVE, - ).toString() + return relativeTimeSpan(start, nowSeconds, view.isAllDay) } /** diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt new file mode 100644 index 0000000000..f8448b1788 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView +import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock +import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate +import com.vitorpamplona.amethyst.commons.ui.note.DateSkeletonFormatter +import com.vitorpamplona.amethyst.commons.ui.note.calendarYearAndDay + +// Skeletons follow Unicode LDML — the platform picks the locale's field order. +private val dayMonthFormat = DateSkeletonFormatter("EEEMMMd") // "Mon, May 28" / "Mon 28 May" +private val fullDateFormat = DateSkeletonFormatter("EEEEMMMMdy") // "Monday, May 28, 2026" / "Monday, 28 May 2026" +private val monthYearFormat = DateSkeletonFormatter("MMMMy") // "May 2026" / "Mai 2026" +private val weekdayShortFormat = DateSkeletonFormatter("EEE") // "Mon" — order doesn't matter + +/** + * The appointment's date or time range. [formatTime] renders a time of day under the user's + * 12/24-hour setting; composables get it from `rememberTimeOfDayFormatter()`. + */ +fun formatCalendarRange( + note: Note, + formatTime: (epochMillis: Long) -> String, +): 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, formatTime) + } +} + +private fun formatTimeRange( + start: Long, + end: Long?, + formatTime: (epochMillis: Long) -> String, +): String { + val startMs = start * 1000 + val startStr = "${dayMonthFormat.format(startMs)} · ${formatTime(startMs)}" + if (end == null || end == start) return startStr + val endMs = end * 1000 + return if (isSameDay(startMs, endMs)) { + "$startStr – ${formatTime(endMs)}" + } else { + "$startStr – ${dayMonthFormat.format(endMs)} · ${formatTime(endMs)}" + } +} + +private fun formatDateRange( + start: Long, + end: Long?, +): String { + val startStr = dayMonthFormat.format(start * 1000) + if (end == null || end == start) return startStr + return "$startStr – ${dayMonthFormat.format(end * 1000)}" +} + +fun formatLongDate(unixSeconds: Long): String = fullDateFormat.format(unixSeconds * 1000) + +fun formatMonthYear( + year: Int, + monthZeroBased: Int, +): String = monthYearFormat.format(LocalClock.startOfDay(SearchDate(year, monthZeroBased + 1, 1)) * 1000) + +fun formatTimeOfDay( + unixSeconds: Long, + formatTime: (epochMillis: Long) -> String, +): String = formatTime(unixSeconds * 1000) + +/** The short name of a weekday, 0 = Sunday. */ +fun formatShortWeekday(weekdayZeroBased: Int): String = + // 2026-01-04 was a Sunday; any known Sunday works. + weekdayShortFormat.format(LocalClock.startOfDay(SearchDate(2026, 1, 4 + weekdayZeroBased)) * 1000) + +private fun isSameDay( + aMs: Long, + bMs: Long, +): Boolean = calendarYearAndDay(aMs) == calendarYearAndDay(bMs) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/PhoneCalendar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/PhoneCalendar.kt new file mode 100644 index 0000000000..f2e91e24f6 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/PhoneCalendar.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars + +import androidx.compose.runtime.Composable +import com.vitorpamplona.quartz.nip01Core.core.Event + +/** Hands a NIP-52 appointment to the device's own calendar app. */ +fun interface PhoneCalendar { + /** + * Opens the calendar's "New Event" composer pre-filled with [event]. Returns false when there + * is no calendar app to take it (or [event] is not an appointment), so the caller can fall + * back to sharing an .ics file. + */ + fun add(event: Event): Boolean +} + +/** + * Android opens the system calendar composer. Desktop and iOS have no calendar composer wired + * yet and always report false. + */ +@Composable +expect fun rememberPhoneCalendar(): PhoneCalendar diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/RelativeTimeSpan.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/RelativeTimeSpan.kt new file mode 100644 index 0000000000..4f290044c4 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/RelativeTimeSpan.kt @@ -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.commons.ui.screen.loggedIn.calendars + +/** + * The platform's own phrase for how far [targetSeconds] is from [nowSeconds] ("in 2 hr.", + * "5 min. ago", "tomorrow"), no finer than a day when [dayResolution] is set. Android asks + * `DateUtils`, iOS `NSRelativeDateTimeFormatter`; Desktop has no such formatter and writes a + * compact signed span. + */ +expect suspend fun relativeTimeSpan( + targetSeconds: Long, + nowSeconds: Long, + dayResolution: Boolean, +): String diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt index d98058b5f3..046e62b0be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.create import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -53,7 +53,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.calendar_all_day import com.vitorpamplona.amethyst.commons.resources.calendar_collection_delete @@ -68,12 +67,13 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_untitled import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.edit_calendar_collection import com.vitorpamplona.amethyst.commons.resources.new_calendar_collection +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel 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.calendars.formatLongDate import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatLongDate @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -82,7 +82,7 @@ fun NewCalendarCollectionScreen( accountViewModel: AccountViewModel, editDTag: String? = null, ) { - val vm: NewCalendarCollectionViewModel = viewModel() + val vm: NewCalendarCollectionViewModel = rememberViewModel { NewCalendarCollectionViewModel() } vm.init(accountViewModel, editDTag) Scaffold( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt index 8cf614ea2b..f25719d6d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.create import androidx.compose.runtime.Immutable import androidx.compose.runtime.mutableStateListOf @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.Job import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssemblerSubscription.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssemblerSubscription.kt index 4066159581..81c741b434 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssemblerSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssemblerSubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/AddToCalendarSheet.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/detail/AddToCalendarSheet.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/AddToCalendarSheet.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/detail/AddToCalendarSheet.kt index e686b5cbfb..2ab24d626a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/AddToCalendarSheet.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/detail/AddToCalendarSheet.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.detail import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt index 9915e54662..8ee6961fcd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt @@ -18,10 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.detail -import android.content.Intent -import android.net.Uri import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -52,11 +50,10 @@ import androidx.compose.runtime.setValue 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.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -91,24 +88,26 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_untitled import com.vitorpamplona.amethyst.commons.resources.edit_calendar_event import com.vitorpamplona.amethyst.commons.resources.route_calendar_event_detail import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.commons.ui.components.rememberFileSharer +import com.vitorpamplona.amethyst.commons.ui.components.rememberTextSharer import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.commons.ui.note.LoadUser import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow +import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter import com.vitorpamplona.amethyst.commons.ui.note.types.CalendarRsvpRow import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatCalendarRange +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatLongDate +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.rememberPhoneCalendar +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size30dp import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.addToPhoneCalendar -import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange -import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatLongDate -import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.shareIcs import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -176,7 +175,9 @@ fun CalendarEventDetailScreen( } }, actions = { - val context = LocalContext.current + val phoneCalendar = rememberPhoneCalendar() + val fileSharer = rememberFileSharer() + val textSharer = rememberTextSharer() val icsChooserTitle = stringRes(Res.string.calendar_export_share_title) // Two share modes: // - .ics for non-nostr calendar apps (Google Calendar / iOS / Outlook) @@ -189,10 +190,10 @@ fun CalendarEventDetailScreen( // has no calendar app registered for ACTION_INSERT (rare; Wear OS, some // GrapheneOS profiles). IconButton(onClick = { - if (!addToPhoneCalendar(context, event)) { + if (!phoneCalendar.add(event)) { val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now()) val filename = IcsExport.appointmentFilename(event, targetAddress) - shareIcs(context, filename, ics, icsChooserTitle) + fileSharer.shareTextFile(filename, "text/calendar", ics, icsChooserTitle) } }) { Icon( @@ -205,7 +206,7 @@ fun CalendarEventDetailScreen( IconButton(onClick = { val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now()) val filename = IcsExport.appointmentFilename(event, targetAddress) - shareIcs(context, filename, ics, icsChooserTitle) + fileSharer.shareTextFile(filename, "text/calendar", ics, icsChooserTitle) }) { Icon( symbol = MaterialSymbols.Share, @@ -223,15 +224,7 @@ fun CalendarEventDetailScreen( targetAddress.dTag, null, ) - val intent = - Intent(Intent.ACTION_SEND) - .setType("text/plain") - .putExtra(Intent.EXTRA_TEXT, "nostr:$naddr") - context.startActivity( - Intent - .createChooser(intent, shareTitle) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) + textSharer.share("nostr:$naddr", null, shareTitle) }) { Icon( symbol = MaterialSymbols.AutoMirrored.Send, @@ -346,8 +339,8 @@ private fun EventBody( fontWeight = FontWeight.Bold, ) } - val context = LocalContext.current - formatCalendarRange(note, context)?.let { range -> + val timeFormatter = rememberTimeOfDayFormatter() + formatCalendarRange(note, timeFormatter)?.let { range -> Text( text = range, style = MaterialTheme.typography.bodyLarge, @@ -593,7 +586,7 @@ private fun HeroImage( @Composable private fun LocationRow(location: String) { - val context = LocalContext.current + val uriHandler = LocalUriHandler.current // NIP-52 `location` is free-text — a place name, an address, OR a meeting URL (Zoom, Jitsi, // a livestream link). Treat http(s) URLs as web links so they open in the browser; otherwise // hand to the maps app via the geo: intent. @@ -605,17 +598,10 @@ private fun LocationRow(location: String) { .clickable { runCatching { val trimmed = location.trim() - val intent = - if (isUrl) { - Intent(Intent.ACTION_VIEW, trimmed.toUri()) - } else { - // `geo:0,0?q=<location>` is the Android geo intent; the user's - // installed maps app handles it. - Intent(Intent.ACTION_VIEW, "geo:0,0?q=${Uri.encode(trimmed)}".toUri()) - } - // runCatching swallows ActivityNotFoundException when no handler is + // `geo:0,0?q=<location>` is the Android geo URI; the user's installed maps + // app handles it. runCatching swallows the failure when no handler is // installed — we don't have anywhere useful to fall back to. - context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + uriHandler.openUri(if (isUrl) trimmed else "geo:0,0?q=${encodeUriComponent(trimmed)}") } }.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, @@ -903,3 +889,18 @@ private fun rememberCalendarsContaining(targetAddress: Address): State<List<Cale return calendars.collectAsStateWithLifecycle(emptyList()) } + +/** Percent-encodes everything but the unreserved characters, as Android's `Uri.encode` does. */ +private fun encodeUriComponent(text: String): String { + val sb = StringBuilder(text.length) + for (b in text.encodeToByteArray()) { + val c = (b.toInt() and 0xFF).toChar() + if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c in "_-!.~'()*") { + sb.append(c) + } else { + sb.append('%') + sb.append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase()) + } + } + return sb.toString() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt index d6e91be25d..0abe3113f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt index a184abec97..b5a8f442f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup -import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -48,9 +47,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.User @@ -75,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.resources.cordn_info_has_key_package import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_member import com.vitorpamplona.amethyst.commons.resources.cordn_member_count import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.components.rememberWindowViewModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.note.UserPicture @@ -367,13 +365,10 @@ private fun MemberRow( * a roster built under another key. */ @Composable -fun rememberCordnGroupDraft(accountViewModel: AccountViewModel): CordnGroupDraft { - val activity = LocalActivity.current as FragmentActivity - return viewModel( - viewModelStoreOwner = activity, +fun rememberCordnGroupDraft(accountViewModel: AccountViewModel): CordnGroupDraft = + rememberWindowViewModel( key = "CordnGroupDraft-${accountViewModel.account.signer.pubKey}", - ) -} + ) { CordnGroupDraft() } /** * What each coordinator this account uses could do with [roster]. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt index a6f0fb7fcb..2f537436c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -66,11 +66,11 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.rooms.CordnGroupRoomCompose import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.cordn.CoordinatorIdentityRow import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.CordnGroupRoomCompose import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.flow.MutableStateFlow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt index f2f0000f73..17fc78210c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.resources.chat_delivery_details_title import com.vitorpamplona.amethyst.commons.resources.chat_delivery_failed import com.vitorpamplona.amethyst.commons.resources.chat_delivery_no_relay_info import com.vitorpamplona.amethyst.commons.resources.chat_delivery_pending +import com.vitorpamplona.amethyst.commons.resources.chat_delivery_rejected import com.vitorpamplona.amethyst.commons.resources.chat_delivery_sending import com.vitorpamplona.amethyst.commons.resources.close import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox @@ -228,6 +229,7 @@ private fun ChatDeliveryDetailDialog( RelayDeliveryRow( relay = relay, accepted = relay in delivery.acceptedRelays || relay in seenOnRelays, + rejection = delivery.rejectedRelays[relay], ) } @@ -309,19 +311,34 @@ private fun RecipientDeliveryRow( private fun RelayDeliveryRow( relay: NormalizedRelayUrl, accepted: Boolean, + rejection: String? = null, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = relay.displayUrl(), - modifier = Modifier.weight(1f), - maxLines = 1, - ) + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = relay.displayUrl(), + modifier = Modifier.weight(1f), + maxLines = 1, + ) - DeliveryStatusTick(accepted) + if (!accepted && rejection != null) { + TickIcon(MaterialSymbols.Block, Res.string.chat_delivery_rejected, MaterialTheme.colorScheme.error) + } else { + DeliveryStatusTick(accepted) + } + } + // The relay's own words: the only clue to WHY a message will never arrive. + if (!accepted && rejection != null) { + Text( + text = rejection.ifBlank { stringRes(Res.string.chat_delivery_rejected) }, + color = MaterialTheme.colorScheme.error, + fontSize = Font12SP, + ) + } } } @@ -360,6 +377,13 @@ private fun RenderDeliveryTicks( else -> Unit } + // Every targeted relay refused it: it will never arrive. Not "pending" — the detail dialog + // behind this tick shows each relay's reason. + if (delivery?.isRejected == true) { + TickIcon(MaterialSymbols.Block, Res.string.chat_delivery_rejected, MaterialTheme.colorScheme.error) + return + } + if (delivery == null) { // Untracked (sent before a restart): the seen-on relay set is the only signal. if (seenSomewhere) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt index e856d62f1a..619c6f2100 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatFeedView.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index 4ce029ed54..aea25619aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Column @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.screen.SaveableFeedState +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt index 7578d88e8a..7df8f8fcaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.ui.components.DefaultInlineQuoteRenderer import com.vitorpamplona.amethyst.commons.ui.components.InlineQuoteRenderer import com.vitorpamplona.amethyst.commons.ui.note.WatchNoteEvent -import com.vitorpamplona.amethyst.ui.components.DefaultInlineQuoteRenderer import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 105cbca892..3d83168823 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -50,7 +50,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -60,7 +59,6 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.isBuzzEditableBy @@ -72,16 +70,13 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_edit_message import com.vitorpamplona.amethyst.commons.resources.chat_delivery_details_title import com.vitorpamplona.amethyst.commons.resources.edit_draft import com.vitorpamplona.amethyst.commons.resources.edit_message -import com.vitorpamplona.amethyst.commons.resources.error_dialog_zap_error import com.vitorpamplona.amethyst.commons.resources.more_options -import com.vitorpamplona.amethyst.commons.resources.no_wallet_found import com.vitorpamplona.amethyst.commons.resources.quick_action_delete_dialog_btn import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_title import com.vitorpamplona.amethyst.commons.resources.relay_group_pin_message import com.vitorpamplona.amethyst.commons.resources.relay_group_unpin_message import com.vitorpamplona.amethyst.commons.resources.reply_description import com.vitorpamplona.amethyst.commons.resources.show_less -import com.vitorpamplona.amethyst.commons.service.ZapPaymentHandler import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.ChangeReactionIcon @@ -95,10 +90,11 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.NoteActionHandlers import com.vitorpamplona.amethyst.commons.ui.note.elements.ShareOptionsBottomSheet import com.vitorpamplona.amethyst.commons.ui.note.elements.noteActionSections import com.vitorpamplona.amethyst.commons.ui.note.elements.observeBookmarksFollowsAndAccount +import com.vitorpamplona.amethyst.commons.ui.note.platform.EditPostView +import com.vitorpamplona.amethyst.commons.ui.note.platform.QuickZapAmountRow import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordExpiringPinDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.OnchainZapSendDialog -import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.navigateToReloadMint import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.Size28Modifier @@ -107,21 +103,14 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.ui.theme.reactionBox import com.vitorpamplona.amethyst.commons.ui.theme.selectedReactionBoxModifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.EditPostView -import com.vitorpamplona.amethyst.ui.note.ZapAmountChoiceGrid -import com.vitorpamplona.amethyst.ui.note.observeZapRailCapability -import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlin.uuid.ExperimentalUuidApi // null amount = open the on-chain dialog with no prefill. @Immutable @@ -481,7 +470,7 @@ private fun RelayGroupPinTile( * zap, or a reply) and reveals everything else on demand. */ @Composable -internal fun MoreActionsToggle( +fun MoreActionsToggle( expanded: Boolean, onToggle: () -> Unit, ) { @@ -543,7 +532,7 @@ private fun ChatOnlyRow( @OptIn(ExperimentalLayoutApi::class) @Composable -internal fun TileRow(content: @Composable () -> Unit) { +fun TileRow(content: @Composable () -> Unit) { FlowRow( modifier = Modifier @@ -557,7 +546,7 @@ internal fun TileRow(content: @Composable () -> Unit) { } @Composable -internal fun ActionTile( +fun ActionTile( symbol: MaterialSymbol, label: String, isDestructive: Boolean = false, @@ -596,7 +585,7 @@ internal fun ActionTile( private const val SECTION_DIVIDER_ALPHA = 0.5f @Composable -internal fun SectionDivider() { +fun SectionDivider() { HorizontalDivider( thickness = DividerThickness, color = MaterialTheme.colorScheme.placeholderText.copy(alpha = SECTION_DIVIDER_ALPHA), @@ -662,98 +651,3 @@ private fun QuickReactionRow( } } } - -/** - * 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 -private 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, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index c34785a2c8..0edc893ad8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -49,39 +49,38 @@ import com.vitorpamplona.amethyst.commons.chats.ui.ChatBubbleLayout import com.vitorpamplona.amethyst.commons.chats.ui.ChatGroupPosition import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiCount import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit -import com.vitorpamplona.amethyst.commons.model.latestConcordEdit -import com.vitorpamplona.amethyst.commons.model.latestMarmotEdit import com.vitorpamplona.amethyst.commons.model.navigation.routeFor +import com.vitorpamplona.amethyst.commons.notifications.NotificationContent import com.vitorpamplona.amethyst.commons.ui.components.LocalInlineQuoteRenderer import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.commons.ui.note.RenderZapRaiser import com.vitorpamplona.amethyst.commons.ui.note.WatchBlockAndReport import com.vitorpamplona.amethyst.commons.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.DisplayZapSplits +import com.vitorpamplona.amethyst.commons.ui.note.types.observeChatMessageEdit +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderBuzzActivityRow +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderBuzzDiff +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderBuzzEditedNote +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderBuzzForumVote import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderBuzzSystemMessage +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderChannelAdminSystemMessage import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderChatClip +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderChatRaid +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderChatZap import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderConcordTimerNotice +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderEditedNote import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderEncryptedMediaV2 +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2 +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.hasMip04Media +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow import com.vitorpamplona.amethyst.commons.ui.theme.ReactionRowZapraiser import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.note.RenderZapRaiser -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzActivityRow -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzDiff -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzEditedNote -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzForumVote -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChannelAdminSystemMessage -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEditedNote -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedMediaV2 -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2 -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent @@ -669,17 +668,22 @@ fun NoteRow( } /** - * The newest edit overlaying a chat message [note] (Concord kind-3302 or Buzz kind-40003), or null - * when unedited. A message is only ever one kind, so both resolve off the same [Note.edits] and one - * collector on the note's edits flow serves both — recomposing whenever an edit is added or removed. + * The text a one-line chat preview (room list, workspace rows) shows for [note]: the latest edit's + * text when there is one — the room shows the edit, so the list must too — with `nostr:` user + * references rewritten to `@Name`, as the bubble renders them. */ @Composable -fun observeChatEdit(note: Note): Note? { - val latest by - produceState<Note?>(initialValue = null, note.idHex) { - note.flow().edits.stateFlow.collect { - value = note.latestConcordEdit() ?: note.latestBuzzEdit() ?: note.latestMarmotEdit() - } - } - return latest +fun observeChatPreviewText( + note: Note, + max: Int = 200, +): String { + val content = observeChatEdit(note)?.event?.content ?: note.event?.content ?: "" + return remember(content, max) { NotificationContent.resolveMentions(content, max).text } } + +/** + * The newest edit overlaying a chat message [note] (Concord, Buzz or Marmot), or null when unedited, + * recomposing whenever an edit is added or removed. + */ +@Composable +fun observeChatEdit(note: Note): Note? = observeChatMessageEdit(note) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt index 9ffea11b26..fc965758f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi @@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.commons.resources.chat_minichat_reply_count import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.LikedIcon +import com.vitorpamplona.amethyst.commons.ui.note.ObserveZapAmountText import com.vitorpamplona.amethyst.commons.ui.note.ZappedIcon import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.richtext.InLineIconRenderer @@ -76,14 +77,13 @@ import com.vitorpamplona.amethyst.commons.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.note.ObserveZapAmountText import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Immutable -internal data class ReactionChip( +data class ReactionChip( val type: String, val count: Int, val includesMe: Boolean, @@ -219,7 +219,7 @@ fun ChatReactionChips( */ @OptIn(ExperimentalLayoutApi::class) @Composable -internal fun ChatChipFlowRow(content: @Composable () -> Unit) { +fun ChatChipFlowRow(content: @Composable () -> Unit) { FlowRow( // Inset from the bubble's edge so overlapping chips ride the border without // poking past the bubble's rounded corners. @@ -302,7 +302,7 @@ private fun MinichatChip( @OptIn(ExperimentalFoundationApi::class) @Composable -internal fun ReactionChipView( +fun ReactionChipView( chip: ReactionChip, onClick: () -> Unit, onLongClick: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt index 246d683152..63d5606454 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -29,7 +29,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -182,7 +181,6 @@ fun ChatDisplayExpiration(expirationDate: Long) { modifier = Modifier.size(12.dp), tint = MaterialTheme.colorScheme.placeholderText, ) - val context = LocalContext.current Text( text = timeAheadNoDot(expirationDate), color = MaterialTheme.colorScheme.placeholderText, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index 0b619808bb..3f006671d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt index 836da6cfc3..eebc9220b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt index df4bfc6ab9..9d559cafe6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChannelAdminNote.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChannelAdminNote.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChannelAdminNote.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChannelAdminNote.kt index 9d076d82e9..f9b83e2d80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChannelAdminNote.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChannelAdminNote.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt index 36295d5eac..12130f105b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt index eb9dc3f248..0219d847ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.commons.util.showAmountInteger import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.utils.toLongValue private const val BIG_ZAP_THRESHOLD_SATS = 50_000L @@ -76,7 +77,7 @@ fun RenderChatZap( val isBigZap = remember(zapEvent) { - (zapEvent.amount?.toLong() ?: 0L) >= BIG_ZAP_THRESHOLD_SATS + (zapEvent.amount?.toLongValue() ?: 0L) >= BIG_ZAP_THRESHOLD_SATS } val accentAlpha = if (isBigZap) 0.18f else 0.08f diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt index 746c729d19..8c53ae57ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.ObserveDraftEvent +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.NoteRow +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.RenderReplyRow import com.vitorpamplona.amethyst.commons.ui.theme.RowColSpacing5dp import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.NoteRow -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RenderReplyRow @Composable fun RenderDraftEvent( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEditedNote.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderEditedNote.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEditedNote.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderEditedNote.kt index 867c082f6b..710d18733f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEditedNote.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderEditedNote.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt index 95b955f564..69ba4e25cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -38,10 +38,10 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderAudioWithWaveform +import com.vitorpamplona.amethyst.commons.ui.note.platform.ZoomableContentView import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWithWaveform import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaReferenceV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaV2 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt index 27dad4cdbe..5599015351 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -38,10 +38,10 @@ import com.vitorpamplona.amethyst.commons.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.commons.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.LoadDecryptedContentOrNull +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderAudioFromIMeta import com.vitorpamplona.amethyst.commons.ui.note.types.getAudioMetaWithWaveform import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.note.types.RenderAudioFromIMeta @Composable fun RenderRegularTextNote( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt index 5b1590c891..ff511b6eab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.geohashChat -import android.Manifest import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -56,17 +55,14 @@ import androidx.compose.ui.draw.clip 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.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.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.search.SearchSeed import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.commons.ui.components.rememberCoarseLocationPermission +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold @@ -74,22 +70,24 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SearchTopBarAction import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.LocalChatActingIdentities +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.LocalChatDisplayNameResolver +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.LocalChatReactOverride +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.LocalChatShowSelfAuthorName +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.LocalChatSuppressGeohash +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatActingIdentities -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatDisplayNameResolver -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatReactOverride -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatShowSelfAuthorName -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatSuppressGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -121,7 +119,6 @@ fun GeohashChatScreen( } } -@OptIn(ExperimentalPermissionsApi::class) @Composable private fun GeohashChatRoom( channel: GeohashChatChannel, @@ -133,17 +130,17 @@ private fun GeohashChatRoom( val relays = remember(channel) { channel.relays().toList() } // Identity/reactions helper (per-cell pubkeys + anonymous react). - val identity: GeohashChatViewModel = viewModel(key = "GeohashIdentity/$geohash") + val identity: GeohashChatViewModel = rememberViewModel(key = "GeohashIdentity/$geohash") { GeohashChatViewModel() } identity.init(geohash, accountViewModel) val feedViewModel: ChannelFeedViewModel = - viewModel( + rememberViewModel( key = "geohash:${geohash}ChannelFeedViewModel", factory = ChannelFeedViewModel.Factory(channel, accountViewModel.account), ) // The shared composer, made geohash-aware (per-cell signer + PoW + n/t tags) in its send path. - val newMessageModel: ChannelNewMessageViewModel = viewModel(key = "geohash:${geohash}NewMessage") + val newMessageModel: ChannelNewMessageViewModel = rememberViewModel(key = "geohash:${geohash}NewMessage") { ChannelNewMessageViewModel() } newMessageModel.init(accountViewModel) newMessageModel.load(channel) @@ -159,13 +156,14 @@ private fun GeohashChatRoom( // otherwise we fall back to how the user arrived — the map picker passes teleported=true, "near me" // and manual entry default to false. This drives the ["t","teleport"] tag honestly. rememberPermissionState // only reads the current grant (it never prompts), so opening a location chat can't trigger a GPS dialog. - val locationPermission = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) - LaunchedEffect(locationPermission.status.isGranted) { - if (locationPermission.status.isGranted) { - Amethyst.instance.locationManager.setLocationPermission(true) + val locationPermission = rememberCoarseLocationPermission() + val locationSource = LocalAppServices.current.deviceLocation + LaunchedEffect(locationPermission.isGranted) { + if (locationPermission.isGranted) { + locationSource.setLocationPermission(true) } } - val deviceLocation by Amethyst.instance.locationManager.preciseGeohashStateFlow + val deviceLocation by locationSource.preciseGeohashStateFlow .collectAsStateWithLifecycle() val isTeleported = remember(deviceLocation, geohash, teleported) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt index 6f26ea4fdc..e15e1a8e96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.geohashChat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/GeohashChatsScreen.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatsScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/GeohashChatsScreen.kt index 71955dd4e6..57dc5a6464 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatsScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/GeohashChatsScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.geohashChat import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/LoadGeohashChannel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/LoadGeohashChannel.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/LoadGeohashChannel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/LoadGeohashChannel.kt index 61988db27d..cff1509ea8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/LoadGeohashChannel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/LoadGeohashChannel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.geohashChat import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.commons.model.cache.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt index 7e8c436be2..4ef92bd12f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.geohashChat -import android.Manifest import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -56,18 +55,16 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp 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.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.ui.components.rememberCoarseLocationPermission 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.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash @@ -274,18 +271,18 @@ private fun ManualEntrySection(onOpen: (String) -> Unit) { } } -@OptIn(ExperimentalPermissionsApi::class) @Composable private fun NearMeSection(onOpen: (String) -> Unit) { Column { SectionHeader(MaterialSymbols.Explore, "Near me") - val permission = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) - LaunchedEffect(permission.status.isGranted) { - Amethyst.instance.locationManager.setLocationPermission(permission.status.isGranted) + val permission = rememberCoarseLocationPermission() + val locationSource = LocalAppServices.current.deviceLocation + LaunchedEffect(permission.isGranted) { + locationSource.setLocationPermission(permission.isGranted) } - if (!permission.status.isGranted) { + if (!permission.isGranted) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), modifier = Modifier.fillMaxWidth(), @@ -297,7 +294,7 @@ private fun NearMeSection(onOpen: (String) -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 12.dp), ) - FilledTonalButton(onClick = { permission.launchPermissionRequest() }) { + FilledTonalButton(onClick = { permission.launchRequest() }) { SymbolIcon(symbol = MaterialSymbols.LocationOn, contentDescription = null, modifier = Modifier.size(18.dp)) Text(" Use my location") } @@ -306,7 +303,7 @@ private fun NearMeSection(onOpen: (String) -> Unit) { return } - val location by Amethyst.instance.locationManager.preciseGeohashStateFlow + val location by LocalAppServices.current.deviceLocation.preciseGeohashStateFlow .collectAsStateWithLifecycle() when (val loc = location) { is LocationResult.Success -> { @@ -375,7 +372,7 @@ private fun TeleportCard(onClick: () -> Unit) { } } -internal fun GeohashChannelLevel.label(): String = +fun GeohashChannelLevel.label(): String = when (this) { GeohashChannelLevel.CONTINENT -> "Continent" GeohashChannelLevel.REGION -> "Region" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index 6b7216fa01..fc6a54f588 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup -import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -42,7 +41,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.marmot.MarmotGroupIconChange import com.vitorpamplona.amethyst.commons.marmot.ui.MarmotRetentionChoice @@ -60,17 +58,19 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_keypackage_relays_not import com.vitorpamplona.amethyst.commons.resources.marmot_skip_for_now import com.vitorpamplona.amethyst.commons.resources.marmot_use_outbox_relays import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.components.rememberLongNotice import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.CreatingTopBar +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.send.uploadMarmotGroupIcon import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.uploadMarmotGroupIcon import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException @@ -93,7 +93,7 @@ fun CreateGroupScreen( var isCreating by remember { mutableStateOf(false) } var showKeyPackageRelayDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - val context = LocalContext.current + val longNotice = rememberLongNotice() /** * Create the group, optionally after [prepare]. @@ -154,12 +154,7 @@ fun CreateGroupScreen( } catch (e: Exception) { isCreating = false launch(Dispatchers.Main) { - Toast - .makeText( - context, - loadStringRes(Res.string.marmot_failed_to_create_group, e.message), - Toast.LENGTH_LONG, - ).show() + longNotice.show(loadStringRes(Res.string.marmot_failed_to_create_group, e.message)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt index 40e271da12..f745746ee6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup -import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -40,7 +39,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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.marmot.MarmotGroupIconChange @@ -58,15 +56,18 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_group_name_placeholde import com.vitorpamplona.amethyst.commons.resources.marmot_legacy_group_no_avatar_url import com.vitorpamplona.amethyst.commons.resources.save import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.components.rememberLongNotice +import com.vitorpamplona.amethyst.commons.ui.components.rememberShortNotice import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ActionTopBar +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.send.uploadMarmotGroupIcon import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.uploadMarmotGroupIcon import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.launch @Composable @@ -92,7 +93,8 @@ fun EditGroupInfoScreen( var removeIcon by remember { mutableStateOf(false) } var isSaving by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - val context = LocalContext.current + val shortNotice = rememberShortNotice() + val longNotice = rememberLongNotice() val iconChanged = pickedIcon != null || removeIcon val avatarUrlChanged = avatarUrl.trim() != currentAvatarUrl?.url.orEmpty() @@ -136,20 +138,13 @@ fun EditGroupInfoScreen( accountViewModel.setMarmotGroupAvatarUrl(nostrGroupId, avatarUrl.trim()) } launch(Dispatchers.Main) { - Toast - .makeText(context, loadStringRes(Res.string.marmot_group_info_updated), Toast.LENGTH_SHORT) - .show() + shortNotice.show(loadStringRes(Res.string.marmot_group_info_updated)) } nav.popBack() } catch (e: Exception) { isSaving = false launch(Dispatchers.Main) { - Toast - .makeText( - context, - loadStringRes(Res.string.marmot_failed_to_update, e.message), - Toast.LENGTH_LONG, - ).show() + longNotice.show(loadStringRes(Res.string.marmot_failed_to_update, e.message)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt index fa57d7efda..4fff094bc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.marmotOtherMembers import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header.DisplayUserSetAsSubject import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index b902700071..c7793cca4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup -import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -46,11 +45,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute @@ -68,11 +65,25 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_t import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_reset import com.vitorpamplona.amethyst.commons.resources.reply_here import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +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.components.rememberLongNotice +import com.vitorpamplona.amethyst.commons.ui.components.rememberShortNotice +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.LocalChatIsOneOnOne +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.send.MarmotNewMessageViewModel +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.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.EditingMessageBanner import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer @@ -82,22 +93,12 @@ 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.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatIsOneOnOne -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotNewMessageViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.EditingMessageBanner import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.launch @Composable @@ -109,7 +110,7 @@ fun MarmotGroupChatView( nav: INav, ) { val feedViewModel: MarmotGroupFeedViewModel = - viewModel( + rememberViewModel( key = nostrGroupId + "MarmotGroupFeedViewModel", factory = MarmotGroupFeedViewModel.Factory( @@ -125,7 +126,7 @@ fun MarmotGroupChatView( accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) } - val newMessageModel: MarmotNewMessageViewModel = viewModel(key = nostrGroupId + "MarmotNewMessageViewModel") + val newMessageModel: MarmotNewMessageViewModel = rememberViewModel(key = nostrGroupId + "MarmotNewMessageViewModel") { MarmotNewMessageViewModel() } newMessageModel.init(accountViewModel) newMessageModel.load(nostrGroupId) @@ -231,7 +232,7 @@ fun MarmotGroupMessageComposer( ) { val scope = rememberCoroutineScope() val canPost by remember { derivedStateOf { newMessageModel.canPost() } } - val context = LocalContext.current + val shortNotice = rememberShortNotice() var isUploading by remember { mutableStateOf(false) } @@ -306,12 +307,7 @@ fun MarmotGroupMessageComposer( onMessageSent() } catch (e: Exception) { launch(Dispatchers.Main) { - Toast - .makeText( - context, - "Failed to send message: ${e.message}", - Toast.LENGTH_SHORT, - ).show() + shortNotice.show("Failed to send message: ${e.message}") } } } @@ -353,7 +349,8 @@ private fun MarmotGroupFileUploadDialog( onUpload: suspend () -> Unit, onCancel: () -> Unit, ) { - val context = LocalContext.current + val shortNotice = rememberShortNotice() + val longNotice = rememberLongNotice() val scope = rememberCoroutineScope() ChatFileUploadDialog( @@ -370,12 +367,7 @@ private fun MarmotGroupFileUploadDialog( val exporterSecret = accountViewModel.marmotMediaExporterSecret(nostrGroupId) if (exporterSecret == null) { launch(Dispatchers.Main) { - Toast - .makeText( - context, - loadStringRes(Res.string.marmot_not_a_member), - Toast.LENGTH_SHORT, - ).show() + shortNotice.show(loadStringRes(Res.string.marmot_not_a_member)) } return@launch } @@ -385,10 +377,9 @@ private fun MarmotGroupFileUploadDialog( exporterSecret = exporterSecret, onError = { title, message -> scope.launch(Dispatchers.Main) { - Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() + longNotice.show("$title: $message") } }, - context = context, uploader = accountViewModel.host.mediaUploader, onceUploaded = { uploads -> MarmotFileSender(nostrGroupId, accountViewModel).send(uploads) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt index 8db68d763e..50ef95e08c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt @@ -18,15 +18,17 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras import com.vitorpamplona.amethyst.commons.feeds.MarmotGroupFeedFilter import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.viewmodels.ListChangeFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.reflect.KClass class MarmotGroupFeedViewModel( nostrGroupId: HexKey, @@ -40,6 +42,9 @@ class MarmotGroupFeedViewModel( val account: Account, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun <T : ViewModel> create(modelClass: Class<T>): T = MarmotGroupFeedViewModel(nostrGroupId, account) as T + override fun <T : ViewModel> create( + modelClass: KClass<T>, + extras: CreationExtras, + ): T = MarmotGroupFeedViewModel(nostrGroupId, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt index 7707996ad9..992b6311e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt @@ -18,15 +18,16 @@ * 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.marmotGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupImage import com.vitorpamplona.amethyst.commons.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.commons.service.BlossomServerFinder +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 import com.vitorpamplona.quartz.marmot.appComponents.MarmotWebUrl @@ -43,7 +44,7 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri * * The blob is content-addressed on Blossom by [MarmotGroupImage.hash]; the canonical * scheme stores only the hash, and the icon usually lives on the *uploader's* Blossom - * server rather than the viewer's. So we resolve it through [Amethyst.blossomResolver], + * server rather than the viewer's. So we resolve it through the app's BUD-10 resolver ([BlossomServerFinder]), * which probes the viewer's default server (passed as a first-try `xs` hint) and the * group admins' configured servers (via their pubkeys as `as` authors, BUD-03). While * that async probe runs, we optimistically load from the viewer's default server so the @@ -77,9 +78,9 @@ fun rememberMarmotGroupIconUrl( ).toUriString() } - val resolver = Amethyst.instance.blossomResolver - val url by produceState(resolver.cachedFindServer(blossomUri)?.serverUrl ?: fallbackUrl, blossomUri) { - value = resolver.findServers(blossomUri)?.serverUrl ?: fallbackUrl + val finder = LocalAppServices.current.blossomServerFinder + val url by produceState(finder.cachedServerUrl(blossomUri) ?: fallbackUrl, blossomUri) { + value = finder.findServerUrl(blossomUri) ?: fallbackUrl } val cipher = remember(image) { MarmotGroupImageCipher(image.key, image.nonce) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt index bb320241ca..ecfef27dd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt @@ -18,11 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -33,10 +30,13 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupImage import com.vitorpamplona.amethyst.commons.resources.Res @@ -45,8 +45,10 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_change_photo import com.vitorpamplona.amethyst.commons.resources.marmot_group_icon import com.vitorpamplona.amethyst.commons.resources.marmot_remove_photo import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelectSingle import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -72,11 +74,13 @@ fun MarmotGroupIconEditor( onPick: (SelectedMedia) -> Unit, onRemove: () -> Unit, ) { - val resolver = LocalContext.current.contentResolver - val launcher = - rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - if (uri != null) onPick(SelectedMedia(uri, resolver.getType(uri))) + var showPicker by remember { mutableStateOf(false) } + if (showPicker) { + GallerySelectSingle(imagesOnly = true) { media -> + showPicker = false + media?.let(onPick) } + } val model = when { @@ -99,7 +103,7 @@ fun MarmotGroupIconEditor( Modifier .size(96.dp) .clip(CircleShape) - .let { if (enabled) it.clickable { launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } else it }, + .let { if (enabled) it.clickable { showPicker = true } else it }, loadProfilePicture = LocalDisplaySettings.current.showProfilePictures, loadRobohash = LocalDisplaySettings.current.loadRobohash, ) @@ -110,7 +114,7 @@ fun MarmotGroupIconEditor( ) { TextButton( enabled = enabled, - onClick = { launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, + onClick = { showPicker = true }, ) { Text(stringRes(if (hasIcon) Res.string.marmot_change_photo else Res.string.marmot_add_photo)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 26bc045929..67f2da937c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup -import android.widget.Toast import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -62,7 +61,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboard -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 @@ -127,6 +125,8 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_unknown_error import com.vitorpamplona.amethyst.commons.resources.marmot_user_fallback_name import com.vitorpamplona.amethyst.commons.resources.members import com.vitorpamplona.amethyst.commons.resources.remove +import com.vitorpamplona.amethyst.commons.ui.components.rememberLongNotice +import com.vitorpamplona.amethyst.commons.ui.components.rememberShortNotice import com.vitorpamplona.amethyst.commons.ui.components.util.setText import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.loadStringRes @@ -139,6 +139,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserS import com.vitorpamplona.amethyst.commons.ui.note.timeAgo import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.marmotGroupTitle +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.marmotOtherMembers import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.MediumRelayIconModifier import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat @@ -149,7 +151,9 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -186,7 +190,8 @@ fun MarmotGroupInfoScreen( var isAdding by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val myPubkey = accountViewModel.account.signer.pubKey - val context = LocalContext.current + val shortNotice = rememberShortNotice() + val longNotice = rememberLongNotice() val userSuggestions = remember { @@ -346,12 +351,7 @@ fun MarmotGroupInfoScreen( Button( onClick = { if (!accountViewModel.hasBlossomServers()) { - Toast - .makeText( - context, - marmotEnableEncryptedMediaNeedsServerStr, - Toast.LENGTH_LONG, - ).show() + longNotice.show(marmotEnableEncryptedMediaNeedsServerStr) return@Button } isEnablingMedia = true @@ -359,24 +359,16 @@ fun MarmotGroupInfoScreen( try { accountViewModel.enableMarmotEncryptedMediaV2(nostrGroupId) launch(Dispatchers.Main) { - Toast - .makeText( - context, - loadStringRes(Res.string.marmot_encrypted_media_enabled_toast), - Toast.LENGTH_SHORT, - ).show() + shortNotice.show(loadStringRes(Res.string.marmot_encrypted_media_enabled_toast)) } } catch (e: Exception) { launch(Dispatchers.Main) { - Toast - .makeText( - context, - loadStringRes( - Res.string.marmot_failed_to_enable_encrypted_media, - e.message, - ), - Toast.LENGTH_LONG, - ).show() + longNotice.show( + loadStringRes( + Res.string.marmot_failed_to_enable_encrypted_media, + e.message, + ), + ) } } finally { isEnablingMedia = false @@ -512,12 +504,7 @@ fun MarmotGroupInfoScreen( } catch (e: Exception) { isLeaving = false launch(Dispatchers.Main) { - Toast - .makeText( - context, - loadStringRes(Res.string.marmot_failed_to_leave_group, e.message), - Toast.LENGTH_LONG, - ).show() + longNotice.show(loadStringRes(Res.string.marmot_failed_to_leave_group, e.message)) } } } @@ -542,18 +529,15 @@ fun MarmotGroupInfoScreen( // outbound work, so leaving the screen is right. val ended = accountViewModel.disbandMarmotGroup(nostrGroupId) launch(Dispatchers.Main) { - Toast - .makeText( - context, - loadStringRes( - if (ended) { - Res.string.marmot_group_disbanded_toast - } else { - Res.string.marmot_group_disbanding_toast - }, - ), - Toast.LENGTH_SHORT, - ).show() + shortNotice.show( + loadStringRes( + if (ended) { + Res.string.marmot_group_disbanded_toast + } else { + Res.string.marmot_group_disbanding_toast + }, + ), + ) } nav.nav(Route.Message) } catch (e: Exception) { @@ -562,12 +546,7 @@ fun MarmotGroupInfoScreen( // navigate away on a change that did not happen. isDisbanding = false launch(Dispatchers.Main) { - Toast - .makeText( - context, - loadStringRes(Res.string.marmot_failed_to_disband, e.message), - Toast.LENGTH_LONG, - ).show() + longNotice.show(loadStringRes(Res.string.marmot_failed_to_disband, e.message)) } } } @@ -586,18 +565,11 @@ fun MarmotGroupInfoScreen( try { accountViewModel.removeMarmotGroupMember(nostrGroupId, member.leafIndex) launch(Dispatchers.Main) { - Toast - .makeText(context, loadStringRes(Res.string.marmot_member_removed), Toast.LENGTH_SHORT) - .show() + shortNotice.show(loadStringRes(Res.string.marmot_member_removed)) } } catch (e: Exception) { launch(Dispatchers.Main) { - Toast - .makeText( - context, - loadStringRes(Res.string.marmot_failed_to_remove_member, e.message), - Toast.LENGTH_LONG, - ).show() + longNotice.show(loadStringRes(Res.string.marmot_failed_to_remove_member, e.message)) } } } @@ -616,18 +588,11 @@ fun MarmotGroupInfoScreen( try { accountViewModel.grantMarmotGroupAdmin(nostrGroupId, member.pubkey) launch(Dispatchers.Main) { - Toast - .makeText(context, loadStringRes(Res.string.marmot_admin_granted), Toast.LENGTH_SHORT) - .show() + shortNotice.show(loadStringRes(Res.string.marmot_admin_granted)) } } catch (e: Exception) { launch(Dispatchers.Main) { - Toast - .makeText( - context, - loadStringRes(Res.string.marmot_failed_to_grant_admin, e.message), - Toast.LENGTH_LONG, - ).show() + longNotice.show(loadStringRes(Res.string.marmot_failed_to_grant_admin, e.message)) } } } @@ -646,18 +611,11 @@ fun MarmotGroupInfoScreen( try { accountViewModel.revokeMarmotGroupAdmin(nostrGroupId, member.pubkey) launch(Dispatchers.Main) { - Toast - .makeText(context, loadStringRes(Res.string.marmot_admin_revoked), Toast.LENGTH_SHORT) - .show() + shortNotice.show(loadStringRes(Res.string.marmot_admin_revoked)) } } catch (e: Exception) { launch(Dispatchers.Main) { - Toast - .makeText( - context, - loadStringRes(Res.string.marmot_failed_to_revoke_admin, e.message), - Toast.LENGTH_LONG, - ).show() + longNotice.show(loadStringRes(Res.string.marmot_failed_to_revoke_admin, e.message)) } } } @@ -980,7 +938,7 @@ fun GroupRelayStrip( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - val nowSeconds = System.currentTimeMillis() / 1000L + val nowSeconds = TimeUtils.now() normalized.forEach { (raw, relay) -> val lastSeen = relayActivity[relay] val isActive = lastSeen != null && (nowSeconds - lastSeen) <= RELAY_ACTIVITY_WINDOW_SECS @@ -1090,7 +1048,7 @@ fun RelayHealthSection( fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), ) - val nowSeconds = System.currentTimeMillis() / 1000L + val nowSeconds = TimeUtils.now() normalized.forEach { (raw, relay) -> val lastSeen = relayActivity[relay] val isActive = lastSeen != null && (nowSeconds - lastSeen) <= RELAY_ACTIVITY_WINDOW_SECS @@ -1113,7 +1071,6 @@ private fun RelayHealthRow( isActive: Boolean, nav: INav, ) { - val context = LocalContext.current val dotColor = if (isActive) { MaterialTheme.colorScheme.allGoodColor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index 6fae96011f..48651ebfcb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -84,6 +84,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.marmotGroupPreviewText +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.marmotOtherMembers import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header.DisplayUserSetAsSubject import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt index 69445c053b..951a760f58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup import androidx.compose.runtime.Composable import androidx.compose.runtime.key @@ -33,12 +33,12 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_preview_group_updated import com.vitorpamplona.amethyst.commons.resources.marmot_preview_media import com.vitorpamplona.amethyst.commons.resources.marmot_preview_no_text import com.vitorpamplona.amethyst.commons.resources.marmot_preview_with_sender +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.observeChatEdit +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2 +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.hasMip04Media import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.observeChatEdit -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2 -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotInviteDeviceBanner.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotInviteDeviceBanner.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotInviteDeviceBanner.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotInviteDeviceBanner.kt index d8655ff66b..c1f7a69a3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotInviteDeviceBanner.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotInviteDeviceBanner.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup -import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -45,7 +44,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -57,10 +55,13 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_failure import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_publish import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_rejected import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_success +import com.vitorpamplona.amethyst.commons.ui.components.rememberLongNotice +import com.vitorpamplona.amethyst.commons.ui.components.rememberShortNotice import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.coroutines.cancellation.CancellationException @@ -96,7 +97,8 @@ fun MarmotInviteDeviceBanner( // has already read and waved away. var dismissed by rememberSaveable { mutableStateOf(false) } val scope = rememberCoroutineScope() - val context = LocalContext.current + val shortNotice = rememberShortNotice() + val longNotice = rememberLongNotice() // A read-only login cannot publish at all, so the only action this banner // offers is impossible for it. Better to say nothing than to offer a button // that silently does nothing. @@ -178,9 +180,9 @@ fun MarmotInviteDeviceBanner( val accepted = accountViewModel.republishKeyPackage() launch(Dispatchers.Main) { if (accepted) { - Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show() + shortNotice.show(successMessage) } else { - Toast.makeText(context, rejectedMessage, Toast.LENGTH_LONG).show() + longNotice.show(rejectedMessage) owner = LatestKeyPackageOwner.OTHER_DEVICE } } @@ -194,7 +196,7 @@ fun MarmotInviteDeviceBanner( val failureMessage = loadStringRes(Res.string.marmot_invite_device_failure, e.message ?: "") launch(Dispatchers.Main) { - Toast.makeText(context, failureMessage, Toast.LENGTH_LONG).show() + longNotice.show(failureMessage) // The warning was right after all, so put it back. owner = LatestKeyPackageOwner.OTHER_DEVICE } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotSystemRowRenderer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotSystemRowRenderer.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotSystemRowRenderer.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotSystemRowRenderer.kt index ea5571d5a4..c2e1a2b8c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotSystemRowRenderer.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotSystemRowRenderer.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -50,9 +50,9 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_added_p import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_left import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_removed import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_removed_passive +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatFeedRowRenderer import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatFeedRowRenderer import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemEvent import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemType diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt index 1309eb280b..3f21a77082 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.send import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.buildMip04IMetaTag diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt index 004bfa0a64..015cdba5db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt @@ -18,10 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.send -import android.content.Context -import android.net.Uri import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details @@ -30,8 +28,8 @@ import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaReferenceV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaV2Cipher @@ -79,7 +77,6 @@ class MarmotFileUploader( viewState: ChatFileUploadState, exporterSecret: ByteArray, onError: (title: String, message: String) -> Unit, - context: Context, uploader: MediaUploader, /** * Produce `encrypted-media-v2` references instead of MIP-04 ones. @@ -98,7 +95,7 @@ class MarmotFileUploader( val item = multiOrchestrator.get(i) val media = item.media val mimeType = media.mimeType ?: "application/octet-stream" - val filename = resolveFilename(context, media.uri, mimeType) + val filename = uploader.displayName(media.uri) ?: fallbackFilename(mimeType) // v2 puts `m` inside both the key derivation and the AEAD // associated data, so it has to be the canonical form and not @@ -197,19 +194,7 @@ class MarmotFileUploader( viewState.mediaUploadTracker.finishUpload() } - private fun resolveFilename( - context: Context, - uri: Uri, - mimeType: String, - ): String { - val cursor = context.contentResolver.query(uri, null, null, null, null) - val name = - cursor?.use { - val idx = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if (idx >= 0 && it.moveToFirst()) it.getString(idx) else null - } - if (name != null) return name - + private fun fallbackFilename(mimeType: String): String { val ext = when { mimeType.startsWith("image/jpeg") -> "jpg" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt index 39004e420c..26e58a8b98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt @@ -18,24 +18,23 @@ * 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.marmotGroup.send +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.send -import android.net.Uri import com.vitorpamplona.amethyst.commons.marmot.MarmotGroupIconUpload import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader +import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.utils.Log -import java.io.File /** * Encrypts a picked image with the MIP-01 v2 scheme (see [MarmotGroupImageEncryption]) @@ -50,7 +49,7 @@ class MarmotGroupIconUploader( val account: Account, ) { suspend fun upload( - uri: Uri, + uri: MediaUri, mimeType: String?, server: ServerName, uploader: MediaUploader, @@ -105,13 +104,7 @@ class MarmotGroupIconUploader( } finally { // Delete the intermediate compressed temp file (compress returns the original // URI unchanged when it skips compression, so only delete a distinct temp). - if (compressed.uri != uri) { - try { - compressed.uri.path?.let { path -> File(path).takeIf { it.exists() }?.delete() } - } catch (e: Exception) { - Log.w("MarmotGroupIconUploader", "Failed to delete temp icon file", e) - } - } + if (compressed.uri != uri) uploader.discardTempFile(compressed.uri) } } @@ -119,3 +112,15 @@ class MarmotGroupIconUploader( private const val DEFAULT_MIME = "image/jpeg" } } + +/** + * Encrypts and uploads a picked image as a group avatar (canonical `marmot-group-image-v1` + * scheme). The returned handle is later committed through + * [AccountViewModel.updateMarmotGroupMetadata] as `MarmotGroupIconChange.Set`; uploading is + * separate from that commit so the slow Blossom upload can show its own progress first. + */ +suspend fun AccountViewModel.uploadMarmotGroupIcon( + uri: MediaUri, + mimeType: String?, + uploader: MediaUploader, +): MarmotGroupIconUpload = MarmotGroupIconUploader(account).upload(uri, mimeType, account.settings.defaultFileServer, uploader) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt index edc31b853a..8cf84d4ea2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.send import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText @@ -35,10 +35,10 @@ import com.vitorpamplona.amethyst.commons.model.latestMarmotEdit import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.onUiThread import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.ImmutableList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt index 99a108d88f..910a496a4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt @@ -18,11 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.minichat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.minichat import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply @@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn +import kotlin.reflect.KClass /** * The reactive read-model of a message's minichat: the root's kind-1111 [CommentEvent] @@ -65,6 +67,9 @@ class MinichatFeedViewModel( val account: Account, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun <T : ViewModel> create(modelClass: Class<T>): T = MinichatFeedViewModel(rootNote, account) as T + override fun <T : ViewModel> create( + modelClass: KClass<T>, + extras: CreationExtras, + ): T = MinichatFeedViewModel(rootNote, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt similarity index 81% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt index f54529cd63..4dd9eec17f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.minichat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.minichat -import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight @@ -49,17 +48,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.mediaServers.buzzWorkspaceServer import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back @@ -69,26 +70,32 @@ import com.vitorpamplona.amethyst.commons.resources.concord_open_channel import com.vitorpamplona.amethyst.commons.resources.reply_here import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.commons.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.commons.ui.components.rememberLongNotice +import com.vitorpamplona.amethyst.commons.ui.components.rememberShortNotice +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.LocalSuppressReplyToNoteId +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +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.screen.loggedIn.chats.utils.toConcordImeta +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.toPlainImetas import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalSuppressReplyToNoteId -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toPlainImetas +import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -137,7 +144,7 @@ fun MinichatScreen( EventFinderFilterAssemblerSubscription(rootNote, accountViewModel) val feedViewModel: MinichatFeedViewModel = - viewModel( + rememberViewModel( key = rootId + "MinichatFeedViewModel", factory = MinichatFeedViewModel.Factory(rootNote, accountViewModel.account), ) @@ -151,15 +158,25 @@ fun MinichatScreen( val composer = remember { TextFieldState() } val scope = rememberCoroutineScope() - val context = LocalContext.current + val shortNotice = rememberShortNotice() + val longNotice = rememberLongNotice() val canPost by remember { derivedStateOf { composer.text.isNotBlank() } } + // A thread in a Buzz workspace channel: its media must live on the workspace's own server + // (the relay refuses any other image host) and its typing signal is scoped to the thread. + // Reactive on the dialect: a relay is marked Buzz only once its first verified Buzz event lands, + // which a cold start can reach after this screen opens. + val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle() + val relayGroup = remember(rootNote) { rootNote.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel } } + val buzzGroup = relayGroup?.takeIf { it.groupId.relayUrl in buzzRelays } val uploadState = - remember { + remember(buzzGroup) { ChatFileUploadState( accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.stripLocationOnUpload, - ) + ).apply { lockServer(buzzGroup?.let { buzzWorkspaceServer(it.groupId.relayUrl.url) }) } } + // Client-side throttle for the Buzz kind-20002 typing heartbeat. + val lastTypingSecs = remember { longArrayOf(0L) } Scaffold( topBar = { @@ -235,10 +252,10 @@ fun MinichatScreen( ChatFileUploader(accountViewModel.account).justUploadNIP17( viewState = uploadState, onError = { title, message -> - launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() } + launch(Dispatchers.Main) { longNotice.show("$title: $message") } }, onEncryptedUploadError = { title, message -> - launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() } + launch(Dispatchers.Main) { longNotice.show("$title: $message") } }, uploader = accountViewModel.host.mediaUploader, onceUploaded = { uploads -> @@ -249,7 +266,9 @@ fun MinichatScreen( } }, ) - accountViewModel.account.settings.changeDefaultFileServer(uploadState.selectedServer) + if (uploadState.lockedServer == null) { + accountViewModel.account.settings.changeDefaultFileServer(uploadState.selectedServer) + } accountViewModel.account.settings.changeStripLocationOnUpload(uploadState.stripMetadata) } }, @@ -262,6 +281,19 @@ fun MinichatScreen( Column(modifier = EditFieldModifier) { ThinPaddingTextField( state = composer, + onTextChanged = { + val group = buzzGroup + val now = TimeUtils.now() + if (group != null && composer.text.isNotEmpty() && now - lastTypingSecs[0] >= BuzzTypingState.TYPING_HEARTBEAT_SECS) { + lastTypingSecs[0] = now + val rootEvent = rootNote.event + accountViewModel.sendBuzzTyping( + group, + threadRootId = rootEvent?.tags?.buzzThreadRootForReplyTo(rootEvent.id) ?: rootNote.idHex, + replyToId = rootNote.idHex, + ) + } + }, onContentReceived = { uri, mimeType -> uploadState.load(persistentListOf(SelectedMedia(uri, mimeType))) // Same encryption choice the gallery button makes below. @@ -301,7 +333,7 @@ fun MinichatScreen( accountViewModel.account.sendMinichatReply(rootNote, text) } catch (e: Exception) { launch(Dispatchers.Main) { - Toast.makeText(context, "Failed to send message: ${e.message}", Toast.LENGTH_SHORT).show() + shortNotice.show("Failed to send message: ${e.message}") } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt index 5b707cb16e..64b9e055d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -30,9 +30,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier 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.header.RenderRoomTopBar import com.vitorpamplona.amethyst.commons.ui.theme.BottomTopHeight import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.collections.immutable.persistentSetOf diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index b12fe8797e..4852925074 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -34,10 +34,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -47,6 +45,8 @@ import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.event.EventFind import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.chats_history_proto_nip04 import com.vitorpamplona.amethyst.commons.resources.chats_history_proto_nip17 +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog @@ -56,17 +56,16 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.commons.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header.DmReportWarningCard +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia -import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.DmReportWarningCard -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -92,7 +91,7 @@ fun ChatroomView( nav: INav, ) { val feedViewModel: ChatroomFeedViewModel = - viewModel( + rememberViewModel( key = room.hashCode().toString() + "ChatroomViewModels", factory = ChatroomFeedViewModel.Factory( @@ -101,7 +100,7 @@ fun ChatroomView( ), ) - val newPostModel: ChatNewMessageViewModel = viewModel() + val newPostModel: ChatNewMessageViewModel = rememberViewModel { ChatNewMessageViewModel() } newPostModel.init(accountViewModel) newPostModel.load(room) @@ -144,10 +143,10 @@ fun ChatroomView( newPostModel.onMessageChanged() } } - val context = LocalContext.current + val mediaResolver = rememberSharedMediaResolver() if (attachmentUri != null) { LaunchedEffect(key1 = attachmentUri) { - resolveSharedMedia(context, attachmentUri)?.let { + mediaResolver.resolve(attachmentUri)?.let { newPostModel.pickedMedia(persistentListOf(it)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt index 71187b8101..3b63ba4dd6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt @@ -18,15 +18,17 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras import com.vitorpamplona.amethyst.commons.feeds.ChatroomFeedFilter import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.viewmodels.ListChangeFeedViewModel import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlin.reflect.KClass class ChatroomFeedViewModel( val user: ChatroomKey, @@ -40,6 +42,9 @@ class ChatroomFeedViewModel( val account: Account, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun <T : ViewModel> create(modelClass: Class<T>): T = ChatroomFeedViewModel(user, account) as T + override fun <T : ViewModel> create( + modelClass: KClass<T>, + extras: CreationExtras, + ): T = ChatroomFeedViewModel(user, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt index 09aa97fc9a..75ec7e2507 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/DmReportWarningCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/DmReportWarningCard.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/DmReportWarningCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/DmReportWarningCard.kt index 25ad963b7f..ba583191f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/DmReportWarningCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/DmReportWarningCard.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt index 0314a3d947..e1ead0a8e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt index 1d39484d2a..75c9e9d497 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 5059cfe763..b99fa90af4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText @@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -43,27 +42,27 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.PreviewState import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.IZapField +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileSender +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.ChatFileUploadState +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.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileSender -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash @@ -103,6 +102,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -798,9 +798,7 @@ class ChatNewMessageViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -826,7 +824,7 @@ class ChatNewMessageViewModel : } override fun onCleared() { - Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } + Log.d("Init") { "OnCleared: ${this::class.simpleName}" } } // NIP-04 sending is deprecated. NIP-17 is always used. @@ -870,5 +868,5 @@ class ChatNewMessageViewModel : draftTag.newVersion() } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt index e04b6ce484..b2110656e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt @@ -18,15 +18,14 @@ * 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.privateDM.send +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send -import android.webkit.MimeTypeMap import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import com.vitorpamplona.amethyst.commons.service.upload.FileHeader +import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder +import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.service.uploads.prepare import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.imetaTagBuilder @@ -40,21 +39,17 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash -import okhttp3.OkHttpClient -import java.util.Locale class IMetaAttachments { var iMetaAttachments by mutableStateOf<List<IMetaTag>>(emptyList()) suspend fun downloadAndPrepare( url: String, - okHttpClient: (String) -> OkHttpClient, + uploader: MediaUploader, + httpClients: IRoleBasedHttpClientBuilder, ) { - val fileExtension: String = MimeTypeMap.getFileExtensionFromUrl(url) - val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.lowercase(Locale.getDefault())) - val imeta = - FileHeader.prepare(url, mimeType, null, okHttpClient).getOrNull()?.let { + uploader.remoteFileHeader(url, httpClients)?.let { IMetaTagBuilder(url) .apply { hash(it.hash) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 93d0c369e0..9ad5b744af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send -import android.net.Uri import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Box @@ -58,13 +57,10 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList @@ -87,10 +83,17 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +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.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler +import com.vitorpamplona.amethyst.commons.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeToMessage @@ -98,16 +101,29 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.commons.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.commons.ui.note.creators.expiration.ExpirationDateButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.expiration.ExpirationDatePicker import com.vitorpamplona.amethyst.commons.ui.note.creators.invoice.AddLnInvoiceButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.invoice.NewPostInvoiceRequest import com.vitorpamplona.amethyst.commons.ui.note.creators.location.AddGeoHashButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField +import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.MessageField +import com.vitorpamplona.amethyst.commons.ui.note.creators.previews.PreviewUrl import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.SecretEmojiRequest +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.creators.zapraiser.AddZapraiserButton import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.ZapRaiserRequest +import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapToButton +import com.vitorpamplona.amethyst.commons.ui.note.platform.ZoomableContentView +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.EncryptedUploadErrorDialog +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.RecipientMissingRelaysWarning +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.commons.ui.text.onUiThread @@ -120,27 +136,10 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightPage 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.TakePictureButton -import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton -import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList -import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker -import com.vitorpamplona.amethyst.ui.note.creators.invoice.NewPostInvoiceRequest -import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection -import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField -import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrl -import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) @Composable @@ -148,34 +147,20 @@ fun NewGroupDMScreen( message: String? = null, attachment: String? = null, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { - NewGroupDMScreen(message, attachment?.ifBlank { null }?.toUri(), accountViewModel, nav) -} - -@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) -@Composable -fun NewGroupDMScreen( - message: String? = null, - attachment: Uri? = null, - accountViewModel: AccountViewModel, - nav: Nav, -) { - val postViewModel: ChatNewMessageViewModel = viewModel() + val postViewModel: ChatNewMessageViewModel = rememberViewModel { ChatNewMessageViewModel() } postViewModel.init(accountViewModel) - val context = LocalContext.current + val mediaResolver = rememberSharedMediaResolver() LaunchedEffect(postViewModel, accountViewModel) { message?.ifBlank { null }?.let { postViewModel.message.setTextAndPlaceCursorAtEnd(it) postViewModel.onMessageChanged() } - attachment?.let { - withContext(Dispatchers.IO) { - val mediaType = context.contentResolver.getType(it) - postViewModel.pickedMedia(persistentListOf(SelectedMedia(it, mediaType))) - } + mediaResolver.resolve(attachment)?.let { + postViewModel.pickedMedia(persistentListOf(it)) } } @@ -362,7 +347,7 @@ fun MessageFieldRow( postViewModel: IMessageField, accountViewModel: AccountViewModel, requestFocus: Boolean = false, - onContentReceived: ((Uri, String?) -> Unit)? = null, + onContentReceived: ((MediaUri, String?) -> Unit)? = null, ) { Row { BaseUserPicture( @@ -623,13 +608,14 @@ fun ShowImageUploadGallery( ) { val isImage = data.result.mimeTypeBeforeEncryption?.startsWith("image/") == true || RichTextParser.isImageUrl(data.result.url) - if (data.cipher != null) { - accountViewModel.account.encryptionKeyCache.add(data.result.url, data.cipher, data.result.mimeTypeBeforeEncryption) + val cipher = data.cipher + if (cipher != null) { + accountViewModel.account.encryptionKeyCache.add(data.result.url, cipher, data.result.mimeTypeBeforeEncryption) } val content by remember(data) { mutableStateOf<BaseMediaContent>( - if (data.cipher != null) { + if (cipher != null) { if (isImage) { EncryptedMediaUrlImage( url = data.result.url, @@ -641,9 +627,9 @@ fun ShowImageUploadGallery( dim = data.result.fileHeader.dim, uri = null, mimeType = data.result.mimeTypeBeforeEncryption, - encryptionAlgo = data.cipher.name(), - encryptionKey = data.cipher.keyBytes, - encryptionNonce = data.cipher.nonce, + encryptionAlgo = cipher.name(), + encryptionKey = cipher.keyBytes, + encryptionNonce = cipher.nonce, thumbhash = data.result.fileHeader.thumbHash ?.thumbhash, @@ -659,9 +645,9 @@ fun ShowImageUploadGallery( dim = data.result.fileHeader.dim, uri = null, mimeType = data.result.mimeTypeBeforeEncryption, - encryptionAlgo = data.cipher.name(), - encryptionKey = data.cipher.keyBytes, - encryptionNonce = data.cipher.nonce, + encryptionAlgo = cipher.name(), + encryptionKey = cipher.keyBytes, + encryptionNonce = cipher.nonce, thumbhash = data.result.fileHeader.thumbHash ?.thumbhash, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index b8f96867f9..dff74285d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -47,7 +47,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -62,14 +61,19 @@ import com.vitorpamplona.amethyst.commons.resources.this_message_will_disappear_ import com.vitorpamplona.amethyst.commons.resources.upload_without_encryption_warning import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler +import com.vitorpamplona.amethyst.commons.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.upload.RoomChatFileUploadDialog +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.commons.ui.text.onUiThread @@ -86,17 +90,13 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.util.showCount import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation -import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.RoomChatFileUploadDialog -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @Preview @Composable fun PrivateMessageEditFieldRowPreview() { - val channelScreenModel: ChatNewMessageViewModel = viewModel() + val channelScreenModel: ChatNewMessageViewModel = rememberViewModel { ChatNewMessageViewModel() } val accountViewModel = mockAccountViewModel() channelScreenModel.init(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt index ee873c8af1..4e8adec194 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.upload import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt index 97bacf2bca..178a9aeaa7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.upload import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -31,11 +31,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header.RoomNameOnlyDisplay +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel +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.theme.Size34dp import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState @Composable fun RoomChatFileUploadDialog( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt index 65926bac51..00dbfe83e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.upload import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.resources.Res @@ -30,8 +30,8 @@ import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.quartz.utils.ciphers.AESGCM class ChatFileUploader( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt index f7693c91c6..94019958d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.upload import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 9a1a631ef6..b16e5411ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord -import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -49,11 +48,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler import com.vitorpamplona.amethyst.commons.chats.ui.ReplyModeToggle import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton @@ -80,7 +77,12 @@ import com.vitorpamplona.amethyst.commons.resources.concord_typing_one import com.vitorpamplona.amethyst.commons.resources.concord_typing_two import com.vitorpamplona.amethyst.commons.resources.reply_here import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +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.components.rememberLongNotice +import com.vitorpamplona.amethyst.commons.ui.components.rememberShortNotice +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers @@ -89,12 +91,24 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +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.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.concordTimerText +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinnedButton import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinnedMessagesSheet +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordChannelPins +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.send.ConcordNewMessageViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +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.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.EditingMessageBanner +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.toConcordImeta import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer @@ -104,20 +118,6 @@ 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.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.send.ConcordNewMessageViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.EditingMessageBanner -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress @@ -126,6 +126,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -161,7 +162,7 @@ fun ConcordChannelScreen( val channel = remember(account, communityId, channelId) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelId)) } val feedViewModel: ChannelFeedViewModel = - viewModel( + rememberViewModel( key = channel.channelId.toKey() + "ConcordFeedViewModel", factory = ChannelFeedViewModel.Factory(channel, account), ) @@ -185,7 +186,7 @@ fun ConcordChannelScreen( } ConcordBackfillHistoryToWindow(feedViewModel.feedState, history) - val newMessageModel: ConcordNewMessageViewModel = viewModel(key = channel.channelId.toKey() + "ConcordNewMessageViewModel") + val newMessageModel: ConcordNewMessageViewModel = rememberViewModel(key = channel.channelId.toKey() + "ConcordNewMessageViewModel") { ConcordNewMessageViewModel() } newMessageModel.init(accountViewModel) newMessageModel.load(communityId, channelId) @@ -507,7 +508,7 @@ private fun ConcordMessageComposer( ) { val scope = rememberCoroutineScope() val canPost by remember { derivedStateOf { newMessageModel.canPost() } } - val context = LocalContext.current + val shortNotice = rememberShortNotice() // Throttle typing heartbeats to at most one every few seconds while the field is non-empty. val lastTypingSecs = remember(newMessageModel.channelId) { longArrayOf(0L) } @@ -616,7 +617,7 @@ private fun ConcordMessageComposer( onMessageSent() } catch (e: Exception) { launch(Dispatchers.Main) { - Toast.makeText(context, "Failed to send message: ${e.message}", Toast.LENGTH_SHORT).show() + shortNotice.show("Failed to send message: ${e.message}") } } } @@ -646,7 +647,7 @@ private fun ConcordFileUploadDialog( onUpload: suspend () -> Unit, onCancel: () -> Unit, ) { - val context = LocalContext.current + val longNotice = rememberLongNotice() val scope = rememberCoroutineScope() ChatFileUploadDialog( @@ -661,10 +662,10 @@ private fun ConcordFileUploadDialog( ChatFileUploader(accountViewModel.account).justUploadNIP17( viewState = state, onError = { title, message -> - scope.launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() } + scope.launch(Dispatchers.Main) { longNotice.show("$title: $message") } }, onEncryptedUploadError = { title, message -> - scope.launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() } + scope.launch(Dispatchers.Main) { longNotice.show("$title: $message") } }, uploader = accountViewModel.host.mediaUploader, onceUploaded = { uploads -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt index c836316b36..55cd044e91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index 28a39441dd..8dd3f763da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.send +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.send import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText @@ -36,11 +36,11 @@ import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.onUiThread import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.ImmutableList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index e057199a37..63787cd75d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.dal import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.feeds.ChangesFlowFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt similarity index 83% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt index ccf3ffec0c..503397b49a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt @@ -18,14 +18,16 @@ * 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.publicChannels.dal +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.viewmodels.ListChangeFeedViewModel +import kotlin.reflect.KClass class ChannelFeedViewModel( val channel: Channel, @@ -36,6 +38,9 @@ class ChannelFeedViewModel( val account: Account, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun <T : ViewModel> create(modelClass: Class<T>): T = ChannelFeedViewModel(channel, account) as T + override fun <T : ViewModel> create( + modelClass: KClass<T>, + extras: CreationExtras, + ): T = ChannelFeedViewModel(channel, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt index 6cac602664..5eb40aeaee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/ChannelView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/ChannelView.kt similarity index 83% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/ChannelView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/ChannelView.kt index 98b236ba66..8445dcfe4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/ChannelView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/ChannelView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -29,18 +29,19 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId @Composable @@ -73,7 +74,7 @@ private fun PrepareChannelViewModels( nav: INav, ) { val feedViewModel: ChannelFeedViewModel = - viewModel( + rememberViewModel( key = baseChannel.roomId.toKey() + "ChannelFeedViewModel", factory = ChannelFeedViewModel.Factory( @@ -82,7 +83,7 @@ private fun PrepareChannelViewModels( ), ) - val channelScreenModel: ChannelNewMessageViewModel = viewModel() + val channelScreenModel: ChannelNewMessageViewModel = rememberViewModel { ChannelNewMessageViewModel() } channelScreenModel.init(accountViewModel) channelScreenModel.load(baseChannel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt index 1613bbfec7..095bbd946a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -28,8 +28,9 @@ import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.commons.model.cache.LocalCache 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.publicChannels.ephemChat.LoadEphemeralChatChannel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatTopBar import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatTopBar import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/LoadEphemeralChatChannel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/LoadEphemeralChatChannel.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/LoadEphemeralChatChannel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/LoadEphemeralChatChannel.kt index 8673f99314..50124d18fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/LoadEphemeralChatChannel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/LoadEphemeralChatChannel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.commons.model.cache.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt index eb8e58b8ec..f5f8d4c62e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat.header import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt index b34632879d..21a23a28b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat.header import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt index c5f8910f74..35f3dda694 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt index 1650680780..87796afadd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -39,7 +39,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.navigation.routeFor import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.channel_name @@ -48,11 +47,13 @@ import com.vitorpamplona.amethyst.commons.resources.my_awesome_group import com.vitorpamplona.amethyst.commons.resources.relay_chat import com.vitorpamplona.amethyst.commons.resources.relay_chat_explainer import com.vitorpamplona.amethyst.commons.resources.relay_chat_title +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategoryFirstModifier @@ -60,14 +61,13 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory @Composable fun NewEphemeralChatScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val postViewModel: NewEphemeralChatMetaViewModel = viewModel() + val postViewModel: NewEphemeralChatMetaViewModel = rememberViewModel { NewEphemeralChatMetaViewModel() } postViewModel.load(accountViewModel.account) ChannelMetadataScaffold( @@ -81,7 +81,7 @@ fun NewEphemeralChatScreen( @Composable private fun DialogContentPreview() { val accountViewModel = mockAccountViewModel() - val postViewModel: NewEphemeralChatMetaViewModel = viewModel() + val postViewModel: NewEphemeralChatMetaViewModel = rememberViewModel { NewEphemeralChatMetaViewModel() } postViewModel.load(accountViewModel.account) ThemeComparisonColumn { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/ChannelView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/ChannelView.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/ChannelView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/ChannelView.kt index 9b19514b26..b13681eb89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/ChannelView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/ChannelView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -29,19 +29,19 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.LoadPublicChatChannel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow @Composable fun PublicChatChannelView( @@ -73,7 +73,7 @@ fun PrepareChannelViewModels( nav: INav, ) { val feedViewModel: ChannelFeedViewModel = - viewModel( + rememberViewModel( key = baseChannel.idHex + "ChannelFeedViewModel", factory = ChannelFeedViewModel.Factory( @@ -82,7 +82,7 @@ fun PrepareChannelViewModels( ), ) - val channelScreenModel: ChannelNewMessageViewModel = viewModel() + val channelScreenModel: ChannelNewMessageViewModel = rememberViewModel { ChannelNewMessageViewModel() } channelScreenModel.init(accountViewModel) channelScreenModel.load(baseChannel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt index 6f0133c242..635b3dcab4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -31,8 +31,8 @@ import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatTopBar import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatTopBar import com.vitorpamplona.quartz.nip01Core.core.HexKey @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt index 95534cbd1e..dfcdd1aaa4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt index c42a93fa4a..7a0c7fff6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -38,13 +38,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.routeFor import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel @@ -62,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.resources.public_chat_relays_title import com.vitorpamplona.amethyst.commons.resources.public_chat_title import com.vitorpamplona.amethyst.commons.resources.read_only_user import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectSingleFromGallery +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav @@ -69,6 +68,8 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.CreatingTopBar import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.commons.ui.note.LoadPublicChatChannel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.SettingsCategory +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -80,8 +81,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -106,7 +105,7 @@ fun ChannelMetadataScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val postViewModel: ChannelMetadataViewModel = viewModel() + val postViewModel: ChannelMetadataViewModel = rememberViewModel { ChannelMetadataViewModel() } postViewModel.init(accountViewModel) if (channel != null) { @@ -130,7 +129,7 @@ fun ChannelMetadataScreen( @Composable private fun DialogContentPreview() { val accountViewModel = mockAccountViewModel() - val postViewModel: ChannelMetadataViewModel = viewModel() + val postViewModel: ChannelMetadataViewModel = rememberViewModel { ChannelMetadataViewModel() } postViewModel.init(accountViewModel) ThemeComparisonColumn { @@ -298,13 +297,12 @@ private fun Picture( ) }, leadingIcon = { - val context = LocalContext.current SelectSingleFromGallery( isUploading = postViewModel.isUploadingImageForPicture, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(start = 2.dp), ) { - postViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast) + postViewModel.uploadForPicture(it, onError = accountViewModel.toastManager::toast) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt similarity index 60% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt index adac712a2b..49b7c1e079 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata -import android.content.Context import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -31,37 +30,22 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel -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.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer 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.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlin.coroutines.cancellation.CancellationException @Stable class ChannelMetadataViewModel : ViewModel() { @@ -190,13 +174,11 @@ class ChannelMetadataViewModel : ViewModel() { fun uploadForPicture( uri: SelectedMedia, - context: Context, onError: (String, String) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { upload( uri, - context, onUploading = { isUploadingImageForPicture = it }, onUploaded = { channelPicture.value = TextFieldValue(it) }, onError = onError, @@ -206,83 +188,13 @@ class ChannelMetadataViewModel : ViewModel() { private suspend fun upload( galleryUri: SelectedMedia, - context: Context, onUploading: (Boolean) -> Unit, onUploaded: (String) -> Unit, onError: (String, String) -> Unit, ) { - val account = account 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) - } + val url = uploadToDefaultServer(galleryUri, account, accountViewModel.host.mediaUploader, onError) + onUploading(false) + if (url != null) onUploaded(url) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt similarity index 82% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt index 0c469c4510..1b4c4913bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -28,23 +28,23 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.LoadLiveActivityChannel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ShowVideoStreaming +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamGoalHeader +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamTopZappers +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nests.room.lobby.NestJoinCard import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamGoalHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamTopZappers -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.quartz.nip01Core.core.Address @Composable @@ -77,7 +77,7 @@ fun PrepareChannelViewModels( nav: INav, ) { val feedViewModel: ChannelFeedViewModel = - viewModel( + rememberViewModel( key = baseChannel.address.toValue() + "ChannelFeedViewModel", factory = ChannelFeedViewModel.Factory( @@ -86,7 +86,7 @@ fun PrepareChannelViewModels( ), ) - val channelScreenModel: ChannelNewMessageViewModel = viewModel() + val channelScreenModel: ChannelNewMessageViewModel = rememberViewModel { ChannelNewMessageViewModel() } channelScreenModel.init(accountViewModel) channelScreenModel.load(baseChannel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt index 98e6975828..68757072ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -27,12 +27,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.service.OnlineChecker +import com.vitorpamplona.amethyst.commons.service.OnlineStatusCache import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.LoadLiveActivityChannel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -55,7 +55,7 @@ fun LiveActivityChannelScreen( LoadLiveActivityChannel(channelId) { LaunchedEffect(it.info) { it.info?.streaming()?.let { - OnlineChecker.resetIfOfflineToRetry(it) + OnlineStatusCache.resetIfOfflineToRetry(it) } } LiveActivityTopBar(it, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt index 573eca0086..8e51f5ec14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt index ae08a351ba..cb2e3d41cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt index 4d588255fc..3ef5f2ce71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable @@ -47,13 +47,14 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.CreationExtras import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.chat_zap_anonymous +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.UserPicture import com.vitorpamplona.amethyst.commons.ui.note.ZapIcon @@ -64,7 +65,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size24dp import com.vitorpamplona.amethyst.commons.util.showAmountInteger import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.LiveStreamTopZappersViewModel -import java.math.BigDecimal +import com.vitorpamplona.quartz.utils.BigDecimal +import kotlin.reflect.KClass /** * Horizontally-scrollable leaderboard of the top zappers on a live stream. Matches @@ -80,12 +82,15 @@ fun LiveStreamTopZappers( nav: INav, ) { val topZappersVm: LiveStreamTopZappersViewModel = - viewModel( + rememberViewModel( key = "TopZappers-${channel.address.toValue()}", factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun <T : ViewModel> create(modelClass: Class<T>): T = LiveStreamTopZappersViewModel(channel) as T + override fun <T : ViewModel> create( + modelClass: KClass<T>, + extras: CreationExtras, + ): T = LiveStreamTopZappersViewModel(channel) as T }, ) @@ -164,7 +169,7 @@ private fun ZapperPill( } ZapIcon(Size16Modifier, BitcoinOrange) Text( - text = showAmountInteger(BigDecimal.valueOf(entry.totalSats)), + text = showAmountInteger(BigDecimal(entry.totalSats)), style = MaterialTheme.typography.labelMedium, ) Spacer(Modifier.padding(start = 2.dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzTypingIndicator.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzTypingIndicator.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzTypingIndicator.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzTypingIndicator.kt index d5b02fa0ef..ec5b6374e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzTypingIndicator.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzTypingIndicator.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.RepeatMode diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt index b46ce30b80..a0f7f2e91e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.foundation.layout.Column import androidx.compose.material3.AlertDialog diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt index 9e4fe9deb4..5064d4cba3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt index 1b2cb77630..063c1b3d75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -38,7 +38,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatHistorySubAssembler import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.feeds.FeedState @@ -50,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.model.relayGroupChannelLastReadRoute import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_group_invite_only_to_post import com.vitorpamplona.amethyst.commons.resources.relay_group_join_to_post +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers @@ -57,17 +57,17 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.LoadRelayGroupChannel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatHistorySubscription +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatTailSubscription +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatHistorySubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatTailSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip29RelayGroups.GroupId @@ -110,7 +110,7 @@ private fun PrepareChannelViewModels( nav: INav, ) { val feedViewModel: ChannelFeedViewModel = - viewModel( + rememberViewModel( key = baseChannel.groupId.toKey() + "ChannelFeedViewModel", factory = ChannelFeedViewModel.Factory( @@ -119,7 +119,7 @@ private fun PrepareChannelViewModels( ), ) - val channelScreenModel: ChannelNewMessageViewModel = viewModel() + val channelScreenModel: ChannelNewMessageViewModel = rememberViewModel { ChannelNewMessageViewModel() } channelScreenModel.init(accountViewModel) channelScreenModel.load(baseChannel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt index 3ff4589c70..54e7738278 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index e22a0582ec..7817506b77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -63,7 +63,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -104,17 +103,18 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_section_discover import com.vitorpamplona.amethyst.commons.resources.relay_group_section_discovery_desc import com.vitorpamplona.amethyst.commons.resources.relay_group_section_permissions import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelectSingle +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.CreatingTopBar import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName +import com.vitorpamplona.amethyst.commons.ui.note.platform.LocationPreviewMap import com.vitorpamplona.amethyst.commons.ui.platform.GeohashLocationPickerDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.LoadRelayGroupChannel 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.note.creators.location.LocationPreviewMap import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash import com.vitorpamplona.quartz.nip29RelayGroups.GroupId @@ -133,7 +133,7 @@ fun RelayGroupCreateScreen( isForum: Boolean = false, ) { val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return - val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupCreate:$relayUrl") + val viewModel: RelayGroupMetadataViewModel = rememberViewModel(key = "RelayGroupCreate:$relayUrl") { RelayGroupMetadataViewModel() } LaunchedEffect(relay) { viewModel.initCreate(accountViewModel, relay) if (isForum) viewModel.isForum = true @@ -156,7 +156,17 @@ fun RelayGroupCreateScreen( accountViewModel = accountViewModel, nav = nav, nip29Support = nip29Support, - onSuccess = { nav.popUpTo(Route.RelayGroup(viewModel.groupId, relay.url), Route.RelayGroupCreate::class) }, + onSuccess = { + // A new forum opens on its thread list, where its posts live — the same screen the + // workspace list opens a forum on — not on the chat timeline a stream channel uses. + val destination = + if (viewModel.isForum) { + Route.RelayGroupThreads(viewModel.groupId, relay.url) + } else { + Route.RelayGroup(viewModel.groupId, relay.url) + } + nav.popUpTo(destination, Route.RelayGroupCreate::class) + }, ) } @@ -174,7 +184,7 @@ fun RelayGroupEditScreen( ) { val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return val groupId = remember(id, relay) { GroupId(id, relay) } - val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupEdit:${groupId.toKey()}") + val viewModel: RelayGroupMetadataViewModel = rememberViewModel(key = "RelayGroupEdit:${groupId.toKey()}") { RelayGroupMetadataViewModel() } LoadRelayGroupChannel(groupId) { channel -> // Keep the relay-signed metadata fresh while editing so a late load prefills. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index 26c5cbba79..b824ee5a18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf @@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.utils.RandomInstance import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.launch /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMigrationBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMigrationBar.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMigrationBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMigrationBar.kt index 506393d4d8..c07ac9acc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMigrationBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMigrationBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt index dceeb4485d..5787f6b365 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background @@ -81,10 +81,10 @@ import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncIma import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings 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.util.sortedBySnapshot import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt index 055e627de8..5fe0e0c971 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt index cded118fbb..658832f07a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -49,9 +49,9 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.LoadRelayGroupChannel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription import com.vitorpamplona.quartz.nip29RelayGroups.GroupId /** Cap on how many child chips get their own warm-up subscription, to bound relay load. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 15d335c1f0..c59414ce0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -18,10 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup -import android.content.Context -import android.content.Intent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -48,7 +46,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.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -90,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_role_moderator import com.vitorpamplona.amethyst.commons.resources.relay_group_threads_title import com.vitorpamplona.amethyst.commons.resources.remove_from_messages import com.vitorpamplona.amethyst.commons.search.SearchSeed +import com.vitorpamplona.amethyst.commons.ui.components.rememberTextSharer import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SearchTopBarAction import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton @@ -161,8 +159,28 @@ fun RelayGroupTopBar( var showInvite by remember { mutableStateOf(false) } var showJoinCode by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) } - val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) } + // Reactive: a relay is only marked Buzz once its first verified Buzz event lands, which can be + // after this screen opens (e.g. straight from Messages on a cold start). A remembered snapshot + // stayed false for the screen's life, hiding Join and never sharing the profile. + val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle() + val isBuzzRelay = channel.groupId.relayUrl in buzzRelays val huddleLive by observeBuzzHuddleLive(channel, enabled = isBuzzRelay && membership.isMember(), accountViewModel) + // Buzz names members only from profiles on its own relay; make sure mine is there. Follows the + // profile note so a profile that loads after the screen opens (or is edited later) still goes out. + LaunchedEffect(channel.groupId.relayUrl, isBuzzRelay) { + if (!isBuzzRelay) return@LaunchedEffect + accountViewModel.account.userMetadata + .getUserMetadataFlow() + .collect { + try { + accountViewModel.account.relayGroups.shareProfileWithBuzzWorkspace(channel.groupId.relayUrl) + } catch (e: Exception) { + if (e is CancellationException) throw e + // A refused/failed signature must not take down the screen; it retries next time. + Log.w("RelayGroupTopBar", "Could not share the profile with ${channel.groupId.relayUrl.url}", e) + } + } + } TopBarExtensibleWithBackButton( title = { @@ -269,10 +287,15 @@ fun RelayGroupTopBar( style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - } else if (!displayMembership.isMember() && channel.requiresMembershipToPost()) { + } else if (!displayMembership.isMember() && (channel.requiresMembershipToPost() || (isBuzzRelay && !isDm))) { + // A Buzz open channel takes posts from any workspace member, but channel membership + // is still what Buzz lists, counts and offers in its mention picker — its own client + // shows Join here too, so a reader can become a member. FilledTonalButton(onClick = { - // Closed groups need an invite code; open groups join directly. - if (channel.isClosed()) { + // Closed groups need an invite code; open groups join directly. Every Buzz channel + // carries the `closed` tag, so a Buzz OPEN channel (one that doesn't require + // membership to post) joins directly too. + if (channel.isClosed() && channel.requiresMembershipToPost()) { showJoinCode = true } else { requested = true @@ -357,14 +380,14 @@ fun RelayGroupTopBar( ) } if (naddr != null) { - val context = LocalContext.current + val textSharer = rememberTextSharer() val shareLinkTitle = stringRes(Res.string.quick_action_share_browser_link) val shareChooserTitle = stringRes(Res.string.quick_action_share) DropdownMenuItem( text = { Text(stringRes(Res.string.quick_action_share)) }, onClick = { menuOpen = false - shareRelayGroup(context, naddr, shareLinkTitle, shareChooserTitle) + textSharer.share(njumpLink(naddr), shareLinkTitle, shareChooserTitle) }, ) } @@ -418,14 +441,18 @@ fun RelayGroupTopBar( } }, ) - DropdownMenuItem( - text = { Text(stringRes(Res.string.leave), color = MaterialTheme.colorScheme.error) }, - onClick = { - menuOpen = false - accountViewModel.leaveRelayGroup(channel) - if (canPop) nav.popBack() - }, - ) + // A Buzz open channel lets a non-member read and post, but there is nothing to + // leave until they join (the Join button above stands in for it). + if (displayMembership.isMember() || !isBuzzRelay) { + DropdownMenuItem( + text = { Text(stringRes(Res.string.leave), color = MaterialTheme.colorScheme.error) }, + onClick = { + menuOpen = false + accountViewModel.leaveRelayGroup(channel) + if (canPop) nav.popBack() + }, + ) + } // Archive/Unarchive (kind-9002 `archived` tag) — a reversible hide-from-the-sidebar, // Buzz-only and admin-gated like Delete but NOT destructive, so no confirm dialog. // A DM is never archived (it has its own hide), so this is channels/forums only. @@ -527,23 +554,6 @@ private fun DmParticipantTitle( ) } -/** Fire the system share sheet with a njump web link to the group's naddr. */ -private fun shareRelayGroup( - context: Context, - naddr: String, - linkTitle: String, - chooserTitle: String, -) { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, njumpLink(naddr)) - putExtra(Intent.EXTRA_TITLE, linkTitle) - } - context.startActivity(Intent.createChooser(sendIntent, chooserTitle)) -} - /** A small colored pill naming the user's role/status in the group. */ @Composable private fun RoleBadge(membership: RelayGroupMembership) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupChatSubscriptions.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupChatSubscriptions.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupChatSubscriptions.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupChatSubscriptions.kt index d334e18e90..6f08516496 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupChatSubscriptions.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupChatSubscriptions.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelaySubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelaySubscription.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelaySubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelaySubscription.kt index c93a040021..891b8c09b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelaySubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelaySubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt index 539f57af85..a1ac4d3b08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -41,12 +41,12 @@ import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncIma import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.UserPicture import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings +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.HeaderPictureModifier import com.vitorpamplona.amethyst.commons.ui.theme.Size34dp import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState @Composable fun ChannelFileUploadDialog( @@ -135,7 +135,9 @@ fun ChannelFileUploadDialog( onceUploaded = onUpload, ) - accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) + if (state.lockedServer == null) { + accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) + } accountViewModel.account.settings.changeStripLocationOnUpload(state.stripMetadata) }, onCancel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 7acbf11897..50a130b216 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText @@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Channel @@ -45,7 +44,9 @@ import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult +import com.vitorpamplona.amethyst.commons.model.mediaServers.buzzWorkspaceServer import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership @@ -64,7 +65,11 @@ import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.ChatFileUploadState +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 @@ -72,16 +77,13 @@ import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode -import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor +import com.vitorpamplona.quartz.buzz.media.BuzzImeta import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage import com.vitorpamplona.quartz.buzz.stream.BuzzEditTagOverlay import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent import com.vitorpamplona.quartz.buzz.stream.mentions import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo +import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent @@ -112,6 +114,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.notify import com.vitorpamplona.quartz.nip28PublicChat.base.notify import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag @@ -130,13 +133,17 @@ import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.concurrent.ConcurrentSet import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource @Stable open class ChannelNewMessageViewModel : @@ -419,6 +426,7 @@ open class ChannelNewMessageViewModel : // out so the relay accepts the mention. No-op unless this is a Buzz relay group the user // moderates; guarded so a failed add never blocks the message. channel?.let { autoInviteMentionedBuzzMembers(it, pendingBuzzInviteMentions) } + channel?.let { joinBuzzChannelBeforePosting(it) } // A geohash cell with no resolvable relays has nowhere to publish. Bail before cancel() clears // the composer, so the user keeps their text (and draft) to retry rather than losing it silently. @@ -480,10 +488,9 @@ open class ChannelNewMessageViewModel : val mined = withContext(Dispatchers.Default) { // Bitchat mines 8 bits by default; cap the effort so a slow device still sends. - val deadline = System.nanoTime() + 2_000_000_000L - val threads = Runtime.getRuntime().availableProcessors().coerceAtLeast(1) + val started = TimeSource.Monotonic.markNow() runCatching { - PoWMiner.mine(template, pubKeyHex, 8, threads, isActive = { System.nanoTime() < deadline }) + PoWMiner.mine(template, pubKeyHex, 8, account.powMinerWorkers(), isActive = { started.elapsedNow() < 2.seconds }) }.getOrDefault(template) } @@ -506,6 +513,16 @@ open class ChannelNewMessageViewModel : } fun pickedMedia(list: ImmutableList<SelectedMedia>) { + // A Buzz relay only accepts imeta URLs under its own /media/, so a Buzz channel's + // attachments go to the workspace's media server and nowhere else. + val channel = channel + uploadState?.lockServer( + if (channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) { + buzzWorkspaceServer(channel.groupId.relayUrl.url) + } else { + null + }, + ) uploadState?.load(list) } @@ -616,18 +633,44 @@ open class ChannelNewMessageViewModel : } } + // Buzz channels this composer has already tried to join (see [joinBuzzChannelBeforePosting]). + private val buzzJoinAttempted = ConcurrentSet<GroupId>() + + /** + * Posting to a Buzz open channel joins it first. The relay takes a non-member's message there, + * but channel membership is what Buzz lists, counts and offers in its mention picker, and its + * own client requires joining before it lets anyone post — so a poster who never joined was + * invisible on the Buzz side. Best-effort: a failed join must never block the message. + */ + private suspend fun joinBuzzChannelBeforePosting(channel: Channel) { + if (channel !is RelayGroupChannel || !BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) return + if (channel.event?.isBuzzDm() == true) return + if (channel.membershipOf(accountViewModel.account.userProfile().pubkeyHex).isMember()) return + // Once per channel: the relay's roster (39002) lags the join, and every post before it + // lands would otherwise re-send the kind-9021 and re-publish the kind-10009 list. + if (!buzzJoinAttempted.add(channel.groupId)) return + try { + accountViewModel.account.relayGroups.joinRelayGroup(channel) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("BuzzAutoJoin", "Failed to join ${channel.groupId.id} before posting", e) + } + } + // `protected open` so a specialized composer (e.g. the Buzz forum reply) can reuse this whole // rich EditFieldRow but swap only the event it builds for the composed text. protected open suspend fun createTemplate(): EventTemplate<out Event>? { val channel = channel ?: return null val messageText = message.text.toString() + val isBuzzChannel = channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) val tagger = NewMessageTagger( message = messageText, pTags = listOfNotNull(replyTo.value?.author), eTags = listOfNotNull(replyTo.value), dao = accountViewModel, + userMentionsAsNames = isBuzzChannel, ) tagger.run() @@ -639,7 +682,11 @@ open class ChannelNewMessageViewModel : pendingBuzzInviteMentions = tagger.pTags?.filter { it != replyTo.value?.author }.orEmpty() val urls = findURLs(messageText) - val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) + val usedAttachments = + iMetaAttachments.filterIsIn(urls.toSet()).let { + // Buzz refuses the whole message over any imeta key it doesn't know (e.g. `ox`). + if (isBuzzChannel) BuzzImeta.sanitize(it) else it + } val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) val channelRelays = channel.relays() @@ -863,7 +910,8 @@ open class ChannelNewMessageViewModel : val threadRoot = parent?.let { it.event?.tags?.buzzThreadRootForReplyTo(it.idHex) ?: it.idHex } BuzzChatMessage.build( channelId = channel.groupId.id, - content = tagger.message, + // Buzz draws an attachment only where the body links it as `![image](url)`. + content = BuzzImeta.markdownMediaBody(tagger.message, usedAttachments), threadRoot = threadRoot, replyTo = parent?.idHex, // `p` mentions for everyone cited in the body (plus the reply target, which the @@ -1034,9 +1082,7 @@ open class ChannelNewMessageViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -1059,7 +1105,7 @@ open class ChannelNewMessageViewModel : message.insertUrlAtCursor(newElement) } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation override fun locationFlow(): StateFlow<LocationResult> { if (location == null) { @@ -1070,7 +1116,7 @@ open class ChannelNewMessageViewModel : } override fun onCleared() { - Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } + Log.d("Init") { "OnCleared: ${this::class.simpleName}" } } fun updateZapPercentage( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index 3ad55a0c43..fb82bae890 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -54,10 +54,13 @@ import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.reply_here import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler +import com.vitorpamplona.amethyst.commons.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.commons.ui.text.onUiThread @@ -67,9 +70,6 @@ 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.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.FlowPreview diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index ef4b75348f..346b8f7c43 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.rooms import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope @@ -137,9 +137,18 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.commons.ui.platform.rememberConcordImageModel import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings +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.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.marmotGroupPreviewText +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.marmotGroupTitle +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.marmotOtherMembers +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordLeaveDialog +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.AccountPictureModifier @@ -151,15 +160,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer 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.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupPreviewText -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupTitle -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotOtherMembers -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent +import com.vitorpamplona.quartz.buzz.workspace.isBuzzForum import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent @@ -697,8 +699,9 @@ private fun RelayGroupRoomCompose( val authorName by observeUserName(author, accountViewModel) // A Buzz timeline row (system line, huddle/job activity, diff) carries JSON/diff in its // content, so show its human-readable summary — the same text the in-chat row renders — - // rather than "author: {json}". Plain chat messages fall through to the usual framing. - buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${noteEvent.content.take(200)}" + // rather than "author: {json}". Plain chat messages fall through to the usual framing, + // showing the latest edit with mentions as names. + buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${observeChatPreviewText(lastMessage)}" } else { // Event-less placeholder row. Until the channel's `limit = 1` preview REQ settles we cannot // tell an empty channel from one whose newest message simply hasn't arrived, and claiming @@ -810,7 +813,14 @@ fun RelayGroupRow( accountViewModel.settings.autoPlayVideosFlow .collectAsStateWithLifecycle() .value, - onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) }, + onClick = { + // A Buzz forum's posts are its threads; open it there, as the workspace list does. + if (channel.event?.isBuzzForum() == true) { + nav.nav(Route.RelayGroupThreads(channel.groupId.id, channel.groupId.relayUrl.url)) + } else { + nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) + } + }, onLongClick = { menuOpen = true }, ) @@ -1009,7 +1019,7 @@ private fun RelayGroupServerRoomCompose( val authorName by observeUserName(author, accountViewModel) // Buzz timeline rows (system/huddle/job/diff) carry JSON/diff content — summarize them // like the in-chat row instead of printing raw payload; plain chat falls through. - buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${noteEvent.content.take(200)}" + buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${row.newestMessage?.let { observeChatPreviewText(it) } ?: ""}" } else { stringRes(Res.string.relay_group_no_messages_yet) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 8a536c9a44..9e81bd344e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.rooms.feed import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Column @@ -61,12 +61,12 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState import com.vitorpamplona.amethyst.commons.ui.feeds.SaveableFeedContentState 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.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.commons.util.JavaSerializable import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt index 734cd2d143..d99a782b35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.share import com.vitorpamplona.amethyst.commons.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.feeds.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt index dc72134862..1213930ea0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.share import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -45,10 +45,10 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.ShareToDMNav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar +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.DividerThickness import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.ChatroomListFeedView @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index 488b3b7ea2..4fd91708dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -52,7 +52,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.accessibility_send @@ -76,6 +75,7 @@ import com.vitorpamplona.amethyst.commons.ui.actions.uploads.ShowImageUploadGall import com.vitorpamplona.amethyst.commons.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.commons.ui.components.edgeToEdgeDialogProperties 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.ShorterTopAppBar @@ -112,12 +112,7 @@ fun ChatFileUploadDialog( Dialog( onDismissRequest = { onCancel() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), + properties = edgeToEdgeDialogProperties(dismissOnClickOutside = false), ) { SetDialogToEdgeToEdge() Scaffold( @@ -230,18 +225,20 @@ private fun ImageVideoPostChat( ) } - SettingsRow(Res.string.file_server, Res.string.file_server_description) { - TextSpinner( - label = "", - placeholder = - fileServers - .firstOrNull { it.baseUrl == accountViewModel.account.settings.defaultFileServer.baseUrl } - ?.name - ?: fileServers.firstOrNull()?.name - ?: DEFAULT_MEDIA_SERVERS[0].name, - options = fileServerOptions, - onSelect = { fileUploadState.selectedServer = fileServers[it] }, - ) + if (fileUploadState.lockedServer == null) { + SettingsRow(Res.string.file_server, Res.string.file_server_description) { + TextSpinner( + label = "", + placeholder = + fileServers + .firstOrNull { it.baseUrl == accountViewModel.account.settings.defaultFileServer.baseUrl } + ?.name + ?: fileServers.firstOrNull()?.name + ?: DEFAULT_MEDIA_SERVERS[0].name, + options = fileServerOptions, + onSelect = { fileUploadState.selectedServer = fileServers[it] }, + ) + } } if (fileUploadState.multiOrchestrator?.hasCompressible() == true) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt index 2703b316e1..63197eb134 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -43,6 +43,20 @@ class ChatFileUploadState( val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var selectedServer by mutableStateOf(defaultServer) + + /** + * A server this chat must upload to, e.g. a Buzz workspace's own media server, whose relay + * refuses any other image host. While set, the picker is hidden and the choice is not saved + * as the user's default. + */ + var lockedServer by mutableStateOf<ServerName?>(null) + private set + + fun lockServer(server: ServerName?) { + lockedServer = server + selectedServer = server ?: defaultServer + } + var caption by mutableStateOf("") var contentWarning by mutableStateOf(false) @@ -73,7 +87,7 @@ class ChatFileUploadState( multiOrchestrator = null mediaUploadTracker.finishUpload() caption = "" - selectedServer = defaultServer + selectedServer = lockedServer ?: defaultServer encryptFiles = true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt index 129fb8df84..16802c20c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Column @@ -36,10 +36,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose @Composable fun DisplayReplyingToNote( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt index a000abf5ec..587837a70c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/UploadImetas.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/UploadImetas.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/UploadImetas.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/UploadImetas.kt index 17ab40f078..5fbb0f4198 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/UploadImetas.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/utils/UploadImetas.kt @@ -18,10 +18,10 @@ * 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.utils +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.nip92IMeta.IMetaTag diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/AndroidChessAdapter.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/AndroidChessAdapter.kt index 5e39b89c07..326f554e62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/AndroidChessAdapter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.cache.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/ChessGameScreen.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/ChessGameScreen.kt index 183dfb2e18..c55fc092a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess -import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -56,8 +55,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -75,11 +72,13 @@ import com.vitorpamplona.amethyst.commons.resources.chess_game_waiting import com.vitorpamplona.amethyst.commons.resources.chess_loading_game import com.vitorpamplona.amethyst.commons.resources.go_back import com.vitorpamplona.amethyst.commons.resources.relay_settings +import com.vitorpamplona.amethyst.commons.ui.components.rememberWindowViewModel 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.datasource.ChessSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator /** @@ -99,12 +98,10 @@ fun ChessGameScreen( nav: INav, ) { // Scope ViewModel to Activity so it's shared between lobby and game screens - val activity = LocalActivity.current as FragmentActivity val chessViewModel: ChessViewModel = - viewModel( - viewModelStoreOwner = activity, + rememberWindowViewModel( key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}", - factory = ChessViewModelFactory(accountViewModel.account), + factory = ChessViewModelFactory(accountViewModel.account, LocalAppServices.current.appStores), ) val activeGames by chessViewModel.activeGames.collectAsState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/ChessLobbyScreen.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/ChessLobbyScreen.kt index a41afb483a..d4c9b5526d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess -import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -56,8 +55,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -79,12 +76,14 @@ import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.dismiss import com.vitorpamplona.amethyst.commons.resources.relay_settings import com.vitorpamplona.amethyst.commons.resources.route_chess +import com.vitorpamplona.amethyst.commons.ui.components.rememberWindowViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox 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.LocalAppServices +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess.datasource.ChessSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor /** @@ -97,12 +96,10 @@ fun ChessLobbyScreen( nav: INav, ) { // Scope ViewModel to Activity so it's shared between lobby and game screens - val activity = LocalActivity.current as FragmentActivity val chessViewModel: ChessViewModel = - viewModel( - viewModelStoreOwner = activity, + rememberWindowViewModel( key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}", - factory = ChessViewModelFactory(accountViewModel.account), + factory = ChessViewModelFactory(accountViewModel.account, LocalAppServices.current.appStores), ) // Subscribe to chess events when screen is visible diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt index 16af1d0b1f..1f5e16e911 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/ChessViewModelFactory.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/ChessViewModelFactory.kt index ab7d7a646e..8472ec36d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/ChessViewModelFactory.kt @@ -18,15 +18,16 @@ * 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.chess +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.CreationExtras import com.vitorpamplona.amethyst.commons.model.Account -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStore +import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPollingDefaults import com.vitorpamplona.amethyst.commons.nip64Chess.ChessViewModel +import com.vitorpamplona.amethyst.commons.nip64Chess.chessDismissedGamesStore import kotlin.reflect.KClass /** @@ -34,6 +35,7 @@ import kotlin.reflect.KClass */ class ChessViewModelFactory( private val account: Account, + private val appStores: AppPreferenceStores, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun <T : ViewModel> create( @@ -47,7 +49,7 @@ class ChessViewModelFactory( fetcher = AndroidRelayFetcher(account), metadataProvider = AndroidMetadataProvider(), pollingConfig = ChessPollingDefaults.android, - dismissedStorage = ChessDismissedGamesStore(chessDismissedGamesData), + dismissedStorage = appStores.chessDismissedGamesStore(), ) as T } throw IllegalArgumentException("Unknown ViewModel class") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt index c5a072a0fc..d8bfb4390e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/DiscoverScreen.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/DiscoverScreen.kt index b8727ee1e4..625a50034f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.discover import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi @@ -80,13 +80,13 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.FabBottomBarP 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.discover.ChannelCardCompose +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.TabItem import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.TabItem import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/DiscoveryTopBar.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/DiscoveryTopBar.kt index 9035ab5fae..00e5be5b86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/DiscoveryTopBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.discover import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt index 37d199ccb7..7ae3e9c7b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.discover.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 14a038c7db..5f2b9b727a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.discover.nip23LongForm import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border @@ -75,7 +75,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList @@ -101,24 +100,38 @@ import com.vitorpamplona.amethyst.commons.resources.your_article_title import com.vitorpamplona.amethyst.commons.resources.zapraiser import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelectSingle import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler +import com.vitorpamplona.amethyst.commons.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.commons.ui.note.creators.expiration.ExpirationDateButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.expiration.ExpirationDatePicker 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.location.AddGeoHashButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.SecretEmojiRequest +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.creators.zapraiser.AddZapraiserButton import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.ZapRaiserRequest +import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapToButton +import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextPlatform +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ObserveInboxRelayListAndDisplayIfNotFound import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation @@ -128,19 +141,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightPage 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.TakePictureButton -import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton -import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList -import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker -import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest -import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection -import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ObserveInboxRelayListAndDisplayIfNotFound import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf @@ -150,9 +150,9 @@ fun LongFormPostScreen( draftId: HexKey? = null, versionId: HexKey? = null, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { - val postViewModel: LongFormPostViewModel = viewModel() + val postViewModel: LongFormPostViewModel = rememberViewModel { LongFormPostViewModel() } postViewModel.init(accountViewModel) LaunchedEffect(postViewModel, accountViewModel) { @@ -214,7 +214,7 @@ fun LongFormPostScreen( private fun MarkdownPostScreenBody( postViewModel: LongFormPostViewModel, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { val scrollState = rememberScrollState() Column( @@ -387,12 +387,13 @@ private fun MarkdownPostScreenBody( .fillMaxWidth() .padding(Size10dp), ) { - RenderContentAsMarkdown( + LocalRichTextPlatform.current.Markdown( content = postViewModel.message.text.toString(), tags = EmptyTagList, canPreview = true, quotesLeft = 1, backgroundColor = backgroundColor, + callbackUri = null, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 527a64cf98..59ab9d30e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.discover.nip23LongForm import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText @@ -31,7 +31,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.IZapRaiser import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia @@ -61,9 +61,12 @@ import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.IZapField +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor @@ -72,10 +75,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate @@ -116,6 +115,7 @@ import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -686,9 +686,7 @@ class LongFormPostViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -721,7 +719,7 @@ class LongFormPostViewModel : } override fun onCleared() { - Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } + Log.d("Init") { "OnCleared: ${this::class.simpleName}" } } override fun updateZapPercentage( @@ -762,5 +760,5 @@ class LongFormPostViewModel : draftTag.newVersion() } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index b06be8a622..5f03485d4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.discover.nip99Classifieds import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column @@ -39,10 +39,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.commons.resources.Res @@ -55,21 +52,33 @@ import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDial import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.commons.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.commons.ui.note.creators.expiration.ExpirationDateButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.expiration.ExpirationDatePicker 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.location.AddGeoHashButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.GeoHashPostSection +import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.MessageField +import com.vitorpamplona.amethyst.commons.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.SecretEmojiRequest +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.creators.zapraiser.AddZapraiserButton import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.ZapRaiserRequest +import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapToButton import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.onUiThread @@ -78,22 +87,9 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton -import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList -import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker -import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest -import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection -import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField -import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews -import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) @Composable @@ -103,12 +99,11 @@ fun NewProductScreen( quoteId: HexKey? = null, draftId: HexKey? = null, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { - val postViewModel: NewProductViewModel = viewModel() + val postViewModel: NewProductViewModel = rememberViewModel { NewProductViewModel() } postViewModel.init(accountViewModel) - - val context = LocalContext.current + val mediaResolver = rememberSharedMediaResolver() LaunchedEffect(postViewModel, accountViewModel) { postViewModel.reloadRelaySet() @@ -122,11 +117,8 @@ fun NewProductScreen( postViewModel.message.setTextAndPlaceCursorAtEnd(it) postViewModel.onMessageChanged() } - attachment?.ifBlank { null }?.toUri()?.let { - withContext(Dispatchers.IO) { - val mediaType = context.contentResolver.getType(it) - postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) - } + mediaResolver.resolve(attachment)?.let { + postViewModel.selectImage(persistentListOf(it)) } } @@ -285,8 +277,6 @@ private fun NewProductBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { - val context = LocalContext.current - ImageVideoDescription( uris = it, defaultServer = accountViewModel.account.settings.defaultFileServer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index a5b3dfdad5..aa7d6a171b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.discover.nip99Classifieds import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText @@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.PreviewState import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState @@ -58,19 +58,18 @@ import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.IZapField +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +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.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -103,6 +102,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -579,9 +579,7 @@ open class NewProductViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaDescription.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaDescription.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -619,7 +617,7 @@ open class NewProductViewModel : } override fun onCleared() { - Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } + Log.d("Init") { "OnCleared: ${this::class.simpleName}" } } override fun updateZapPercentage( @@ -681,5 +679,5 @@ open class NewProductViewModel : draftTag.newVersion() } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt index 11bc0c490b..42608ab10f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.discover.nip99Classifieds import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Box @@ -79,7 +79,9 @@ import com.vitorpamplona.amethyst.commons.resources.classifieds_location_placeho import com.vitorpamplona.amethyst.commons.resources.classifieds_price import com.vitorpamplona.amethyst.commons.resources.classifieds_title import com.vitorpamplona.amethyst.commons.resources.classifieds_title_placeholder +import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner +import com.vitorpamplona.amethyst.commons.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation @@ -91,8 +93,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.SquaredQuoteBorderModifier import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation -import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag import kotlinx.collections.immutable.toImmutableList diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index 403708c1cc..829d030b12 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.SimpleImage75Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp +import com.vitorpamplona.amethyst.commons.ui.wallet.payInvoice import com.vitorpamplona.amethyst.commons.ui.wallet.rememberWalletAppLauncher import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.lightning.LnInvoiceUtil diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/create/NewGeocacheScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/create/NewGeocacheScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/create/NewGeocacheScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/create/NewGeocacheScreen.kt index d99e892f54..057755356d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/create/NewGeocacheScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/create/NewGeocacheScreen.kt @@ -18,11 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.create +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.create -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -62,6 +59,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -70,7 +68,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -99,16 +96,18 @@ import com.vitorpamplona.amethyst.commons.resources.geocache_owner_qr_warning import com.vitorpamplona.amethyst.commons.resources.geocache_photo_remove import com.vitorpamplona.amethyst.commons.resources.route_edit_geocache import com.vitorpamplona.amethyst.commons.resources.route_new_geocache +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelectSingle import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.geocacheEmoji import com.vitorpamplona.amethyst.commons.ui.note.geocacheLabelRes +import com.vitorpamplona.amethyst.commons.ui.note.platform.LocationPreviewMap import com.vitorpamplona.amethyst.commons.ui.platform.GeohashLocationPickerDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheGeohash import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize @@ -139,7 +138,7 @@ fun NewGeocacheScreen( editPubKeyHex: String? = null, editDTag: String? = null, ) { - val model: NewGeocacheViewModel = viewModel() + val model: NewGeocacheViewModel = rememberViewModel { NewGeocacheViewModel() } val scope = rememberCoroutineScope() remember(editKind, editPubKeyHex, editDTag, prefillGeohash) { @@ -154,10 +153,13 @@ fun NewGeocacheScreen( var pickingLocation by remember { mutableStateOf(false) } - val photoPicker = - rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - if (uri != null) scope.launch { model.uploadImage(uri, null, accountViewModel.host.mediaUploader) } + var pickingPhoto by rememberSaveable { mutableStateOf(false) } + if (pickingPhoto) { + GallerySelectSingle(imagesOnly = true) { media -> + pickingPhoto = false + if (media != null) scope.launch { model.uploadImage(media.uri, media.mimeType, accountViewModel.host.mediaUploader) } } + } Scaffold( topBar = { @@ -285,7 +287,7 @@ fun NewGeocacheScreen( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { OutlinedButton( enabled = !model.isUploading.value, - onClick = { photoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, + onClick = { pickingPhoto = true }, ) { if (model.isUploading.value) { CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/create/NewGeocacheViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/create/NewGeocacheViewModel.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/create/NewGeocacheViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/create/NewGeocacheViewModel.kt index 1c60602098..d869e1fd38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/create/NewGeocacheViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/create/NewGeocacheViewModel.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.create +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.create -import android.net.Uri import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel @@ -28,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader +import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel @@ -175,7 +175,7 @@ class NewGeocacheViewModel : ViewModel() { fun isValid() = hasName() && hasLocation() suspend fun uploadImage( - uri: Uri, + uri: MediaUri, mimeType: String?, uploader: MediaUploader, ): Boolean { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheAddToHuntSheet.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheAddToHuntSheet.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheAddToHuntSheet.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheAddToHuntSheet.kt index 8ab7b184f9..7d98836520 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheAddToHuntSheet.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheAddToHuntSheet.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.detail +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.detail import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheDetailParts.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheDetailParts.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheDetailParts.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheDetailParts.kt index 52e05596e9..be3783dfaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheDetailParts.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheDetailParts.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.detail +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.detail -import android.content.Intent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -40,7 +39,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Size -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -57,6 +55,8 @@ import com.vitorpamplona.amethyst.commons.resources.geocache_log_type_dnf import com.vitorpamplona.amethyst.commons.resources.geocache_log_type_maintenance import com.vitorpamplona.amethyst.commons.resources.geocache_log_type_note import com.vitorpamplona.amethyst.commons.resources.geocache_verified_find +import com.vitorpamplona.amethyst.commons.resources.quick_action_share +import com.vitorpamplona.amethyst.commons.ui.components.rememberTextSharer import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.commons.ui.note.GeocacheChip @@ -162,14 +162,11 @@ fun GeocacheWinnerStrip( /** Shares the cache as `nostr:naddr…`, which is how it opens in another NIP-CC client. */ @Composable fun GeocacheShareAction(address: Address) { - val context = LocalContext.current + val sharer = rememberTextSharer() + val shareStr = stringRes(Res.string.quick_action_share) IconButton(onClick = { val naddr = NAddress.create(address.kind, address.pubKeyHex, address.dTag, null) - val intent = - Intent(Intent.ACTION_SEND) - .setType("text/plain") - .putExtra(Intent.EXTRA_TEXT, "nostr:$naddr") - context.startActivity(Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + sharer.share("nostr:$naddr", null, shareStr) }) { Icon( symbol = MaterialSymbols.AutoMirrored.Send, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheDetailScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheDetailScreen.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheDetailScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheDetailScreen.kt index 5cb6948c20..43e9902e2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheDetailScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheDetailScreen.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.detail +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.detail -import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -64,10 +63,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -98,13 +96,13 @@ import com.vitorpamplona.amethyst.commons.ui.note.GeocacheSpoilerHint import com.vitorpamplona.amethyst.commons.ui.note.forListing import com.vitorpamplona.amethyst.commons.ui.note.geocacheEmoji import com.vitorpamplona.amethyst.commons.ui.note.geocachePoint +import com.vitorpamplona.amethyst.commons.ui.note.platform.LocationPreviewMap import com.vitorpamplona.amethyst.commons.ui.note.rememberGeocachePalette import com.vitorpamplona.amethyst.commons.ui.note.specSummary import com.vitorpamplona.amethyst.commons.ui.note.types.distanceToCache import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.datasource.GeocachesFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier @@ -427,12 +425,11 @@ private fun GeocacheNavigateRow( longitude: Double, name: String, ) { - val context = LocalContext.current + val uriHandler = LocalUriHandler.current OutlinedButton(onClick = { val label = name.ifBlank { "Geocache" } - val uri = "geo:$latitude,$longitude?q=$latitude,$longitude($label)".toUri() runCatching { - context.startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + uriHandler.openUri("geo:$latitude,$longitude?q=$latitude,$longitude($label)") } }) { Icon( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheLogSheet.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheLogSheet.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheLogSheet.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheLogSheet.kt index 87f4ab5b18..70f0b5c12d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheLogSheet.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheLogSheet.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.detail +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.detail import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheOwnerActions.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheOwnerActions.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheOwnerActions.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheOwnerActions.kt index f69a4d5c89..ddbc21f310 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/detail/GeocacheOwnerActions.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/detail/GeocacheOwnerActions.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.detail +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.detail import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt similarity index 74% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt index c5f9554506..ea8ad3d43e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt @@ -18,25 +18,21 @@ * 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.geohash +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geohash import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.LocalContext -import androidx.core.net.toUri -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.nip22Comments.CommentPostViewModel +import com.vitorpamplona.amethyst.commons.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav -import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel -import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext @Composable fun GeoHashPostScreen( @@ -47,12 +43,12 @@ fun GeoHashPostScreen( quoteId: HexKey? = null, draftId: HexKey? = null, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { - val postViewModel: CommentPostViewModel = viewModel() + val postViewModel: CommentPostViewModel = rememberViewModel { CommentPostViewModel() } postViewModel.init(accountViewModel) - val context = LocalContext.current + val mediaResolver = rememberSharedMediaResolver() LaunchedEffect(postViewModel, accountViewModel) { geohash?.let { @@ -71,11 +67,8 @@ fun GeoHashPostScreen( postViewModel.message.setTextAndPlaceCursorAtEnd(it) postViewModel.onMessageChanged() } - attachment?.ifBlank { null }?.toUri()?.let { - withContext(Dispatchers.IO) { - val mediaType = context.contentResolver.getType(it) - postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) - } + mediaResolver.resolve(attachment)?.let { + postViewModel.selectImage(persistentListOf(it)) } if (draftId == null) { postViewModel.applySignature() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt similarity index 74% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt index f82ffcca8b..fcd8259fd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt @@ -18,25 +18,21 @@ * 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.hashtag +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.hashtag import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.LocalContext -import androidx.core.net.toUri -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.nip22Comments.CommentPostViewModel +import com.vitorpamplona.amethyst.commons.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav -import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel -import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext @Composable fun HashtagPostScreen( @@ -47,12 +43,12 @@ fun HashtagPostScreen( quoteId: HexKey? = null, draftId: HexKey? = null, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { - val postViewModel: CommentPostViewModel = viewModel() + val postViewModel: CommentPostViewModel = rememberViewModel { CommentPostViewModel() } postViewModel.init(accountViewModel) - val context = LocalContext.current + val mediaResolver = rememberSharedMediaResolver() LaunchedEffect(postViewModel, accountViewModel) { hashtag?.let { @@ -71,11 +67,8 @@ fun HashtagPostScreen( postViewModel.message.setTextAndPlaceCursorAtEnd(it) postViewModel.onMessageChanged() } - attachment?.ifBlank { null }?.toUri()?.let { - withContext(Dispatchers.IO) { - val mediaType = context.contentResolver.getType(it) - postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) - } + mediaResolver.resolve(attachment)?.let { + postViewModel.selectImage(persistentListOf(it)) } if (draftId == null) { postViewModel.applySignature() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightPostViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/highlights/NewHighlightPostViewModel.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightPostViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/highlights/NewHighlightPostViewModel.kt index 4866a544e4..c1ecea6c86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightPostViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/highlights/NewHighlightPostViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.highlights import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/highlights/NewHighlightScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/highlights/NewHighlightScreen.kt index 8358b47b80..255402c722 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/highlights/NewHighlightScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.highlights import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -60,7 +60,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -71,18 +70,19 @@ import com.vitorpamplona.amethyst.commons.resources.new_highlight_note_placehold import com.vitorpamplona.amethyst.commons.resources.new_highlight_passage_placeholder import com.vitorpamplona.amethyst.commons.resources.new_highlight_source_label import com.vitorpamplona.amethyst.commons.resources.new_highlight_title +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose +import com.vitorpamplona.amethyst.commons.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList +import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.commons.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList -import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField /** A warm highlighter amber — the highlight metaphor reads as yellow regardless of theme. */ private val MarkerAccent = Color(0xFFF5C518) @@ -111,9 +111,9 @@ fun NewHighlightScreen( sourceEventId: String? = null, author: String? = null, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { - val postViewModel: NewHighlightPostViewModel = viewModel() + val postViewModel: NewHighlightPostViewModel = rememberViewModel { NewHighlightPostViewModel() } postViewModel.init(accountViewModel) WatchAndLoadMyEmojiList(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AddOutboxRelayCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/AddOutboxRelayCard.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AddOutboxRelayCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/AddOutboxRelayCard.kt index acc89b1249..a11f3f2a18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AddOutboxRelayCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/AddOutboxRelayCard.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.resources.outbox_relays_not_found import com.vitorpamplona.amethyst.commons.resources.outbox_relays_not_found_description import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.notifications.AddInboxRelayCard import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.BigPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding @@ -52,7 +53,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.AddInboxRelayCard @Preview @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AddOutboxRelayListDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/AddOutboxRelayListDialog.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AddOutboxRelayListDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/AddOutboxRelayListDialog.kt index 34932d3318..5e533cca8d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AddOutboxRelayListDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/AddOutboxRelayListDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -35,20 +35,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.outbox_relays_not_found_editing import com.vitorpamplona.amethyst.commons.resources.outbox_relays_not_found_examples import com.vitorpamplona.amethyst.commons.resources.outbox_relays_title import com.vitorpamplona.amethyst.commons.ui.components.SetDialogToEdgeToEdge +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel 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.nip65.Nip65OutboxRelayList +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.nip65.Nip65RelayListViewModel import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.Nip65OutboxRelayList -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.Nip65RelayListViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -57,7 +57,7 @@ fun AddOutboxRelayListDialog( accountViewModel: AccountViewModel, nav: INav, ) { - val postViewModel: Nip65RelayListViewModel = viewModel() + val postViewModel: Nip65RelayListViewModel = rememberViewModel { Nip65RelayListViewModel() } postViewModel.init(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt index 5ba2457880..e30ced7868 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/HomeScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/HomeScreen.kt index 8ff65412ac..b94874c2e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/HomeScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement @@ -59,7 +59,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.commons.feeds.ChannelFeedState import com.vitorpamplona.amethyst.commons.feeds.FeedContentState @@ -92,8 +91,15 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.zonedDrawerSwipeIfModal import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.backups.BackupConflictCards import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geohash.NewGeoPostButton import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.hashtag.NewHashtagPostButton +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.live.RenderEphemeralBubble +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.live.RenderGeohashBubble +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.live.RenderLiveActivityBubble +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.keyBackup.BackupKeysNudge import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -103,12 +109,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.backups.BackupConflictCards -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderEphemeralBubble -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderGeohashBubble -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderLiveActivityBubble -import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.BackupKeysNudge import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch @@ -334,7 +334,7 @@ fun HomeScreenFloatingButton( when (val filter = list.value) { TopFilter.AroundMe -> { - val location by Amethyst.instance.locationManager.geohashStateFlow + val location by LocalAppServices.current.deviceLocation.geohashStateFlow .collectAsStateWithLifecycle() when (val myLocation = location) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/HomeTopBar.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/HomeTopBar.kt index 0f7d0f8077..a669a4889c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/HomeTopBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/NewNoteButton.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/NewNoteButton.kt index 0a3cf4bb8d..1bccc7efd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/NewNoteButton.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/ShortNotePostScreen.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/ShortNotePostScreen.kt index de50091951..6f26fb38cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -18,10 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home -import android.content.Intent -import android.net.Uri import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable @@ -55,28 +53,21 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.content.IntentCompat -import androidx.core.net.toUri -import androidx.core.util.Consumer import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.BooleanType @@ -110,27 +101,58 @@ import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind import com.vitorpamplona.amethyst.commons.resources.zapraiser import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.mediaServers.FileServerSelectionRow +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.OnIncomingShare import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.UploadProgressIndicator +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.VoiceAnonymizationSection +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler +import com.vitorpamplona.amethyst.commons.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.commons.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose import com.vitorpamplona.amethyst.commons.ui.note.creators.aihelp.AiWritingHelpPanel import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.commons.ui.note.creators.expiration.ExpirationDateButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.expiration.ExpirationDatePicker 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.location.AddGeoHashButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.GeoHashPostSection +import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.MessageField +import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.AudienceFlap +import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.AudienceSheet +import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.rememberAudienceLists +import com.vitorpamplona.amethyst.commons.ui.note.creators.polls.PollOptionsField +import com.vitorpamplona.amethyst.commons.ui.note.creators.pow.PowOverrideButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.commons.ui.note.creators.scheduling.ScheduleAtButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.scheduling.ScheduleAtPicker +import com.vitorpamplona.amethyst.commons.ui.note.creators.scheduling.roundUpToNextQuarterHour import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.SecretEmojiRequest +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.creators.zappolls.ZapPollField import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.AddZapraiserButton import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.ZapRaiserRequest +import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapToButton +import com.vitorpamplona.amethyst.commons.ui.note.platform.MAX_VOICE_RECORD_SECONDS +import com.vitorpamplona.amethyst.commons.ui.note.platform.RecordVoiceButton +import com.vitorpamplona.amethyst.commons.ui.note.platform.VoiceMessagePreview import com.vitorpamplona.amethyst.commons.ui.note.types.ReplyRenderType +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ObserveInboxRelayListAndDisplayIfNotFound +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.onUiThread @@ -146,41 +168,12 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow -import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS -import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton -import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton -import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton -import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator -import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection -import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview -import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.components.getActivity -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList -import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker -import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest -import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection -import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField -import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceFlap -import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceSheet -import com.vitorpamplona.amethyst.ui.note.creators.notify.rememberAudienceLists -import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField -import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton -import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews -import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtPicker -import com.vitorpamplona.amethyst.ui.note.creators.scheduling.roundUpToNextQuarterHour -import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription -import com.vitorpamplona.amethyst.ui.note.creators.zappolls.ZapPollField -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) @Composable @@ -195,15 +188,13 @@ fun ShortNotePostScreen( groupThreadId: HexKey? = null, groupThreadRelayUrl: String? = null, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { - val postViewModel: ShortNotePostViewModel = viewModel() + val postViewModel: ShortNotePostViewModel = rememberViewModel { ShortNotePostViewModel() } postViewModel.init(accountViewModel) postViewModel.setGroupThread(groupThreadId, groupThreadRelayUrl) - val context = LocalContext.current - val activity = context.getActivity() - val scope = rememberCoroutineScope() + val mediaResolver = rememberSharedMediaResolver() val proposeAiImprovements by accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements @@ -211,8 +202,7 @@ fun ShortNotePostScreen( LaunchedEffect(proposeAiImprovements) { if (proposeAiImprovements == BooleanType.ALWAYS) { - // The assistant outlives this Activity inside the ViewModel, so it must not hold it. - postViewModel.initWritingAssistant(context.applicationContext) + postViewModel.initWritingAssistant() } } @@ -227,39 +217,19 @@ fun ShortNotePostScreen( postViewModel.message.setTextAndPlaceCursorAtEnd(it) postViewModel.onMessageChanged() } - attachment?.ifBlank { null }?.toUri()?.let { - withContext(Dispatchers.IO) { - val mediaType = context.contentResolver.getType(it) - postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) - } + mediaResolver.resolve(attachment)?.let { + postViewModel.selectImage(persistentListOf(it)) } if (draftId == null && forkId == null) { postViewModel.applySignature() } } - DisposableEffect(nav, activity) { - // Microsoft's swift key sends Gifs as new actions - val consumer = - Consumer<Intent> { intent -> - if (intent.action == Intent.ACTION_SEND) { - intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }?.let { - postViewModel.addToMessage(it) - } - - // Use the `intent` parameter (the new intent), not activity.intent (the launch intent). - IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri -> - scope.launch(Dispatchers.IO) { - val mediaType = context.contentResolver.getType(uri) - postViewModel.selectImage(persistentListOf(SelectedMedia(uri, mediaType))) - } - } - } - } - - activity.addOnNewIntentListener(consumer) - onDispose { activity.removeOnNewIntentListener(consumer) } - } + // Microsoft's swift key sends Gifs as new actions + OnIncomingShare( + onText = postViewModel::addToMessage, + onMedia = { postViewModel.selectImage(persistentListOf(it)) }, + ) NewPostScreenInner(postViewModel, accountViewModel, nav) } @@ -269,7 +239,7 @@ fun ShortNotePostScreen( internal fun NewPostScreenInner( postViewModel: ShortNotePostViewModel, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { WatchAndLoadMyEmojiList(accountViewModel) @@ -324,7 +294,7 @@ internal fun NewPostScreenInner( private fun NewPostScreenBody( postViewModel: ShortNotePostViewModel, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { val scrollState = rememberScrollState() val audienceLists = rememberAudienceLists(accountViewModel) @@ -567,10 +537,9 @@ private fun NewPostScreenBody( val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService .collectAsStateWithLifecycle() - val savedAccounts by com.vitorpamplona.amethyst.LocalPreferences - .accountsFlow() - .collectAsStateWithLifecycle() - val hasMultipleAccounts = (savedAccounts?.size ?: 0) > 1 + val savedAccounts by accountViewModel.host.savedAccounts + .collectAsStateWithLifecycle(emptySet()) + val hasMultipleAccounts = savedAccounts.size > 1 postViewModel.scheduledForSec?.let { current -> Row( verticalAlignment = CenterVertically, @@ -610,7 +579,6 @@ private fun NewPostScreenBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { - val context = LocalContext.current ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, @@ -772,7 +740,7 @@ private fun NewPostScreenBody( showAlwaysOnPrompt = true } else { postViewModel.scheduledForSec = - roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60) + roundUpToNextQuarterHour(TimeUtils.now() + 60 * 60) } }, ) @@ -794,7 +762,7 @@ private fun NewPostScreenBody( TextButton(onClick = { showAlwaysOnPrompt = false postViewModel.scheduledForSec = - roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60) + roundUpToNextQuarterHour(TimeUtils.now() + 60 * 60) }) { Text(stringRes(Res.string.schedule_post_always_on_prompt_continue)) } @@ -812,7 +780,7 @@ private fun BottomRowActions( if (postViewModel.scheduledForSec != null) { null } else { - roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60) + roundUpToNextQuarterHour(TimeUtils.now() + 60 * 60) } }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/ShortNotePostViewModel.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 629fb3cc2d..261d1d2f9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home -import android.content.Context import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd @@ -33,8 +32,10 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.audio.RecordingResult +import com.vitorpamplona.amethyst.commons.audio.VoiceAnonymizationController import com.vitorpamplona.amethyst.commons.audio.VoicePreset +import com.vitorpamplona.amethyst.commons.model.AMETHYST_CLIENT_TAG_NAME import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.BooleanType @@ -48,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.PreviewState import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia @@ -76,11 +78,14 @@ import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState +import com.vitorpamplona.amethyst.commons.service.uploads.mediaUriOfFile import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.IAudience import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.IZapField +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor @@ -88,14 +93,8 @@ import com.vitorpamplona.amethyst.commons.ui.text.onUiThread import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.text.setTextAndPlaceCursorAtBeginning import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource +import com.vitorpamplona.amethyst.commons.util.platformFileSystem import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState -import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory -import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult -import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController -import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent @@ -180,6 +179,7 @@ import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -188,7 +188,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.util.UUID +import okio.Path +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid enum class UserSuggestionAnchor { MAIN_MESSAGE, @@ -287,7 +289,7 @@ open class ShortNotePostViewModel : // Voice Messages var voiceRecording by mutableStateOf<RecordingResult?>(null) - var voiceLocalFile by mutableStateOf<java.io.File?>(null) + var voiceLocalFile by mutableStateOf<Path?>(null) var isUploadingVoice by mutableStateOf(false) var voiceMetadata by mutableStateOf<AudioMeta?>(null) var voiceSelectedServer by mutableStateOf<ServerName?>(null) @@ -305,9 +307,10 @@ open class ShortNotePostViewModel : error, ) }, + anonymize = { file, preset -> accountViewModel.host.anonymizeVoice(file, preset.name) }, ) - val activeFile: java.io.File? + val activeFile: Path? get() = voiceAnonymization.activeFile(voiceLocalFile) val activeWaveform: List<Float>? @@ -478,9 +481,9 @@ open class ShortNotePostViewModel : accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value == BooleanType.ALWAYS - fun initWritingAssistant(context: Context) { + fun initWritingAssistant() { if (writingAssistant != null) return - writingAssistant = WritingAssistantFactory.create(context) + writingAssistant = accountViewModel.host.createWritingAssistant() ?: return refreshAiStatus() } @@ -1260,21 +1263,23 @@ open class ShortNotePostViewModel : * Runs on the mining queue's scope when PoW is on, so it must not touch * viewModelScope. */ + @OptIn(ExperimentalUuidApi::class) private suspend fun storeScheduledPost( template: EventTemplate<out Event>, extraNotesToBroadcast: List<Event>, publishAtSec: Long, ) { val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) - Amethyst.instance.scheduledPostStore.add( + val store = accountViewModel.host.scheduledPostStore ?: return + store.add( ScheduledPost( - id = UUID.randomUUID().toString(), + id = Uuid.random().toString(), accountPubkey = event.pubKey, signedEventJson = event.toJson(), relayUrls = relays.map { it.url }, extraEventsJson = extras.map { it.toJson() }, publishAtSec = publishAtSec, - createdAtSec = System.currentTimeMillis() / 1000, + createdAtSec = TimeUtils.now(), ), ) } @@ -1570,7 +1575,7 @@ open class ShortNotePostViewModel : val replyingToEvent = originalNote?.event ?: return false return replyingToEvent is TextNoteEvent && replyingToEvent.isNewThread() && - replyingToEvent.isClient(AccountCacheState.CLIENT_TAG_NAME) + replyingToEvent.isClient(AMETHYST_CLIENT_TAG_NAME) } fun upload( @@ -1822,9 +1827,7 @@ open class ShortNotePostViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -1918,14 +1921,13 @@ open class ShortNotePostViewModel : voiceOrchestrator = null } - private fun deleteVoiceLocalFile(toDelete: java.io.File? = voiceLocalFile) { + private fun deleteVoiceLocalFile(toDelete: Path? = voiceLocalFile) { toDelete?.let { file -> try { - if (file.delete()) { - Log.d("ShortNotePostViewModel") { "Deleted voice file: ${file.absolutePath}" } - } + platformFileSystem.delete(file, mustExist = false) + Log.d("ShortNotePostViewModel") { "Deleted voice file: $file" } } catch (e: Exception) { - Log.w("ShortNotePostViewModel", "Failed to delete voice file: ${file.absolutePath}", e) + Log.w("ShortNotePostViewModel", "Failed to delete voice file: $file", e) } } } @@ -1944,7 +1946,7 @@ open class ShortNotePostViewModel : isUploadingVoice = true try { - val uri = android.net.Uri.fromFile(fileToUpload) + val uri = mediaUriOfFile(fileToUpload) val orchestrator = UploadOrchestrator() voiceOrchestrator = orchestrator @@ -1997,7 +1999,7 @@ open class ShortNotePostViewModel : } catch (e: Exception) { onError( uploadErrorTitle, - loadStringRes(Res.string.upload_error_voice_message_exception, e.message ?: e.javaClass.simpleName), + loadStringRes(Res.string.upload_error_voice_message_exception, e.message ?: e::class.simpleName.orEmpty()), ) voiceRecording = null } finally { @@ -2017,7 +2019,7 @@ open class ShortNotePostViewModel : override fun onCleared() { writingAssistant?.close() writingAssistant = null - Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } + Log.d("Init") { "OnCleared: ${this::class.simpleName}" } } override fun updateZapPercentage( @@ -2120,5 +2122,5 @@ open class ShortNotePostViewModel : draftTag.newVersion() } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/VoiceReplyScreen.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/VoiceReplyScreen.kt index f9b5232f26..edd2f1cecb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -38,20 +38,20 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.ui.actions.mediaServers.FileServerSelectionRow +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.UploadProgressIndicator +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.VoiceAnonymizationSection import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel +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.platform.VoiceMessagePreview import com.vitorpamplona.amethyst.commons.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow -import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator -import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection -import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -62,9 +62,9 @@ fun VoiceReplyScreen( duration: Int, amplitudesJson: String, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { - val viewModel: VoiceReplyViewModel = viewModel() + val viewModel: VoiceReplyViewModel = rememberViewModel { VoiceReplyViewModel() } LaunchedEffect(replyToNoteId, recordingFilePath, accountViewModel) { viewModel.init(accountViewModel) @@ -107,7 +107,7 @@ fun VoiceReplyScreen( private fun VoiceReplyScreenBody( viewModel: VoiceReplyViewModel, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { val scrollState = rememberScrollState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/VoiceReplyViewModel.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 850b450647..f10278ecad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -26,7 +26,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.audio.RecordingResult +import com.vitorpamplona.amethyst.commons.audio.VoiceAnonymizationController import com.vitorpamplona.amethyst.commons.audio.VoicePreset import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -41,11 +42,10 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay 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.service.uploads.mediaUriOfFile import com.vitorpamplona.amethyst.commons.ui.loadStringRes -import com.vitorpamplona.amethyst.commons.util.deleteOrWarn +import com.vitorpamplona.amethyst.commons.util.platformFileSystem import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult -import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.tags.markedETags @@ -57,11 +57,13 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -import java.io.File +import okio.Path +import okio.Path.Companion.toPath @Stable class VoiceReplyViewModel : ViewModel() { @@ -70,7 +72,7 @@ class VoiceReplyViewModel : ViewModel() { var replyToNote: Note? by mutableStateOf(null) var voiceRecording: RecordingResult? by mutableStateOf(null) - var voiceLocalFile: File? by mutableStateOf(null) + var voiceLocalFile: Path? by mutableStateOf(null) var voiceMetadata: AudioMeta? by mutableStateOf(null) var voiceSelectedServer: ServerName? by mutableStateOf(null) var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null) @@ -89,9 +91,10 @@ class VoiceReplyViewModel : ViewModel() { ) } }, + anonymize = { file, preset -> accountViewModel.host.anonymizeVoice(file, preset.name) }, ) - val activeFile: File? + val activeFile: Path? get() = voiceAnonymization.activeFile(voiceLocalFile) val activeWaveform: List<Float>? @@ -126,7 +129,7 @@ class VoiceReplyViewModel : ViewModel() { emptyList() } - val file = File(recordingFilePath) + val file = recordingFilePath.toPath() voiceLocalFile = file voiceRecording = RecordingResult( @@ -164,11 +167,10 @@ class VoiceReplyViewModel : ViewModel() { private fun deleteVoiceLocalFile() { voiceLocalFile?.let { file -> try { - if (file.deleteOrWarn("VoiceReplyViewModel", "voice file")) { - Log.d("VoiceReplyViewModel") { "Voice file removed or already gone: ${file.absolutePath}" } - } + platformFileSystem.delete(file, mustExist = false) + Log.d("VoiceReplyViewModel") { "Voice file removed or already gone: $file" } } catch (e: Exception) { - Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e) + Log.w("VoiceReplyViewModel", "Failed to delete voice file: $file", e) } } } @@ -195,7 +197,7 @@ class VoiceReplyViewModel : ViewModel() { try { val result = withContext(Dispatchers.IO) { - val uri = android.net.Uri.fromFile(fileToUpload) + val uri = mediaUriOfFile(fileToUpload) orchestrator.upload( uri = uri, mimeType = recording.mimeType, @@ -213,12 +215,11 @@ class VoiceReplyViewModel : ViewModel() { } catch (e: CancellationException) { Log.w("VoiceReplyViewModel", "User canceled, or ViewModel cleared", e) } catch (e: Exception) { - val appContext = Amethyst.instance.appContext accountViewModel.toastManager.toast( loadStringRes(Res.string.upload_error_title), loadStringRes( Res.string.upload_error_voice_message_exception, - e.message ?: e.javaClass.simpleName, + e.message ?: e::class.simpleName.orEmpty(), ), ) } finally { @@ -237,7 +238,6 @@ class VoiceReplyViewModel : ViewModel() { result: UploadingState, onSuccess: () -> Unit, ) { - val appContext = Amethyst.instance.appContext val uploadErrorTitle = loadStringRes(Res.string.upload_error_title) val uploadVoiceNip95NotSupported = loadStringRes(Res.string.upload_error_voice_message_nip95_not_supported) val uploadVoiceFailed = loadStringRes(Res.string.upload_error_voice_message_failed) @@ -326,6 +326,6 @@ class VoiceReplyViewModel : ViewModel() { override fun onCleared() { cancel() - Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } + Log.d("Init") { "OnCleared: ${this::class.simpleName}" } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt index 06e836f313..17edd3ab3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt index 69badc6e51..be0799f27c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.live import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.size @@ -32,12 +32,12 @@ import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.commons.service.OnlineChecker +import com.vitorpamplona.amethyst.commons.service.isVideoOnline import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.checkVideoIsOnline import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag as MeetingSpaceStatusTag @@ -97,12 +97,12 @@ private suspend fun checkChannelIsOnline( // Check if streaming URL is online, fall back to relay check val streamingUrl = channel.info?.streaming() if (!streamingUrl.isNullOrBlank()) { - accountViewModel.checkVideoIsOnline(streamingUrl) + accountViewModel.httpClientBuilder.isVideoOnline(streamingUrl) } else { // Check relay connection val relayUrl = channel.relayHintUrl() if (relayUrl != null) { - OnlineChecker.isOnline(relayUrl.url, accountViewModel.httpClientBuilder::okHttpClientForVideo) + accountViewModel.httpClientBuilder.isVideoOnline(relayUrl.url) } else { false } @@ -113,7 +113,7 @@ private suspend fun checkChannelIsOnline( is EphemeralChatChannel -> { // Check relay connection for ephemeral chat val relayUrl = channel.roomId.relayUrl - OnlineChecker.isOnline(relayUrl.url, accountViewModel.httpClientBuilder::okHttpClientForVideo) + accountViewModel.httpClientBuilder.isVideoOnline(relayUrl.url) } else -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt index 7c3cdf9c29..978b6fcc71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.live import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderGeohashBubble.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/live/RenderGeohashBubble.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderGeohashBubble.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/live/RenderGeohashBubble.kt index 430fa01877..b3463968cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderGeohashBubble.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/live/RenderGeohashBubble.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.live import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt index 50ea9a4b59..02326a8cf2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.live import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -29,7 +29,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.routeFor @@ -39,9 +38,9 @@ import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.channel.observe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.commons.ui.note.Gallery +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppPlatform import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @Composable @@ -65,7 +64,7 @@ fun RenderLiveActivityBubble( null } } - val context = LocalContext.current + val appPlatform = LocalAppPlatform.current FilledTonalButton( contentPadding = PaddingValues(start = 8.dp, end = 10.dp, bottom = 0.dp, top = 0.dp), onClick = { @@ -75,10 +74,7 @@ fun RenderLiveActivityBubble( val dTag = meetingEvent.address().dTag if (!service.isNullOrBlank() && !endpoint.isNullOrBlank() && dTag.isNotBlank()) { NestBridge.set(accountViewModel) - NestActivity.launch( - context = context, - addressValue = meetingEvent.address().toValue(), - ) + appPlatform.openNestRoom(meetingEvent.address().toValue()) } else { // Fall back to the channel route so the user // still lands somewhere — same as a malformed diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt index fe49ecb469..c5a0a9701c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.nip75Goals import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -44,7 +44,6 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.goal_amount_label import com.vitorpamplona.amethyst.commons.resources.goal_amount_placeholder @@ -59,20 +58,20 @@ import com.vitorpamplona.amethyst.commons.resources.goal_website_label import com.vitorpamplona.amethyst.commons.resources.goal_website_placeholder import com.vitorpamplona.amethyst.commons.resources.new_goal import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav @OptIn(ExperimentalMaterial3Api::class) @Composable fun NewGoalScreen( accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { - val goalViewModel: NewGoalViewModel = viewModel() + val goalViewModel: NewGoalViewModel = rememberViewModel { NewGoalViewModel() } goalViewModel.init(accountViewModel) LaunchedEffect(goalViewModel, accountViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt index 04b437e14c..9b54d5afa9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.nip75Goals import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/BackupKeysNudge.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/keyBackup/BackupKeysNudge.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/BackupKeysNudge.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/keyBackup/BackupKeysNudge.kt index 7dd5fce323..fb275ef11f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/BackupKeysNudge.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/keyBackup/BackupKeysNudge.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.keyBackup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -41,7 +41,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.navigation.Route @@ -58,7 +57,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip19Bech32.toNpub -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch /** @@ -66,7 +65,7 @@ import kotlinx.coroutines.launch * generated accounts that haven't saved their secret key yet. It never blocks * navigation: the user either backs up (navigates to [Route.AccountBackup]) or * confirms they already saved the key. Both actions flip the per-account - * [LocalPreferences.setHasBackedUpKeys] flag so the nudge stops appearing. + * backed-up flag so the nudge stops appearing. */ @Composable fun BackupKeysNudge( @@ -81,19 +80,20 @@ fun BackupKeysNudge( // Seed the reactive flag off a background read. Rendering only proceeds once the // flow resolves, so the observing composable never conditionally calls hooks. - val flow by produceState<MutableStateFlow<Boolean>?>(initialValue = null, key1 = npub) { - value = LocalPreferences.hasBackedUpKeys(npub) + val flow by produceState<StateFlow<Boolean>?>(initialValue = null, key1 = npub) { + value = accountViewModel.host.hasBackedUpKeys(npub) } flow?.let { stateFlow -> - WatchBackupKeysNudge(stateFlow, npub, nav, modifier) + WatchBackupKeysNudge(stateFlow, npub, accountViewModel, nav, modifier) } } @Composable private fun WatchBackupKeysNudge( - stateFlow: MutableStateFlow<Boolean>, + stateFlow: StateFlow<Boolean>, npub: String, + accountViewModel: AccountViewModel, nav: INav, modifier: Modifier, ) { @@ -106,11 +106,11 @@ private fun WatchBackupKeysNudge( onBackupNow = { // Best-effort: opening the backup screen counts as backing up so the // nudge doesn't linger after the user follows through. - scope.launch { LocalPreferences.setHasBackedUpKeys(true, npub) } + scope.launch { accountViewModel.host.setHasBackedUpKeys(npub, true) } nav.nav(Route.AccountBackup) }, onAlreadySaved = { - scope.launch { LocalPreferences.setHasBackedUpKeys(true, npub) } + scope.launch { accountViewModel.host.setHasBackedUpKeys(npub, true) } }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt index 79579f1835..a11ef7505c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.lists.list.metadata import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -36,12 +36,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.about_us import com.vitorpamplona.amethyst.commons.resources.follow_pack_copy_name_label @@ -55,11 +53,13 @@ import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_ import com.vitorpamplona.amethyst.commons.resources.picture_url import com.vitorpamplona.amethyst.commons.resources.read_only_user import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectSingleFromGallery +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe 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.CreatingTopBar 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.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategoryFirstModifier @@ -67,7 +67,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @Composable @@ -76,7 +75,7 @@ fun FollowPackMetadataScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val postViewModel: FollowPackMetadataViewModel = viewModel() + val postViewModel: FollowPackMetadataViewModel = rememberViewModel { FollowPackMetadataViewModel() } postViewModel.init(accountViewModel) if (selectedDTag != null) { @@ -100,7 +99,7 @@ fun FollowPackMetadataScreen( @Composable private fun DialogContentPreview() { val accountViewModel = mockAccountViewModel() - val postViewModel: FollowPackMetadataViewModel = viewModel() + val postViewModel: FollowPackMetadataViewModel = rememberViewModel { FollowPackMetadataViewModel() } postViewModel.init(accountViewModel) ThemeComparisonRow { @@ -248,13 +247,12 @@ private fun Picture( ) }, leadingIcon = { - val context = LocalContext.current SelectSingleFromGallery( isUploading = postViewModel.isUploadingImageForPicture, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(start = 2.dp), ) { - postViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast) + postViewModel.uploadForPicture(it, onError = accountViewModel.toastManager::toast) } }, ) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt new file mode 100644 index 0000000000..3ed2c9821a --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.lists.list.metadata + +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.nip51Lists.followSets.PeopleList +import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +@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, + onError: (String, String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + upload( + uri, + onUploading = { isUploadingImageForPicture = it }, + onUploaded = { picture.value = TextFieldValue(it) }, + onError = onError, + ) + } + } + + private suspend fun upload( + galleryUri: SelectedMedia, + onUploading: (Boolean) -> Unit, + onUploaded: (String) -> Unit, + onError: (String, String) -> Unit, + ) { + onUploading(true) + val url = uploadToDefaultServer(galleryUri, account, accountViewModel.host.mediaUploader, onError) + onUploading(false) + if (url != null) onUploaded(url) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt index 4d2a4b3ecb..5fea3b1f21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.lists.list.metadata import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -36,12 +36,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.about_us import com.vitorpamplona.amethyst.commons.resources.follow_set_copy_name_label @@ -55,11 +53,13 @@ import com.vitorpamplona.amethyst.commons.resources.people_list_title import com.vitorpamplona.amethyst.commons.resources.picture_url import com.vitorpamplona.amethyst.commons.resources.read_only_user import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectSingleFromGallery +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe 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.CreatingTopBar 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.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategoryFirstModifier @@ -67,7 +67,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @Composable @@ -76,7 +75,7 @@ fun PeopleListMetadataScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val postViewModel: PeopleListMetadataViewModel = viewModel() + val postViewModel: PeopleListMetadataViewModel = rememberViewModel { PeopleListMetadataViewModel() } postViewModel.init(accountViewModel) if (selectedDTag != null) { @@ -100,7 +99,7 @@ fun PeopleListMetadataScreen( @Composable private fun DialogContentPreview() { val accountViewModel = mockAccountViewModel() - val postViewModel: PeopleListMetadataViewModel = viewModel() + val postViewModel: PeopleListMetadataViewModel = rememberViewModel { PeopleListMetadataViewModel() } postViewModel.init(accountViewModel) ThemeComparisonRow { @@ -248,13 +247,12 @@ private fun Picture( ) }, leadingIcon = { - val context = LocalContext.current SelectSingleFromGallery( isUploading = postViewModel.isUploadingImageForPicture, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(start = 2.dp), ) { - postViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast) + postViewModel.uploadForPicture(it, onError = accountViewModel.toastManager::toast) } }, ) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt new file mode 100644 index 0000000000..16e5bd53a3 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.lists.list.metadata + +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.nip51Lists.followSets.PeopleList +import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +@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, + onError: (String, String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + upload( + uri, + onUploading = { isUploadingImageForPicture = it }, + onUploaded = { picture.value = TextFieldValue(it) }, + onError = onError, + ) + } + } + + private suspend fun upload( + galleryUri: SelectedMedia, + onUploading: (Boolean) -> Unit, + onUploaded: (String) -> Unit, + onError: (String, String) -> Unit, + ) { + onUploading(true) + val url = uploadToDefaultServer(galleryUri, account, accountViewModel.host.mediaUploader, onError) + onUploading(false) + if (url != null) onUploaded(url) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsFeedLoaded.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/LongsFeedLoaded.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsFeedLoaded.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/LongsFeedLoaded.kt index d52cd64a1d..f7943d17ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsFeedLoaded.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/LongsFeedLoaded.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.longs import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -34,10 +34,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.feeds.FeedState 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.shorts.VideoCardCompose import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.VideoCardCompose import com.vitorpamplona.quartz.nip71Video.VideoEvent @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/LongsScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/LongsScreen.kt index 62afa18f9d..85ea1b6386 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/LongsScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.longs import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -35,8 +35,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.longs.datasource.LongsFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssemblerSubscription @Composable fun LongsScreen( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/LongsTopBar.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/LongsTopBar.kt index 61640fe87f..2777c2ed76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/LongsTopBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.longs import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/NewLongVideoButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/NewLongVideoButton.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/NewLongVideoButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/NewLongVideoButton.kt index 72ac47929c..814fd180d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/NewLongVideoButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/NewLongVideoButton.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.longs import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -41,7 +41,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.VideoPostKind @@ -51,19 +50,21 @@ import com.vitorpamplona.amethyst.commons.resources.new_long_video import com.vitorpamplona.amethyst.commons.resources.record_a_video import com.vitorpamplona.amethyst.commons.resources.upload_image import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.NewMediaModel +import com.vitorpamplona.amethyst.commons.ui.actions.NewMediaView import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelect +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideo +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.painterRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.NewMediaModel -import com.vitorpamplona.amethyst.ui.actions.NewMediaView -import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideo import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -86,7 +87,7 @@ fun NewLongVideoButton( var pickedURIs by remember { mutableStateOf<ImmutableList<SelectedMedia>>(persistentListOf()) } val scope = rememberCoroutineScope() - val postViewModel: NewMediaModel = viewModel() + val postViewModel: NewMediaModel = rememberViewModel { NewMediaModel() } postViewModel.onceUploaded { scope.launch(Dispatchers.IO) { delay(500) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/LongsFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/datasource/LongsFilterAssemblerSubscription.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/LongsFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/datasource/LongsFilterAssemblerSubscription.kt index fedefdccc7..afde8baa53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/LongsFilterAssemblerSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/longs/datasource/LongsFilterAssemblerSubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.longs.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt index ac621ee4df..96f6fe6045 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nests.room.chat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -41,9 +41,12 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.reply_here import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +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.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldBorder @@ -52,9 +55,6 @@ 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.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.FlowPreview diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestFileUploadDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/chat/NestFileUploadDialog.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestFileUploadDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/chat/NestFileUploadDialog.kt index 111e99080d..a23f9921c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestFileUploadDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/chat/NestFileUploadDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nests.room.chat import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -33,10 +33,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.UserPicture +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.theme.Size34dp import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 378d7a8aa3..9b08f7b0ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nests.room.chat import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText @@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -40,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.DraftTagState import com.vitorpamplona.amethyst.commons.model.composer.IExpiration import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState @@ -54,18 +54,17 @@ import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.utils.ChatFileUploadState +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.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState -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.AddressableEvent @@ -98,6 +97,7 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -571,9 +571,7 @@ open class NestNewMessageViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -596,7 +594,7 @@ open class NestNewMessageViewModel : message.insertUrlAtCursor(newElement) } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation override fun locationFlow(): StateFlow<LocationResult> { if (location == null) { @@ -607,7 +605,7 @@ open class NestNewMessageViewModel : } override fun onCleared() { - Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } + Log.d("Init") { "OnCleared: ${this::class.simpleName}" } } fun updateZapPercentage( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt index dbdf4cdf2d..e92a49ede6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nests.room.lobby import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -55,10 +55,8 @@ import androidx.compose.runtime.setValue 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.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -73,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.resources.nest_lobby_host_label import com.vitorpamplona.amethyst.commons.resources.nest_lobby_listeners_count import com.vitorpamplona.amethyst.commons.resources.nest_lobby_no_listeners import com.vitorpamplona.amethyst.commons.resources.nest_lobby_open_action +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor @@ -85,13 +84,13 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.MeetingSpaceClosedFlag import com.vitorpamplona.amethyst.commons.ui.note.types.MeetingSpaceOpenFlag import com.vitorpamplona.amethyst.commons.ui.note.types.MeetingSpacePlannedFlag import com.vitorpamplona.amethyst.commons.ui.note.types.MeetingSpacePrivateFlag +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppPlatform +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.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose -import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity -import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestEditFieldRow -import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestNewMessageViewModel import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent @@ -152,7 +151,7 @@ private fun NestLobbyContent( // so the user gets @-mention picker, file uploads, draft auto-save, // emoji suggestions, and reply preview without joining audio. val nestScreenModel: NestNewMessageViewModel = - viewModel(key = "NestLobby/$roomATag") + rememberViewModel(key = "NestLobby/$roomATag") { NestNewMessageViewModel() } nestScreenModel.init(accountViewModel) nestScreenModel.load(addressableNote) @@ -292,7 +291,7 @@ private fun OpenNestRoomAction( val roomId = event.address().dTag if (serviceBase.isNullOrBlank() || endpoint.isNullOrBlank() || roomId.isBlank()) return - val context = LocalContext.current + val appPlatform = LocalAppPlatform.current // Filled button so the primary CTA is unmistakable; the actions // Row in the AppBar already centers its children vertically, but // we trim the default end padding so the button sits flush with @@ -300,10 +299,7 @@ private fun OpenNestRoomAction( Button( onClick = { NestBridge.set(accountViewModel) - NestActivity.launch( - context = context, - addressValue = event.address().toValue(), - ) + appPlatform.openNestRoom(event.address().toValue()) }, contentPadding = ButtonDefaults.ContentPadding, modifier = Modifier.padding(end = 8.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/AddInboxRelayCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/notifications/AddInboxRelayCard.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/AddInboxRelayCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/notifications/AddInboxRelayCard.kt index 55fc8e2b05..bce955e4c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/AddInboxRelayCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/notifications/AddInboxRelayCard.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.notifications import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/AddInboxRelayListDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/notifications/AddInboxRelayListDialog.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/AddInboxRelayListDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/notifications/AddInboxRelayListDialog.kt index 5b6ac5f244..9bce19575e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/AddInboxRelayListDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/notifications/AddInboxRelayListDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.notifications import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -35,20 +35,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.inbox_relays_not_found_editing import com.vitorpamplona.amethyst.commons.resources.inbox_relays_not_found_examples import com.vitorpamplona.amethyst.commons.resources.inbox_relays_title import com.vitorpamplona.amethyst.commons.ui.components.SetDialogToEdgeToEdge +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel 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.nip65.Nip65InboxRelayList +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.nip65.Nip65RelayListViewModel import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.Nip65InboxRelayList -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.Nip65RelayListViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -57,7 +57,7 @@ fun AddInboxRelayListDialog( accountViewModel: AccountViewModel, nav: INav, ) { - val postViewModel: Nip65RelayListViewModel = viewModel() + val postViewModel: Nip65RelayListViewModel = rememberViewModel { Nip65RelayListViewModel() } postViewModel.init(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index b3b9bce7ae..8c009bb682 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.notifications.publicMessages import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy @@ -50,7 +50,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.commons.resources.Res @@ -60,25 +59,38 @@ import com.vitorpamplona.amethyst.commons.resources.public_message import com.vitorpamplona.amethyst.commons.resources.zapraiser import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.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.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler +import com.vitorpamplona.amethyst.commons.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.commons.ui.note.creators.expiration.ExpirationDateButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.expiration.ExpirationDatePicker import com.vitorpamplona.amethyst.commons.ui.note.creators.invoice.AddLnInvoiceButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.invoice.NewPostInvoiceRequest import com.vitorpamplona.amethyst.commons.ui.note.creators.location.AddGeoHashButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.GeoHashPostSection +import com.vitorpamplona.amethyst.commons.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.SecretEmojiRequest +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.creators.zapraiser.AddZapraiserButton import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.ZapRaiserRequest +import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapToButton import com.vitorpamplona.amethyst.commons.ui.note.types.ReplyRenderType +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.MessageFieldRow import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.commons.ui.text.onUiThread @@ -90,23 +102,11 @@ import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightPa import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier 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.TakePictureButton -import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton -import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList -import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker -import com.vitorpamplona.amethyst.ui.note.creators.invoice.NewPostInvoiceRequest -import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection -import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews -import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.MessageFieldRow import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -118,9 +118,9 @@ fun NewPublicMessageScreen( replyId: HexKey? = null, draftId: HexKey? = null, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { - val postViewModel: NewPublicMessageViewModel = viewModel() + val postViewModel: NewPublicMessageViewModel = rememberViewModel { NewPublicMessageViewModel() } postViewModel.init(accountViewModel) LaunchedEffect(postViewModel, accountViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 4031174597..8dd70edb6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.notifications.publicMessages import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText @@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.PreviewState import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState @@ -60,19 +60,18 @@ import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.commons.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.IZapField +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +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.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -120,6 +119,7 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -643,9 +643,7 @@ class NewPublicMessageViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -673,7 +671,7 @@ class NewPublicMessageViewModel : } override fun onCleared() { - Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } + Log.d("Init") { "OnCleared: ${this::class.simpleName}" } } override fun updateZapPercentage( @@ -715,5 +713,5 @@ class NewPublicMessageViewModel : draftTag.newVersion() } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/NewPictureButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/NewPictureButton.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/NewPictureButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/NewPictureButton.kt index bf80d12fbb..dc735af849 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/NewPictureButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/NewPictureButton.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.pictures import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -41,9 +41,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -52,20 +50,22 @@ import com.vitorpamplona.amethyst.commons.resources.new_picture import com.vitorpamplona.amethyst.commons.resources.take_a_picture import com.vitorpamplona.amethyst.commons.resources.upload_image import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.NewMediaModel +import com.vitorpamplona.amethyst.commons.ui.actions.NewMediaView import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelect +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakePicture +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.painterRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.NewMediaModel -import com.vitorpamplona.amethyst.ui.actions.NewMediaView -import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture -import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -94,7 +94,7 @@ fun NewPictureButton( var pickedURIs by remember { mutableStateOf<ImmutableList<SelectedMedia>>(persistentListOf()) } val scope = rememberCoroutineScope() - val postViewModel: NewMediaModel = viewModel() + val postViewModel: NewMediaModel = rememberViewModel { NewMediaModel() } postViewModel.onceUploaded { scope.launch(Dispatchers.IO) { delay(500) @@ -102,9 +102,9 @@ fun NewPictureButton( } } - val context = LocalContext.current + val mediaResolver = rememberSharedMediaResolver() LaunchedEffect(sharedAttachments) { - resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it } + mediaResolver.resolve(sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it } } if (wantsToPostFromCamera) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/PictureCardCompose.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/PictureCardCompose.kt index cd3a8ed3d5..0d12458cec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.pictures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -50,9 +50,9 @@ import com.vitorpamplona.amethyst.commons.ui.components.collectContentWarningRea import com.vitorpamplona.amethyst.commons.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow +import com.vitorpamplona.amethyst.commons.ui.note.platform.ZoomableContentView +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.collections.immutable.toImmutableList @@ -165,7 +165,7 @@ private fun PictureCardImage( } @Composable -internal fun PictureCardCaption(event: PictureEvent) { +fun PictureCardCaption(event: PictureEvent) { val title = event.title() val content = event.content diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt index 42e436f495..70737df2c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.pictures import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/PicturesScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/PicturesScreen.kt index 5814aabfaa..f1354cfeb7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/PicturesScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.pictures import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -35,8 +35,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.pictures.datasource.PicturesFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssemblerSubscription @Composable fun PicturesScreen( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/PicturesTopBar.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/PicturesTopBar.kt index 27d7412c65..ef13c2663c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/PicturesTopBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.pictures import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssemblerSubscription.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssemblerSubscription.kt index a17d0a403d..3846bc8eef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssemblerSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssemblerSubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.pictures.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastAuthors.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastAuthors.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastAuthors.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastAuthors.kt index 4eaace58e1..2fcecfcf68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastAuthors.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastAuthors.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt index f6a1dd63f6..16c1e827cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -32,7 +32,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -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 @@ -44,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.resources.podcast_season import com.vitorpamplona.amethyst.commons.resources.podcast_season_episode import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.commons.ui.note.platform.PodcastEpisodeAudioPlayer import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeAgoLabels import com.vitorpamplona.amethyst.commons.ui.note.timeAgoWith @@ -51,7 +51,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer import com.vitorpamplona.quartz.podcasts.PodcastEpisode private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp)) @@ -79,7 +78,6 @@ fun PodcastEpisodeListItem( val season = remember(noteEvent) { episode.episodeSeason() } val episodeNumber = remember(noteEvent) { episode.episodeNumber() } - val context = LocalContext.current val timeLabels = rememberTimeAgoLabels() val dateStr = remember(noteEvent, timeLabels) { timeAgoWith(noteEvent.createdAt, timeLabels, prefix = "") } val seasonEpisodeLabel = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastHeader.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastHeader.kt index 16432a19d7..6899f5e005 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastScreen.kt index 83e79a7e3e..e300ea83fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer @@ -41,7 +41,6 @@ import androidx.compose.ui.text.style.TextAlign 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.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User @@ -50,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteEvent import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.podcast_no_episodes import com.vitorpamplona.amethyst.commons.resources.route_podcasts +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel @@ -61,14 +61,14 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.commons.ui.note.types.PodcastBookmarkButton import com.vitorpamplona.amethyst.commons.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.PodcastTrailerListItem +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.dal.OnePodcastFeedViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.OnePodcastFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssemblerSubscription import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent @@ -98,7 +98,7 @@ fun PodcastScreen( } val feedViewModel: OnePodcastFeedViewModel = - viewModel( + rememberViewModel( key = pubkey + "OnePodcastFeedViewModel", factory = OnePodcastFeedViewModel.Factory(pubkey, accountViewModel.account), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt index 2ab7bcab5f..84c25cd819 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -62,7 +62,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.commons.util.showAmountInteger import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent -import java.math.BigDecimal +import com.vitorpamplona.quartz.utils.BigDecimal +import com.vitorpamplona.quartz.utils.toLongValue private val Gold = Color(0xFFFFC300) private val Silver = Color(0xFFB0B7C0) @@ -88,7 +89,7 @@ fun PodcastTopSupporters( note.zaps.mapNotNull { (_, receiptNote) -> val receipt = receiptNote?.event as? ZapReceiptEvent ?: return@mapNotNull null val request = receipt.zapRequest ?: return@mapNotNull null - val sats = receipt.amount()?.toLong() ?: return@mapNotNull null + val sats = receipt.amount()?.toLongValue() ?: return@mapNotNull null // Anon/private zaps carry an `anon` tag; collapse them into the shared bucket. val isAnon = request.tags.any { it.isNotEmpty() && it[0] == "anon" } ZapContribution(receiptNote.idHex, request.pubKey, isAnon, sats) @@ -151,7 +152,7 @@ private fun SupporterRow( ZapIcon(Size16Modifier, BitcoinOrange) Text( - text = showAmountInteger(BigDecimal.valueOf(entry.totalSats)), + text = showAmountInteger(BigDecimal(entry.totalSats)), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt index dbe4d2c532..7b727a48dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.dal import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.feeds.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastFeedViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/dal/OnePodcastFeedViewModel.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastFeedViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/dal/OnePodcastFeedViewModel.kt index 14f1ead528..ec01106027 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastFeedViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/dal/OnePodcastFeedViewModel.kt @@ -18,15 +18,17 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.dal import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.ui.screen.AndroidFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.reflect.KClass @Stable class OnePodcastFeedViewModel( @@ -40,6 +42,9 @@ class OnePodcastFeedViewModel( val account: Account, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun <T : ViewModel> create(modelClass: Class<T>): T = OnePodcastFeedViewModel(podcastPubkey, account) as T + override fun <T : ViewModel> create( + modelClass: KClass<T>, + extras: CreationExtras, + ): T = OnePodcastFeedViewModel(podcastPubkey, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssemblerSubscription.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssemblerSubscription.kt index c60c68521b..79e08319a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssemblerSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssemblerSubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollPostScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/polls/PollPostScreen.kt similarity index 82% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollPostScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/polls/PollPostScreen.kt index 40f7184fbc..5d89b902a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollPostScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/polls/PollPostScreen.kt @@ -18,18 +18,18 @@ * 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.polls +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.polls import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.NewPostScreenInner +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.NewPostScreenInner -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.FlowPreview @@ -39,9 +39,9 @@ fun PollPostScreen( message: String? = null, draftId: HexKey? = null, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { - val postViewModel: ShortNotePostViewModel = viewModel() + val postViewModel: ShortNotePostViewModel = rememberViewModel { ShortNotePostViewModel() } postViewModel.init(accountViewModel) LaunchedEffect(postViewModel, accountViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index 934fc14e28..fe3e644ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.profile.gallery -import androidx.annotation.OptIn import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -35,7 +34,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.media3.common.util.UnstableApi import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent @@ -61,8 +59,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier +import com.vitorpamplona.amethyst.commons.video.isHlsMedia import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent @@ -216,7 +214,6 @@ fun DisplayGalleryAuthorBanner( } } -@OptIn(UnstableApi::class) @Composable fun GalleryContentView( contentList: List<MediaUrlContent>, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt index 0c89d5d432..9c25c74a52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.profile.header -import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -45,7 +44,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow @@ -62,13 +60,15 @@ import com.vitorpamplona.amethyst.commons.resources.copy_to_clipboard import com.vitorpamplona.amethyst.commons.resources.no_wallet_found import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.commons.ui.components.rememberShortNotice import com.vitorpamplona.amethyst.commons.ui.components.util.setText import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.commons.ui.wallet.payBolt12Offer +import com.vitorpamplona.amethyst.commons.ui.wallet.rememberWalletAppLauncher import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.note.payViaBolt12Intent import kotlinx.coroutines.launch @Composable @@ -78,7 +78,8 @@ fun Bolt12OffersDialog( onDismiss: () -> Unit, ) { val noWalletFoundStr = stringRes(Res.string.no_wallet_found) - val context = LocalContext.current + val walletApp = rememberWalletAppLauncher() + val shortNotice = rememberShortNotice() val clipboardManager = LocalClipboard.current val scope = rememberCoroutineScope() @@ -101,22 +102,16 @@ fun Bolt12OffersDialog( onCopy = { scope.launch { clipboardManager.setText(offer) - Toast - .makeText( - context, - loadStringRes(Res.string.copied_to_clipboard), - Toast.LENGTH_SHORT, - ).show() + shortNotice.show(loadStringRes(Res.string.copied_to_clipboard)) } }, onPayViaIntent = { - payViaBolt12Intent( + walletApp.payBolt12Offer( offer = offer, - context = context, - noWalletFoundStr, + noWalletFound = noWalletFoundStr, onPaid = { onDismiss() }, onError = { msg -> - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + shortNotice.show(msg) }, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/AllRelayListScreen.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/AllRelayListScreen.kt index abbf158297..40e505bf97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -40,9 +40,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList @@ -87,10 +85,43 @@ import com.vitorpamplona.amethyst.commons.resources.trusted_section_explainer import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.commons.ui.components.rememberFileSharer +import com.vitorpamplona.amethyst.commons.ui.components.rememberTextSharer +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel 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.blocked.BlockedRelayListViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.blocked.renderBlockedItems +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.broadcast.BroadcastRelayListViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.broadcast.renderBroadcastItems +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayExporter +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayListCollection +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayZipExporter import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.connected.ConnectedRelayListViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.connected.renderConnectedItems +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.dm.DMRelayListViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.dm.renderDMItems +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.feeds.FavoriteRelayListViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.feeds.renderRelayFeedsItems +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.indexer.IndexerRelayListViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.indexer.renderIndexerItems +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.keyPackage.KeyPackageRelayListViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.keyPackage.renderKeyPackageItems +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.local.LocalRelayListViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.local.renderLocalItems +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.nip37.PrivateOutboxRelayListViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.nip37.renderPrivateOutboxItems +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.nip65.Nip65RelayListViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.nip65.renderNip65HomeItems +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.nip65.renderNip65NotifItems +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.proxy.ProxyRelayListViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.proxy.renderProxyItems +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.search.SearchRelayListViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.search.renderSearchItems +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.trusted.TrustedRelayListViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.trusted.renderTrustedItems import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.commons.ui.theme.RowColSpacing @@ -98,36 +129,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategoryFirstWithHorz import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategorySpacingWithHorzBorderModifier import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.BlockedRelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.renderBlockedItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.BroadcastRelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.renderBroadcastItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayExporter -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayListCollection -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayZipExporter -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.ConnectedRelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.renderConnectedItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.DMRelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.renderDMItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.feeds.FavoriteRelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.feeds.renderRelayFeedsItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.IndexerRelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.renderIndexerItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.keyPackage.KeyPackageRelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.keyPackage.renderKeyPackageItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local.LocalRelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local.renderLocalItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37.PrivateOutboxRelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37.renderPrivateOutboxItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.Nip65RelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.renderNip65HomeItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.renderNip65NotifItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy.ProxyRelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy.renderProxyItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.renderSearchItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.trusted.TrustedRelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.trusted.renderTrustedItems import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource @@ -136,19 +137,19 @@ fun AllRelayListScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val dmViewModel: DMRelayListViewModel = viewModel() - val keyPackageViewModel: KeyPackageRelayListViewModel = viewModel() - val nip65ViewModel: Nip65RelayListViewModel = viewModel() - val privateOutboxViewModel: PrivateOutboxRelayListViewModel = viewModel() - val searchViewModel: SearchRelayListViewModel = viewModel() - val blockedViewModel: BlockedRelayListViewModel = viewModel() - val trustedViewModel: TrustedRelayListViewModel = viewModel() - val localViewModel: LocalRelayListViewModel = viewModel() - val connectedViewModel: ConnectedRelayListViewModel = viewModel() - val broadcastViewModel: BroadcastRelayListViewModel = viewModel() - val indexerViewModel: IndexerRelayListViewModel = viewModel() - val proxyViewModel: ProxyRelayListViewModel = viewModel() - val relayFeedsViewModel: FavoriteRelayListViewModel = viewModel() + val dmViewModel: DMRelayListViewModel = rememberViewModel { DMRelayListViewModel() } + val keyPackageViewModel: KeyPackageRelayListViewModel = rememberViewModel { KeyPackageRelayListViewModel() } + val nip65ViewModel: Nip65RelayListViewModel = rememberViewModel { Nip65RelayListViewModel() } + val privateOutboxViewModel: PrivateOutboxRelayListViewModel = rememberViewModel { PrivateOutboxRelayListViewModel() } + val searchViewModel: SearchRelayListViewModel = rememberViewModel { SearchRelayListViewModel() } + val blockedViewModel: BlockedRelayListViewModel = rememberViewModel { BlockedRelayListViewModel() } + val trustedViewModel: TrustedRelayListViewModel = rememberViewModel { TrustedRelayListViewModel() } + val localViewModel: LocalRelayListViewModel = rememberViewModel { LocalRelayListViewModel() } + val connectedViewModel: ConnectedRelayListViewModel = rememberViewModel { ConnectedRelayListViewModel() } + val broadcastViewModel: BroadcastRelayListViewModel = rememberViewModel { BroadcastRelayListViewModel() } + val indexerViewModel: IndexerRelayListViewModel = rememberViewModel { IndexerRelayListViewModel() } + val proxyViewModel: ProxyRelayListViewModel = rememberViewModel { ProxyRelayListViewModel() } + val relayFeedsViewModel: FavoriteRelayListViewModel = rememberViewModel { FavoriteRelayListViewModel() } dmViewModel.init(accountViewModel) keyPackageViewModel.init(accountViewModel) @@ -621,7 +622,8 @@ fun SettingsCategoryWithButton( @Composable fun ExportDropdownMenu(collection: () -> RelayListCollection) { var expanded by remember { mutableStateOf(false) } - val context = LocalContext.current + val textSharer = rememberTextSharer() + val fileSharer = rememberFileSharer() val scope = rememberCoroutineScope() IconButton(onClick = { expanded = true }) { @@ -642,14 +644,14 @@ fun ExportDropdownMenu(collection: () -> RelayListCollection) { text = stringRes(Res.string.export_as_text), ) { expanded = false - scope.launch { RelayExporter(context).export(collection()) } + scope.launch { RelayExporter(textSharer).export(collection()) } } M3ActionRow( icon = MaterialSymbols.FolderZip, text = stringRes(Res.string.export_as_zip), ) { expanded = false - scope.launch { RelayZipExporter(context).export(collection()) } + scope.launch { RelayZipExporter(fileSharer).export(collection()) } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt index 976150bbe1..b7f21fcdf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.blocked import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -33,7 +33,9 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.rememberRelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -41,8 +43,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.HorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @Composable fun BlockedRelayList( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt index abaf3faabf..1d24a52475 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt @@ -18,10 +18,10 @@ * 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.relays.blocked +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.blocked import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt index 127c8df427..78b4907020 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.broadcast import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -33,7 +33,9 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.rememberRelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -41,8 +43,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.HorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @Composable fun BroadcastRelayList( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt index 1c94de0f69..0f211b9c10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt @@ -18,10 +18,10 @@ * 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.relays.broadcast +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.broadcast import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index 43964a6b3a..c9cbac07bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index b91d5bb6fe..cfb3ecf069 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayExporter.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/RelayExporter.kt similarity index 76% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayExporter.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/RelayExporter.kt index 0b05718e60..de849afe4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayExporter.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/RelayExporter.kt @@ -18,35 +18,20 @@ * 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.relays.common +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common -import android.content.Context -import android.content.Intent import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.export_relay_settings import com.vitorpamplona.amethyst.commons.resources.relay_settings +import com.vitorpamplona.amethyst.commons.ui.components.TextSharer import com.vitorpamplona.amethyst.commons.ui.loadStringRes class RelayExporter( - val context: Context, + val sharer: TextSharer, ) { suspend fun export(collection: RelayListCollection) { - val text = buildExportText(collection) - - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, text) - putExtra(Intent.EXTRA_TITLE, loadStringRes(Res.string.export_relay_settings)) - } - - val shareIntent = - Intent.createChooser( - sendIntent, - loadStringRes(Res.string.export_relay_settings), - ) - context.startActivity(shareIntent) + val title = loadStringRes(Res.string.export_relay_settings) + sharer.share(buildExportText(collection), title, title) } suspend fun buildExportText(collection: RelayListCollection): String { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayListCollection.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/RelayListCollection.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayListCollection.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/RelayListCollection.kt index e8e986423e..1e525bbae6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayListCollection.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/RelayListCollection.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.blocked_section @@ -45,7 +45,6 @@ import com.vitorpamplona.amethyst.commons.resources.search_section import com.vitorpamplona.amethyst.commons.resources.search_section_explainer import com.vitorpamplona.amethyst.commons.resources.trusted_section import com.vitorpamplona.amethyst.commons.resources.trusted_section_explainer -import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import org.jetbrains.compose.resources.StringResource data class RelayListCollection( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayZipExporter.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/RelayZipExporter.kt similarity index 50% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayZipExporter.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/RelayZipExporter.kt index 5bb043eb06..b98af43c39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayZipExporter.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/common/RelayZipExporter.kt @@ -18,67 +18,30 @@ * 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.relays.common +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common -import android.content.Context -import android.content.Intent -import androidx.core.content.FileProvider import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.export_relay_settings +import com.vitorpamplona.amethyst.commons.ui.components.FileSharer import com.vitorpamplona.amethyst.commons.ui.loadStringRes -import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo -import java.io.File -import java.io.FileOutputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream +/** Exports each non-empty relay section as `<section>.json` (an array of URLs) in one zip. */ class RelayZipExporter( - val context: Context, + val sharer: FileSharer, ) { suspend fun export(collection: RelayListCollection) { - val zipFile = buildZipFile(collection) - - val uri = - FileProvider.getUriForFile( - context, - "${context.packageName}.provider", - zipFile, - ) - - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "application/zip" - putExtra(Intent.EXTRA_STREAM, uri) - putExtra(Intent.EXTRA_TITLE, loadStringRes(Res.string.export_relay_settings)) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - - val shareIntent = - Intent.createChooser( - sendIntent, - loadStringRes(Res.string.export_relay_settings), - ) - context.startActivity(shareIntent) + sharer.shareTextFilesAsZip( + zipName = "relay_settings.zip", + files = buildFiles(collection), + title = loadStringRes(Res.string.export_relay_settings), + ) } - fun buildZipFile(collection: RelayListCollection): File { - val zipFile = File(context.cacheDir, "relay_settings.zip") - - ZipOutputStream(FileOutputStream(zipFile)).use { zip -> - collection.sections().forEach { section -> - if (section.relays.isNotEmpty()) { - val json = buildJsonArray(section.relays) - zip.putNextEntry(ZipEntry("${section.fileName}.json")) - zip.write(json.toByteArray()) - zip.closeEntry() - } - } + fun buildFiles(collection: RelayListCollection): List<Pair<String, String>> = + collection.sections().mapNotNull { section -> + if (section.relays.isEmpty()) null else "${section.fileName}.json" to buildJsonArray(section.relays) } - return zipFile - } - private fun buildJsonArray(relays: List<BasicRelaySetupInfo>): String { val builder = StringBuilder() builder.appendLine("[") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt index b926c58619..ac8b50d5c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.connected import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row @@ -41,13 +41,13 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.active_subs_title import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @Composable fun ConnectedRelayList( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt index 23092ef734..378e6c75db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.connected import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -38,7 +38,7 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { val reqs = account.client.activeRequests(it) val users = mutableSetOf<HexKey>() - reqs.forEach { _, filters -> + reqs.forEach { (_, filters) -> filters.forEach { filter -> val isReportFilter = filter.kinds?.size == 1 && filter.kinds?.firstOrNull() == ReportEvent.KIND if (!isReportFilter) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt index fa587725a0..be1681d4bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.dm import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -36,7 +36,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.default_relays_longer @@ -44,9 +43,12 @@ import com.vitorpamplona.amethyst.commons.resources.dm_relays_not_found_editing import com.vitorpamplona.amethyst.commons.resources.dm_relays_not_found_examples2 import com.vitorpamplona.amethyst.commons.resources.dm_relays_title import com.vitorpamplona.amethyst.commons.ui.components.SetDialogToEdgeToEdge +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel 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.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.dm.DMRelayList +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.dm.DMRelayListViewModel import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier @@ -59,7 +61,7 @@ fun AddDMRelayListDialog( accountViewModel: AccountViewModel, nav: INav, ) { - val postViewModel: DMRelayListViewModel = viewModel() + val postViewModel: DMRelayListViewModel = rememberViewModel { DMRelayListViewModel() } postViewModel.init(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/dm/DMRelayListView.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/dm/DMRelayListView.kt index 8b5680b539..9700f3f3aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/dm/DMRelayListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.dm import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -34,7 +34,9 @@ import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.rememberRelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -42,8 +44,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.HorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt index d0f5ac593c..b120e0676b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt @@ -18,13 +18,13 @@ * 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.relays.dm +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.dm import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relays.ui.CountFilter import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.dms -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/FavoriteRelayListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/feeds/FavoriteRelayListViewModel.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/FavoriteRelayListViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/feeds/FavoriteRelayListViewModel.kt index 91fd7cf9a6..e80ed850f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/FavoriteRelayListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/feeds/FavoriteRelayListViewModel.kt @@ -18,10 +18,10 @@ * 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.relays.feeds +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.feeds import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/feeds/RelayFeedsListView.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/feeds/RelayFeedsListView.kt index 85949dcc17..0418f5ce35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/feeds/RelayFeedsListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.feeds +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.feeds import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -33,7 +33,9 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.rememberRelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -41,8 +43,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.HorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @Composable fun FavoriteRelayList( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt index 5360bd9692..5a575c1129 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.indexer import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -34,7 +34,9 @@ import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.rememberRelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -42,8 +44,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.HorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt index d5325d8e91..276295ba75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt @@ -18,14 +18,14 @@ * 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.relays.indexer +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.indexer import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relays.ui.CountFilter import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.profiles import com.vitorpamplona.amethyst.commons.resources.relay_settings_lower2 -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/keyPackage/KeyPackageRelayListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/keyPackage/KeyPackageRelayListView.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/keyPackage/KeyPackageRelayListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/keyPackage/KeyPackageRelayListView.kt index 8b6e3ad4a5..c810bdc9ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/keyPackage/KeyPackageRelayListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/keyPackage/KeyPackageRelayListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.keyPackage +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.keyPackage import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -34,7 +34,9 @@ import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.rememberRelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -42,8 +44,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.HorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/keyPackage/KeyPackageRelayListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/keyPackage/KeyPackageRelayListViewModel.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/keyPackage/KeyPackageRelayListViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/keyPackage/KeyPackageRelayListViewModel.kt index f39543adc6..eef3dd2c06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/keyPackage/KeyPackageRelayListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/keyPackage/KeyPackageRelayListViewModel.kt @@ -18,13 +18,13 @@ * 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.relays.keyPackage +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.keyPackage import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relays.ui.CountFilter import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.keypackages -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/local/LocalRelayListView.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/local/LocalRelayListView.kt index 3580d9c981..43a923af7e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/local/LocalRelayListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.local import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -37,7 +37,9 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.send_kind0_to_local_relay_description import com.vitorpamplona.amethyst.commons.resources.send_kind0_to_local_relay_title import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsRow @@ -47,8 +49,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategorySpacingWithHorzBorderModifier import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @Composable fun LocalRelayList( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt index 0a4df4eef5..460508c7dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt @@ -18,10 +18,10 @@ * 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.relays.local +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.local import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt index 05e0fa25a9..5bb3f2cbbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37 +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.nip37 import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -34,7 +34,9 @@ import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.rememberRelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -42,8 +44,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.HorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index 58cdb381ec..5b61a847ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt @@ -18,13 +18,13 @@ * 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.relays.nip37 +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.nip37 import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relays.ui.CountFilter import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.events_from_you -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt index 36e17be0e1..5cf4e6f315 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65 +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.nip65 import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -34,7 +34,9 @@ import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.rememberRelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -42,8 +44,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.HorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index 5f7fa37608..328da7d207 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65 +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.nip65 import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt index bb6f268bb5..2bfb6edb0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.proxy import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -34,7 +34,9 @@ import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.rememberRelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -42,8 +44,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.HorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt index e2a0f13596..8215c71019 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt @@ -18,10 +18,10 @@ * 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.relays.proxy +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.proxy import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/search/SearchRelayListView.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/search/SearchRelayListView.kt index 0b7cdd27a1..cf18b99bbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/search/SearchRelayListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.search import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -34,7 +34,9 @@ import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.rememberRelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -42,8 +44,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.HorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index a40047e6b5..8386043f83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -18,13 +18,13 @@ * 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.relays.search +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.search import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relays.ui.CountFilter import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.searchable_events -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt index 488797bcfc..d68b7e4634 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.subscriptions import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background @@ -57,7 +57,6 @@ import androidx.compose.ui.text.font.FontWeight 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.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.nip11RelayInfo.loadRelayInfo @@ -90,20 +89,21 @@ import com.vitorpamplona.amethyst.commons.resources.active_subs_unattributed import com.vitorpamplona.amethyst.commons.resources.active_subs_untagged import com.vitorpamplona.amethyst.commons.resources.marmot_group_fallback_name import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName import com.vitorpamplona.amethyst.commons.ui.platform.rememberConcordImageModel import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.RelayIconFilter import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.ui.theme.warningColor import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -125,7 +125,7 @@ import kotlin.math.roundToInt fun ActiveSubscriptionsScreen( accountViewModel: AccountViewModel, nav: INav, - viewModel: ActiveSubscriptionsViewModel = viewModel(), + viewModel: ActiveSubscriptionsViewModel = rememberViewModel { ActiveSubscriptionsViewModel() }, ) { LaunchedEffect(Unit) { viewModel.pollWhileShown(accountViewModel.account.client) } val state by viewModel.state.collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt index 5e79ff23c3..359c897119 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.subscriptions import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt index c96d2c8707..176ee2f50f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.trusted +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.trusted import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -33,7 +33,9 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.rememberRelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding @@ -41,8 +43,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.HorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @Composable fun TrustedRelayList( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt index c6b46496e7..0de2ebc3f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt @@ -18,10 +18,10 @@ * 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.relays.trusted +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.trusted import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt index 6c2b5e7171..0281177223 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.vanish -import android.text.format.DateFormat import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -59,7 +58,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.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -69,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11Fetcher import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.confirm @@ -89,12 +88,15 @@ import com.vitorpamplona.amethyst.commons.resources.vanish_select_date import com.vitorpamplona.amethyst.commons.resources.vanish_select_time import com.vitorpamplona.amethyst.commons.resources.vanish_send_request import com.vitorpamplona.amethyst.commons.resources.vanish_target_relay +import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer 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.note.DateTimeStyle import com.vitorpamplona.amethyst.commons.ui.note.formatDateTime +import com.vitorpamplona.amethyst.commons.ui.note.rememberIs24HourClock +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -103,14 +105,10 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding 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.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableList -import java.time.Instant -import java.time.ZoneId -import java.time.ZoneOffset @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -149,14 +147,13 @@ fun RequestToVanishScreen( initialSelectedDateMillis = vanishDate * 1000, ) - val currentTime = Instant.ofEpochMilli(vanishDate * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() - - val context = LocalContext.current + // The vanish time as a local wall-clock time: seconds since the epoch shifted by this zone's offset. + val localSecondOfDay = (vanishDate + LocalClock.utcOffsetSeconds(vanishDate)).mod(TimeUtils.ONE_DAY) val timePickerState = rememberTimePickerState( - initialHour = currentTime.hour, - initialMinute = currentTime.minute, - is24Hour = DateFormat.is24HourFormat(context), + initialHour = localSecondOfDay / TimeUtils.ONE_HOUR, + initialMinute = (localSecondOfDay % TimeUtils.ONE_HOUR) / TimeUtils.ONE_MINUTE, + is24Hour = rememberIs24HourClock(), ) val relayOptions = @@ -387,9 +384,7 @@ fun RequestToVanishScreen( (timePickerState.minute * TimeUtils.ONE_MINUTE) } ?: TimeUtils.now() - val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) - - vanishDate = datetimeLocalTimeZone - offset.totalSeconds + vanishDate = datetimeLocalTimeZone - LocalClock.utcOffsetSeconds(TimeUtils.now()) showTimePicker = false }, @@ -487,7 +482,7 @@ private fun ConfirmVanishDialog( fun RequestToVanishScreenPreview() { ThemeComparisonColumn { RequestToVanishScreen( - nip11CachedRetriever = Nip11CachedRetriever { TODO() }, + nip11CachedRetriever = Nip11CachedRetriever(Nip11Fetcher.Offline), accountViewModel = mockAccountViewModel(), nav = EmptyNav(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt index 5adaf1521c..29ef722364 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.search +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.search import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt index bb8fe5a062..49385ba613 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.search +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.search import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -36,7 +36,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.default_relays_longer @@ -44,15 +43,16 @@ import com.vitorpamplona.amethyst.commons.resources.search_relays_not_found_edit import com.vitorpamplona.amethyst.commons.resources.search_relays_not_found_examples import com.vitorpamplona.amethyst.commons.resources.search_relays_title import com.vitorpamplona.amethyst.commons.ui.components.SetDialogToEdgeToEdge +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel 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.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.search.SearchRelayList +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.search.SearchRelayListViewModel import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayList -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayListViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @OptIn(ExperimentalMaterial3Api::class) @@ -62,7 +62,7 @@ fun AddSearchRelayListDialog( accountViewModel: AccountViewModel, nav: INav, ) { - val postViewModel: SearchRelayListViewModel = viewModel() + val postViewModel: SearchRelayListViewModel = rememberViewModel { SearchRelayListViewModel() } postViewModel.init(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/search/SearchBarViewModel.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/search/SearchBarViewModel.kt index 57280cf21e..b6e66d4ec9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.search +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.search import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Stable @@ -30,6 +30,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent import com.vitorpamplona.amethyst.commons.model.Account @@ -74,9 +75,11 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Rfc3986 import com.vitorpamplona.quartz.utils.startsWithAny +import com.vitorpamplona.quartz.utils.toDoubleValue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -97,6 +100,7 @@ import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlin.reflect.KClass @Stable @OptIn(FlowPreview::class) @@ -464,7 +468,7 @@ class SearchBarViewModel( query = parsed, order = order, event = { it.event }, - zapTotal = { it.zapsAmount.toDouble() }, + zapTotal = { it.zapsAmount.toDoubleValue() }, ) }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) @@ -688,6 +692,9 @@ class SearchBarViewModel( val initialQuery: String? = null, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchBarViewModel(account, nip05, historyStorage, initialQuery) as T + override fun <T : ViewModel> create( + modelClass: KClass<T>, + extras: CreationExtras, + ): T = SearchBarViewModel(account, nip05, historyStorage, initialQuery) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/search/SearchScreen.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/search/SearchScreen.kt index 2e0c06fa66..fd63b492d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/search/SearchScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.search +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.search import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -78,14 +78,13 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign 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.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.routeFor import com.vitorpamplona.amethyst.commons.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.commons.relayClient.searchCommand.TextSearchDataSourceSubscription import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.ephemeral_relay_chat import com.vitorpamplona.amethyst.commons.resources.npub_hex_username @@ -112,12 +111,13 @@ import com.vitorpamplona.amethyst.commons.resources.search_source_relays import com.vitorpamplona.amethyst.commons.resources.search_type_to_begin import com.vitorpamplona.amethyst.commons.resources.search_type_to_begin_explainer import com.vitorpamplona.amethyst.commons.resources.search_waiting_on_relays -import com.vitorpamplona.amethyst.commons.search.DataStoreSearchHistoryStorage import com.vitorpamplona.amethyst.commons.search.QuerySerializer import com.vitorpamplona.amethyst.commons.search.SearchScope import com.vitorpamplona.amethyst.commons.search.SearchSortOrder import com.vitorpamplona.amethyst.commons.search.SearchSource +import com.vitorpamplona.amethyst.commons.search.searchHistoryStorage import com.vitorpamplona.amethyst.commons.ui.components.namecoin.NamecoinResolutionRow +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding @@ -130,7 +130,9 @@ import com.vitorpamplona.amethyst.commons.ui.note.UserCompose 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.platform.AppBottomBar +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.rooms.ChannelName import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoClickableRow import com.vitorpamplona.amethyst.commons.ui.search.GroupCandidate import com.vitorpamplona.amethyst.commons.ui.search.SEARCH_PICKER_LIMIT @@ -145,13 +147,11 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp import com.vitorpamplona.amethyst.commons.ui.theme.StdTopPadding import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations -import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch @@ -168,9 +168,10 @@ fun SearchScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val historyStorage = remember { DataStoreSearchHistoryStorage(Amethyst.instance.appStores.getDataStore(DataStoreSearchHistoryStorage.FILE_NAME)) } + val appStores = LocalAppServices.current.appStores + val historyStorage = remember { appStores.searchHistoryStorage() } val searchBarViewModel: SearchBarViewModel = - viewModel( + rememberViewModel( // Keyed on the seed: navigating from one screen's search button to another's has to // build a model holding that screen's filter, not hand back the previous one. key = "SearchBarViewModel${initialQuery?.let { " $it" }.orEmpty()}", @@ -583,6 +584,7 @@ private fun SearchTextField( // fields rather than search terms. The value stays the plain text, so a query can still // be copied out and pasted back. val fieldState = remember { SearchFieldState(searchBarViewModel.searchValue) } + val appServices = LocalAppServices.current // Reported up rather than read down: the scaffold that has to stop moving is composed // above this field, and it is the only thing that can keep a picker's scroll off the bars. @@ -649,7 +651,7 @@ private fun SearchTextField( groupName = { groupNames[it] }, // The same reverse-geocode cache the feed spinner and thread view already read // through LoadCityName; null until it resolves, which leaves the geohash showing. - scopeName = { field, value -> if (field == "geo") CachedReversedGeoLocations.cached(value) else null }, + scopeName = { field, value -> if (field == "geo") appServices.cachedPlaceName(value) else null }, groups = groupCandidates, onPeopleQuery = { userSuggestions.processCurrentWord(it) }, onGroupQuery = { partial -> groupQuery = partial }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt index d6067bc225..a69dce7b8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -39,7 +39,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -96,6 +95,9 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.pow.POW_PRESETS +import com.vitorpamplona.amethyst.commons.ui.pow.deviceHashesPerSecond +import com.vitorpamplona.amethyst.commons.ui.pow.loadApproxDuration import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsBlockTile import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsControlRow import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsDivider @@ -106,10 +108,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes 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.service.ai.WritingAssistantFactory -import com.vitorpamplona.amethyst.service.pow.deviceHashesPerSecond -import com.vitorpamplona.amethyst.service.pow.loadApproxDuration -import com.vitorpamplona.amethyst.ui.note.creators.pow.POW_PRESETS import kotlinx.coroutines.flow.MutableStateFlow import org.jetbrains.compose.resources.StringResource @@ -153,7 +151,7 @@ fun ComposeSettingsContent( ) SettingsDivider() // The F-Droid build ships no on-device model, so the toggle would do nothing there. - if (WritingAssistantFactory.IS_SUPPORTED) { + if (accountViewModel.host.supportsWritingAssistant) { BooleanSwitchTile( flow = sharedPrefs.automaticallyProposeAiImprovements, icon = MaterialSymbols.AutoAwesome, @@ -246,7 +244,6 @@ private fun PowDifficultyTile(accountViewModel: AccountViewModel) { private fun PowTimeEstimate(difficulty: Int) { if (difficulty <= 0) return - val context = LocalContext.current val estimate by produceState<String?>(initialValue = null, difficulty) { val rate = deviceHashesPerSecond() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NIP47SetupScreen.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NIP47SetupScreen.kt index 111c7bf346..b1bb92d8d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NIP47SetupScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -40,7 +40,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -50,8 +49,14 @@ import com.vitorpamplona.amethyst.commons.resources.no_payment_targets_message import com.vitorpamplona.amethyst.commons.resources.payment_targets import com.vitorpamplona.amethyst.commons.resources.payment_targets_section_explainer import com.vitorpamplona.amethyst.commons.resources.wallet_connect +import com.vitorpamplona.amethyst.commons.ui.actions.paymentTargets.PaymentTargetAddField +import com.vitorpamplona.amethyst.commons.ui.actions.paymentTargets.PaymentTargetsViewModel +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.commons.ui.note.UpdateZapAmountContent +import com.vitorpamplona.amethyst.commons.ui.note.UpdateZapAmountViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.WalletViewModel import com.vitorpamplona.amethyst.commons.ui.settings.SettingsCategory import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -59,11 +64,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategorySpacingModifi 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.paymentTargets.PaymentTargetAddField -import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsViewModel -import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountContent -import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget @Composable @@ -72,13 +72,13 @@ fun NIP47SetupScreen( nav: INav, nip47: String?, ) { - val postViewModel: UpdateZapAmountViewModel = viewModel() + val postViewModel: UpdateZapAmountViewModel = rememberViewModel { UpdateZapAmountViewModel() } postViewModel.init(accountViewModel) - val walletViewModel: WalletViewModel = viewModel() + val walletViewModel: WalletViewModel = rememberViewModel { WalletViewModel() } walletViewModel.init(accountViewModel) - val paymentTargetsViewModel: PaymentTargetsViewModel = viewModel() + val paymentTargetsViewModel: PaymentTargetsViewModel = rememberViewModel { PaymentTargetsViewModel() } paymentTargetsViewModel.init(accountViewModel) LaunchedEffect(accountViewModel, postViewModel) { diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsScreen.kt new file mode 100644 index 0000000000..8dc824086f --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsScreen.kt @@ -0,0 +1,559 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons +import com.vitorpamplona.amethyst.commons.hashtags.Tunestr +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingAccess +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState +import com.vitorpamplona.amethyst.commons.relayClient.event.observeNote +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserStatuses +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.now_playing_access_granted +import com.vitorpamplona.amethyst.commons.resources.now_playing_access_needed +import com.vitorpamplona.amethyst.commons.resources.now_playing_access_needed_title +import com.vitorpamplona.amethyst.commons.resources.now_playing_apps +import com.vitorpamplona.amethyst.commons.resources.now_playing_apps_empty +import com.vitorpamplona.amethyst.commons.resources.now_playing_description +import com.vitorpamplona.amethyst.commons.resources.now_playing_grant_access +import com.vitorpamplona.amethyst.commons.resources.now_playing_manage_access +import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_example +import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_live +import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_off +import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_sample +import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_title +import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_you +import com.vitorpamplona.amethyst.commons.resources.now_playing_section_sources +import com.vitorpamplona.amethyst.commons.resources.now_playing_settings +import com.vitorpamplona.amethyst.commons.resources.now_playing_share_in_app +import com.vitorpamplona.amethyst.commons.resources.now_playing_share_in_app_description +import com.vitorpamplona.amethyst.commons.resources.now_playing_share_other_apps +import com.vitorpamplona.amethyst.commons.resources.now_playing_share_other_apps_description +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * Settings for sharing what the user listens to as their NIP-38 music status. + * + * [access] is the platform permission other apps' playback needs (Android: notification access); + * null where none is needed. [appIcon] draws a real app icon where the platform can (Android); + * without it each app gets a colored monogram. + */ +@Composable +fun NowPlayingSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, + access: NowPlayingAccess? = null, + appIcon: (@Composable (appId: String, label: String) -> Unit)? = null, +) { + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(Res.string.now_playing_settings), nav) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + NowPlayingSettingsContent( + state = accountViewModel.account.nowPlayingSettings, + access = access, + appIcon = appIcon, + preview = { isOn -> AccountStatusPreview(accountViewModel, nav, isOn) }, + ) + } + } +} + +/** + * The body of the settings screen: a preview of the status as others see it, the two sources, + * notification access, and the per-app list. It does not scroll, so front ends with their own + * settings layout (desktop) can embed it. [showInApp] hides the in-app source where the app has + * no music or podcast player of its own; [preview] replaces the anonymous preview card with one + * showing the account (it receives whether sharing is on at all). + */ +@Composable +fun NowPlayingSettingsContent( + state: NowPlayingSettingsState, + access: NowPlayingAccess?, + modifier: Modifier = Modifier, + showInApp: Boolean = true, + appIcon: (@Composable (appId: String, label: String) -> Unit)? = null, + preview: @Composable (isOn: Boolean) -> Unit = { isOn -> + NowPlayingPreviewCard( + avatar = { MonogramBadge(stringRes(Res.string.now_playing_preview_you), size = 44, shape = CircleShape) }, + name = stringRes(Res.string.now_playing_preview_you), + liveStatus = null, + isOn = isOn, + ) + }, +) { + val settings by state.flow.collectAsState() + val isOn = (showInApp && settings.shareInApp) || settings.shareOtherApps + // One check per resume for both the confirmation row and the alert card. + val accessGranted = access == null || rememberAccessGranted(access) + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionTitle(stringRes(Res.string.now_playing_preview_title)) + preview(isOn) + Text( + text = stringRes(Res.string.now_playing_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + + SettingsSection(Res.string.now_playing_section_sources) { + if (showInApp) { + SettingsSwitchTile( + icon = MaterialSymbols.Headphones, + title = Res.string.now_playing_share_in_app, + description = Res.string.now_playing_share_in_app_description, + checked = settings.shareInApp, + onCheckedChange = state::setShareInApp, + ) + SettingsDivider() + } + SettingsSwitchTile( + icon = MaterialSymbols.Apps, + title = Res.string.now_playing_share_other_apps, + description = Res.string.now_playing_share_other_apps_description, + checked = settings.shareOtherApps, + onCheckedChange = state::setShareOtherApps, + ) + if (settings.shareOtherApps && access != null && accessGranted) { + AccessGrantedRow(access) + } + } + + if (settings.shareOtherApps && access != null && !accessGranted) { + AccessNeededCard(access) + } + + if (settings.shareOtherApps) { + SettingsSection(Res.string.now_playing_apps) { + if (settings.knownApps.isEmpty()) { + Text( + text = stringRes(Res.string.now_playing_apps_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp), + ) + } else { + settings.knownApps.entries + .sortedBy { it.value.lowercase() } + .forEachIndexed { index, (id, label) -> + if (index > 0) SettingsDivider() + AppToggleRow( + id = id, + label = label, + checked = id !in settings.blockedApps, + appIcon = appIcon, + onCheckedChange = { allowed -> state.setAppBlocked(id, !allowed) }, + ) + } + } + } + } + } +} + +/** The account's own avatar, name and live music status, exactly as other users see them. */ +@Composable +private fun AccountStatusPreview( + accountViewModel: AccountViewModel, + nav: INav, + isOn: Boolean, +) { + val user = remember(accountViewModel) { accountViewModel.account.userProfile() } + val name by observeUserName(user, accountViewModel) + val statuses by observeUserStatuses(user, accountViewModel) + val music = statuses.firstOrNull { it.address.dTag == UserStatusEvent.MUSIC } + + val avatar = @Composable { ClickableUserPicture(user, 44.dp, accountViewModel) } + + if (music == null) { + NowPlayingPreviewCard(avatar, name, liveStatus = null, isOn = isOn) + } else { + LiveMusicStatus(music, accountViewModel) { text -> + NowPlayingPreviewCard(avatar, name, liveStatus = text, isOn = isOn) + } + } +} + +@Composable +private fun LiveMusicStatus( + note: AddressableNote, + accountViewModel: AccountViewModel, + content: @Composable (String?) -> Unit, +) { + val noteState by observeNote(note, accountViewModel) + content( + noteState.note.event + ?.content + ?.ifBlank { null }, + ) +} + +/** + * A miniature of how the status renders under the user's name in feeds: the avatar, the name and + * the music line with the same icon and color the real status uses. Shows [liveStatus] when one + * is published, otherwise an example, dimmed while sharing is off. + */ +@Composable +fun NowPlayingPreviewCard( + avatar: @Composable () -> Unit, + name: String, + liveStatus: String?, + isOn: Boolean, +) { + val contentAlpha = if (liveStatus != null || isOn) 1f else 0.55f + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Sized, not clipped: the avatar rounds itself, and the follow mark and user cards it + // draws sit in the corners of its square box, which a circular clip would cut off. + Box(Modifier.size(44.dp)) { avatar() } + + Column( + modifier = Modifier.weight(1f).padding(start = 14.dp, end = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = CustomHashTagIcons.Tunestr, + contentDescription = null, + modifier = Modifier.size(15.dp).padding(end = 4.dp), + tint = MaterialTheme.colorScheme.placeholderText.copy(alpha = contentAlpha), + ) + Text( + text = liveStatus ?: stringRes(Res.string.now_playing_preview_example), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.placeholderText.copy(alpha = contentAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + StatusChip( + when { + liveStatus != null -> PreviewChip.LIVE + isOn -> PreviewChip.SAMPLE + else -> PreviewChip.OFF + }, + ) + } + } +} + +private enum class PreviewChip { LIVE, SAMPLE, OFF } + +@Composable +private fun StatusChip(chip: PreviewChip) { + val (container, content) = + when (chip) { + PreviewChip.LIVE -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.surfaceContainerHighest to MaterialTheme.colorScheme.onSurfaceVariant + } + val label = + when (chip) { + PreviewChip.LIVE -> Res.string.now_playing_preview_live + PreviewChip.SAMPLE -> Res.string.now_playing_preview_sample + PreviewChip.OFF -> Res.string.now_playing_preview_off + } + + Row( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(container) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (chip == PreviewChip.LIVE) { + Box(Modifier.size(6.dp).clip(CircleShape).background(content)) + Spacer(Modifier.width(6.dp)) + } + Text( + text = stringRes(label), + style = MaterialTheme.typography.labelMedium, + color = content, + ) + } +} + +/** Sub-row under the "Other apps" tile once access is granted: a quiet confirmation + Manage. */ +@Composable +private fun AccessGrantedRow(access: NowPlayingAccess) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 68.dp, end = 8.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SymbolIcon( + symbol = MaterialSymbols.CheckCircle, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(Res.string.now_playing_access_granted), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f).padding(start = 6.dp), + ) + TextButton(onClick = access::requestAccess) { + Text(stringRes(Res.string.now_playing_manage_access)) + } + } +} + +/** The one thing blocking other-app sharing, as a tonal card with a single clear action. */ +@Composable +private fun AccessNeededCard(access: NowPlayingAccess) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + // A light wash of the error color rather than the full errorContainer: it flags the one + // step left without shouting louder than the rest of the screen, in either theme. + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.10f), + contentColor = MaterialTheme.colorScheme.onSurface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.error.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + SymbolIcon( + symbol = MaterialSymbols.NotificationsOff, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + Text( + text = stringRes(Res.string.now_playing_access_needed_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 16.dp), + ) + } + Text( + text = stringRes(Res.string.now_playing_access_needed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 52.dp, top = 4.dp), + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = access::requestAccess, + modifier = Modifier.align(Alignment.End), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringRes(Res.string.now_playing_grant_access)) + } + } + } +} + +/** The user grants access in system settings and comes back, so this re-checks on every resume. */ +@Composable +private fun rememberAccessGranted(access: NowPlayingAccess): Boolean { + var granted by remember(access) { mutableStateOf(access.hasAccess()) } + LifecycleResumeEffect(access) { + granted = access.hasAccess() + onPauseOrDispose {} + } + return granted +} + +@Composable +private fun AppToggleRow( + id: String, + label: String, + checked: Boolean, + appIcon: (@Composable (appId: String, label: String) -> Unit)?, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)), contentAlignment = Alignment.Center) { + if (appIcon != null) appIcon(id, label) else MonogramBadge(label, size = 36, shape = RoundedCornerShape(10.dp)) + } + Column( + modifier = Modifier.weight(1f).padding(start = 16.dp, end = 12.dp), + ) { + Text(text = label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + // The package name tells apart two apps with the same name; skip it when it only + // repeats the name (desktop player ids such as `spotify` or `vlc`). + if (!label.lowercase().filterNot { it.isWhitespace() }.contains(id.lowercase())) { + Text( + text = id, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +// The accent palette of the Messages settings, so app badges read as the same family. +private val MONOGRAM_COLORS = + listOf( + Color(0xFF2EBD85), + Color(0xFFF6A609), + Color(0xFF2E90FA), + Color(0xFF9E77ED), + Color(0xFF5B6AD0), + Color(0xFFEC4899), + Color(0xFF14B8A6), + Color(0xFFEF4444), + Color(0xFF06B6D4), + ) + +/** A tinted badge with the first letter of [label], colored by the label so it stays stable. */ +@Composable +fun MonogramBadge( + label: String, + size: Int, + shape: Shape, +) { + val accent = MONOGRAM_COLORS[(label.hashCode() and Int.MAX_VALUE) % MONOGRAM_COLORS.size] + Box( + modifier = Modifier.size(size.dp).clip(shape).background(accent.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = label.trim().take(1).uppercase(), + style = if (size >= 40) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = accent, + ) + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 4.dp), + ) +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 0bf5bee248..35f29cf0cc 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -67,6 +67,8 @@ import com.vitorpamplona.amethyst.commons.resources.nests_servers_search_keyword import com.vitorpamplona.amethyst.commons.resources.nests_servers_title import com.vitorpamplona.amethyst.commons.resources.notification_settings import com.vitorpamplona.amethyst.commons.resources.notification_settings_search_keywords +import com.vitorpamplona.amethyst.commons.resources.now_playing_search_keywords +import com.vitorpamplona.amethyst.commons.resources.now_playing_settings import com.vitorpamplona.amethyst.commons.resources.ots_explorer_search_keywords import com.vitorpamplona.amethyst.commons.resources.ots_explorer_settings import com.vitorpamplona.amethyst.commons.resources.payment_targets @@ -160,6 +162,7 @@ fun buildSettingsCatalog( symEntry(Res.string.drawer_settings, MaterialSymbols.AutoMirrored.ViewList, Res.string.drawer_search_keywords, Route.DrawerSettings), symEntry(Res.string.video_player_settings, MaterialSymbols.VideoSettings, Res.string.video_player_search_keywords, Route.VideoPlayerSettings), symEntry(Res.string.audio_visualizer_settings, MaterialSymbols.MusicNote, Res.string.audio_visualizer_search_keywords, Route.AudioVisualizerSettings), + symEntry(Res.string.now_playing_settings, MaterialSymbols.MusicNote, Res.string.now_playing_search_keywords, Route.NowPlayingSettings), symEntry(Res.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, Res.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds), symEntry(Res.string.profile_badges_title, MaterialSymbols.MilitaryTech, Res.string.profile_badges_search_keywords, Route.ProfileBadges), symEntry(Res.string.payment_targets, MaterialSymbols.Payment, Res.string.payment_targets_search_keywords, Route.EditPaymentTargets), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UpdateZapAmountScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/UpdateZapAmountScreen.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UpdateZapAmountScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/UpdateZapAmountScreen.kt index 058b3f1d82..90baf9b27c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UpdateZapAmountScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/UpdateZapAmountScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -27,14 +27,14 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.zaps +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.commons.ui.note.UpdateZapAmountContent +import com.vitorpamplona.amethyst.commons.ui.note.UpdateZapAmountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountContent -import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountViewModel @Composable fun UpdateZapAmountScreen( @@ -42,7 +42,7 @@ fun UpdateZapAmountScreen( nav: INav, nip47: String? = null, ) { - val postViewModel: UpdateZapAmountViewModel = viewModel() + val postViewModel: UpdateZapAmountViewModel = rememberViewModel { UpdateZapAmountViewModel() } postViewModel.init(accountViewModel) LaunchedEffect(accountViewModel, postViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/UserSettingsScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/UserSettingsScreen.kt index 0b8f3f2a38..39c7f00e8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/UserSettingsScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -91,9 +91,11 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp import com.vitorpamplona.amethyst.commons.ui.theme.SpacedBy10dp import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow +import com.vitorpamplona.amethyst.commons.util.LanguageOption +import com.vitorpamplona.amethyst.commons.util.availableLanguages +import com.vitorpamplona.amethyst.commons.util.languageDisplayName import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel -import java.util.Locale as JavaLocale @Preview(device = "spec:width=2160px,height=2340px,dpi=440") @Composable @@ -134,18 +136,12 @@ fun UserSettingsScreen( } } -private fun getAllLanguagesSorted(): List<JavaLocale> { - val seen = mutableSetOf<String>() - return JavaLocale - .getAvailableLocales() - .filter { it.language.isNotBlank() && it.country.isBlank() && seen.add(it.language) } - .sortedBy { it.displayName.lowercase() } -} +private fun getAllLanguagesSorted(): List<LanguageOption> = availableLanguages().sortedBy { it.displayName.lowercase() } @Composable private fun SearchableLanguageList( - languages: List<JavaLocale>, - onSelect: (JavaLocale) -> Unit, + languages: List<LanguageOption>, + onSelect: (LanguageOption) -> Unit, modifier: Modifier = Modifier, ) { var searchQuery by remember { mutableStateOf("") } @@ -225,7 +221,7 @@ fun TranslateToSetting(accountViewModel: AccountViewModel) { verticalAlignment = Alignment.CenterVertically, ) { Text( - text = JavaLocale.forLanguageTag(currentTranslateTo).displayName, + text = languageDisplayName(currentTranslateTo), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, ) @@ -277,7 +273,7 @@ fun DontTranslateFromSetting(accountViewModel: AccountViewModel) { InputChip( selected = true, onClick = { accountViewModel.removeDontTranslateFrom(languageCode) }, - label = { Text(JavaLocale.forLanguageTag(languageCode).displayName) }, + label = { Text(languageDisplayName(languageCode)) }, trailingIcon = { Icon( symbol = MaterialSymbols.Close, @@ -379,8 +375,8 @@ private fun LanguagePreferenceCard( preference: String, accountViewModel: AccountViewModel, ) { - val sourceName = JavaLocale.forLanguageTag(source).displayName - val targetName = JavaLocale.forLanguageTag(target).displayName + val sourceName = languageDisplayName(source) + val targetName = languageDisplayName(target) OutlinedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(12.dp)) { @@ -464,9 +460,9 @@ private fun AddLanguagePairCard( onDismiss: () -> Unit, ) { val allLanguages = remember { getAllLanguagesSorted() } - var selectedSource by remember { mutableStateOf<JavaLocale?>(null) } - var selectedTarget by remember { mutableStateOf<JavaLocale?>(null) } - var selectedPreference by remember { mutableStateOf<JavaLocale?>(null) } + var selectedSource by remember { mutableStateOf<LanguageOption?>(null) } + var selectedTarget by remember { mutableStateOf<LanguageOption?>(null) } + var selectedPreference by remember { mutableStateOf<LanguageOption?>(null) } var pickingSource by remember { mutableStateOf(false) } var pickingTarget by remember { mutableStateOf(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/NewShortVideoButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/NewShortVideoButton.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/NewShortVideoButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/NewShortVideoButton.kt index 234c89f792..b7801a93a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/NewShortVideoButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/NewShortVideoButton.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.shorts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -41,9 +41,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.VideoPostKind @@ -53,20 +51,22 @@ import com.vitorpamplona.amethyst.commons.resources.new_short_video import com.vitorpamplona.amethyst.commons.resources.record_a_video import com.vitorpamplona.amethyst.commons.resources.upload_image import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.NewMediaModel +import com.vitorpamplona.amethyst.commons.ui.actions.NewMediaView import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelect +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideo +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.painterRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.NewMediaModel -import com.vitorpamplona.amethyst.ui.actions.NewMediaView -import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideo -import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -95,7 +95,7 @@ fun NewShortVideoButton( var pickedURIs by remember { mutableStateOf<ImmutableList<SelectedMedia>>(persistentListOf()) } val scope = rememberCoroutineScope() - val postViewModel: NewMediaModel = viewModel() + val postViewModel: NewMediaModel = rememberViewModel { NewMediaModel() } postViewModel.onceUploaded { scope.launch(Dispatchers.IO) { delay(500) @@ -103,9 +103,9 @@ fun NewShortVideoButton( } } - val context = LocalContext.current + val mediaResolver = rememberSharedMediaResolver() LaunchedEffect(sharedAttachments) { - resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it } + mediaResolver.resolve(sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it } } if (wantsToRecordVideo) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt index 7dd7b31049..dca890da99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.shorts import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/ShortsScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/ShortsScreen.kt index 377a4a52bf..d442e7c9b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/ShortsScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.shorts import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -35,8 +35,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.shorts.datasource.ShortsFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssemblerSubscription @Composable fun ShortsScreen( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/ShortsTopBar.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/ShortsTopBar.kt index e07db0d8ff..ba714a03c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/ShortsTopBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.shorts import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/VideoCardCompose.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/VideoCardCompose.kt index 197918f1a6..4f319b2784 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/VideoCardCompose.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.shorts import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -52,9 +52,9 @@ import com.vitorpamplona.amethyst.commons.ui.components.collectContentWarningRea import com.vitorpamplona.amethyst.commons.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow +import com.vitorpamplona.amethyst.commons.ui.note.platform.ZoomableContentView +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW @@ -162,7 +162,7 @@ private fun VideoCardImage( } @Composable -internal fun VideoCardCaption(videoEvent: VideoEvent) { +fun VideoCardCaption(videoEvent: VideoEvent) { val event = (videoEvent as? Event) ?: return val title = videoEvent.title() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/ShortsFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/datasource/ShortsFilterAssemblerSubscription.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/ShortsFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/datasource/ShortsFilterAssemblerSubscription.kt index 9a907d347b..4b83599e2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/ShortsFilterAssemblerSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/shorts/datasource/ShortsFilterAssemblerSubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.shorts.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/threadview/ThreadFeedView.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 71492e7f99..ca61da19fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -20,7 +20,7 @@ */ @file:Suppress("DEPRECATION") -package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.threadview import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable @@ -144,6 +144,18 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.commons.ui.note.nip22Comments.DisplayCommentScope import com.vitorpamplona.amethyst.commons.ui.note.observeEdits import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderAudioHeader +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderAudioTrack +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderGitIssueEvent +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderGitPatchEvent +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderGitPullRequestEvent +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderGitPullRequestUpdateEvent +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderGitRepositoryEvent +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderMusicTrack +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderNamedSiteEvent +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderRootSiteEvent +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderVoiceTrack +import com.vitorpamplona.amethyst.commons.ui.note.platform.ZoomableContentView import com.vitorpamplona.amethyst.commons.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.commons.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.commons.ui.note.types.DisplayBroadcastRelayList @@ -170,6 +182,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestation import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestationRequest import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestorProficiency import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestorRecommendation +import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBallot import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBlossomPieceIndex import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBolt12Zap import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBookshelfDirectory @@ -202,6 +215,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPodcastEpisode import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPodcastMetadata import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPostApproval +import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPredictionMarket import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPrivateMessage import com.vitorpamplona.amethyst.commons.ui.note.types.RenderProfileCard import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPs1Save @@ -262,18 +276,6 @@ import com.vitorpamplona.amethyst.commons.util.showAmount import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.thread.LevelFeedViewModel -import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.note.types.AudioHeader -import com.vitorpamplona.amethyst.ui.note.types.AudioTrackHeader -import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent -import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent -import com.vitorpamplona.amethyst.ui.note.types.RenderGitPullRequestEvent -import com.vitorpamplona.amethyst.ui.note.types.RenderGitPullRequestUpdateEvent -import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent -import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack -import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent -import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent -import com.vitorpamplona.amethyst.ui.note.types.VoiceHeader import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent @@ -285,6 +287,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.ballots.BallotEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward @@ -303,6 +306,7 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent import com.vitorpamplona.quartz.experimental.publications.PublicationContentEvent import com.vitorpamplona.quartz.experimental.publications.PublicationIndexEvent @@ -398,8 +402,10 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.utils.parseBigDecimalOrNull import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -896,7 +902,7 @@ private fun FullBleedNoteCompose( } else if (noteEvent is WorkoutRecordEvent) { WorkoutDisplay(baseNote, backgroundColor, canPreview = true, quotesLeft = 3, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is BaseVoiceEvent) { - VoiceHeader(noteEvent, baseNote, accountViewModel, nav) + RenderVoiceTrack(baseNote, accountViewModel, nav) } else if (noteEvent is FileMetadataEvent) { FileMetadataDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { @@ -908,9 +914,9 @@ private fun FullBleedNoteCompose( } else if (noteEvent is ContactListEvent) { DisplayContactList(baseNote, accountViewModel, nav) } else if (noteEvent is AudioTrackEvent) { - AudioTrackHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) + RenderAudioTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav) } else if (noteEvent is AudioHeaderEvent) { - AudioHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) + RenderAudioHeader(baseNote, ContentScale.FillWidth, accountViewModel, nav) } else if (noteEvent is MusicTrackEvent) { RenderMusicTrack(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is MusicPlaylistEvent) { @@ -1140,6 +1146,10 @@ private fun FullBleedNoteCompose( RenderFedimint(noteEvent) } else if (noteEvent is MintRecommendationEvent) { RenderMintRecommendation(noteEvent) + } else if (noteEvent is PredictionMarketEvent) { + RenderPredictionMarket(baseNote, makeItShort = false, accountViewModel) + } else if (noteEvent is BallotEvent) { + RenderBallot(baseNote, accountViewModel) } else if (noteEvent is ChatEvent) { RenderChat( note = baseNote, @@ -1358,7 +1368,7 @@ private fun RenderClassifiedsReaderForThread( Modifier.padding(top = 5.dp), verticalAlignment = Alignment.CenterVertically, ) { - val newAmount = price.amount.toBigDecimalOrNull()?.let { showAmount(it) } ?: price.amount + val newAmount = parseBigDecimalOrNull(price.amount)?.let { showAmount(it) } ?: price.amount val priceTag = remember(noteEvent) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/threadview/ThreadScreen.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/threadview/ThreadScreen.kt index e5edcfb2be..4703d2e733 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/threadview/ThreadScreen.kt @@ -18,15 +18,15 @@ * 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.threadview +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.threadview import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.thread_title import com.vitorpamplona.amethyst.commons.ui.components.LoadNote +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav @@ -45,7 +45,7 @@ fun ThreadScreen( if (noteId == null) return val feedViewModel: ThreadFeedViewModel = - viewModel( + rememberViewModel( key = noteId + "NostrThreadFeedViewModel", factory = ThreadFeedViewModel.Factory(accountViewModel.account, noteId), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/NewUrlNoteButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/NewUrlNoteButton.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/NewUrlNoteButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/NewUrlNoteButton.kt index 0b80f8cf5a..8fb4eca7e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/NewUrlNoteButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/NewUrlNoteButton.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.url +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.url import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlPostScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/UrlPostScreen.kt similarity index 74% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlPostScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/UrlPostScreen.kt index 1d10a9eab3..b8c704fcd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlPostScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/UrlPostScreen.kt @@ -18,25 +18,21 @@ * 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.url +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.url import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.LocalContext -import androidx.core.net.toUri -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.nip22Comments.CommentPostViewModel +import com.vitorpamplona.amethyst.commons.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav -import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel -import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext @Composable fun UrlPostScreen( @@ -47,12 +43,12 @@ fun UrlPostScreen( quoteId: HexKey? = null, draftId: HexKey? = null, accountViewModel: AccountViewModel, - nav: Nav, + nav: INav, ) { - val postViewModel: CommentPostViewModel = viewModel() + val postViewModel: CommentPostViewModel = rememberViewModel { CommentPostViewModel() } postViewModel.init(accountViewModel) - val context = LocalContext.current + val mediaResolver = rememberSharedMediaResolver() LaunchedEffect(postViewModel, accountViewModel) { url?.let { @@ -73,11 +69,8 @@ fun UrlPostScreen( postViewModel.message.setTextAndPlaceCursorAtEnd(it) postViewModel.onMessageChanged() } - attachment?.ifBlank { null }?.toUri()?.let { - withContext(Dispatchers.IO) { - val mediaType = context.contentResolver.getType(it) - postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) - } + mediaResolver.resolve(attachment)?.let { + postViewModel.selectImage(persistentListOf(it)) } if (draftId == null) { postViewModel.applySignature() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/UrlScreen.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/UrlScreen.kt index 0e42872c12..73d1b4dd01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/UrlScreen.kt @@ -18,19 +18,19 @@ * 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.url +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.url import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.external_content_title import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewCard import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.FeedLoaded import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold @@ -38,13 +38,13 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.FabBottomBarP 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.nip22Comments.LocalCurrentExternalScope +import com.vitorpamplona.amethyst.commons.ui.note.platform.rememberUrlPreviewState import com.vitorpamplona.amethyst.commons.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.url.dal.UrlFeedViewModel +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.url.datasource.UrlFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.MaxWidthWithHorzPadding import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.components.rememberUrlPreviewState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.dal.UrlFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource.UrlFilterAssemblerSubscription import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId @Composable @@ -66,7 +66,7 @@ fun PrepareViewModelsUrlScreen( nav: INav, ) { val urlFeedViewModel: UrlFeedViewModel = - viewModel( + rememberViewModel( key = url + "UrlFeedViewModel", factory = UrlFeedViewModel.Factory( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt index baf3b141ac..52d61bba97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.url.dal +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.url.dal import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.feeds.sortedByDefaultFeedOrder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/dal/UrlFeedViewModel.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedViewModel.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/dal/UrlFeedViewModel.kt index e9890a34d8..251924ae71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/dal/UrlFeedViewModel.kt @@ -18,15 +18,17 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.url.dal +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.url.dal import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.ui.screen.AndroidFeedViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.reflect.KClass @Stable class UrlFeedViewModel( @@ -43,6 +45,9 @@ class UrlFeedViewModel( val account: Account, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun <T : ViewModel> create(modelClass: Class<T>): T = UrlFeedViewModel(url, relays, account) as T + override fun <T : ViewModel> create( + modelClass: KClass<T>, + extras: CreationExtras, + ): T = UrlFeedViewModel(url, relays, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/datasource/UrlFilterAssemblerSubscription.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/datasource/UrlFilterAssemblerSubscription.kt index 4864613b42..4799138d57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFilterAssemblerSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/url/datasource/UrlFilterAssemblerSubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.url.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileMetadataCardCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/FileMetadataCardCompose.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileMetadataCardCompose.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/FileMetadataCardCompose.kt index aa5b9947f3..0f8d9ade67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileMetadataCardCompose.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/FileMetadataCardCompose.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.video +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -46,10 +46,10 @@ import com.vitorpamplona.amethyst.commons.ui.components.collectContentWarningRea import com.vitorpamplona.amethyst.commons.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow +import com.vitorpamplona.amethyst.commons.ui.note.platform.ZoomableContentView import com.vitorpamplona.amethyst.commons.ui.note.types.FileMetadataAttachmentCard import com.vitorpamplona.amethyst.commons.ui.note.types.toMediaContent 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.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent @@ -145,7 +145,7 @@ private fun FileMetadataCardImage( } @Composable -internal fun FileMetadataCardCaption(videoEvent: FileMetadataEvent) { +fun FileMetadataCardCaption(videoEvent: FileMetadataEvent) { val event = (videoEvent as? Event) ?: return val title = videoEvent.summary() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewVideoFeedButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/NewVideoFeedButton.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewVideoFeedButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/NewVideoFeedButton.kt index fa0450900f..4e51206e5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewVideoFeedButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/NewVideoFeedButton.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.video +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -41,9 +41,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -53,21 +51,23 @@ import com.vitorpamplona.amethyst.commons.resources.record_a_video import com.vitorpamplona.amethyst.commons.resources.take_a_picture import com.vitorpamplona.amethyst.commons.resources.upload_image import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.NewMediaModel +import com.vitorpamplona.amethyst.commons.ui.actions.NewMediaView import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelect +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakePicture +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideo +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver +import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.painterRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.NewMediaModel -import com.vitorpamplona.amethyst.ui.actions.NewMediaView -import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture -import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideo -import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -102,7 +102,7 @@ fun NewVideoFeedButton( val scope = rememberCoroutineScope() - val postViewModel: NewMediaModel = viewModel() + val postViewModel: NewMediaModel = rememberViewModel { NewMediaModel() } postViewModel.onceUploaded { scope.launch(Dispatchers.IO) { delay(500) @@ -110,9 +110,9 @@ fun NewVideoFeedButton( } } - val context = LocalContext.current + val mediaResolver = rememberSharedMediaResolver() LaunchedEffect(sharedAttachments) { - resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it } + mediaResolver.resolve(sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it } } if (wantsToPostFromCamera) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/StoriesTopBar.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/StoriesTopBar.kt index 4b17731e8e..280c5876df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/StoriesTopBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.video +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/UserCardHeader.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/UserCardHeader.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/UserCardHeader.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/UserCardHeader.kt index 7f22e04a90..5b27795e71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/UserCardHeader.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/UserCardHeader.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.video +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/VideoScreen.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/VideoScreen.kt index 33fd756f72..29178fc558 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/VideoScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.video +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -45,12 +45,12 @@ import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding 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.pictures.PictureCardCompose +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.shorts.VideoCardCompose +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.PictureCardCompose -import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.VideoCardCompose -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt index 4d5143848c..c11cd36bb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.kt index 3b37a76947..6b9074bc97 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.kt @@ -25,16 +25,33 @@ import androidx.compose.runtime.Composable /** Hands a payment to a wallet app installed on the device. */ fun interface WalletAppLauncher { /** - * Opens [invoice] (BOLT-11) in the wallet app. [onPaid] runs once the wallet took it, which is - * not a payment confirmation; [onError] gets [noWalletFound] when no wallet can open it. + * Opens the payment [uri] (`lightning:` or `bitcoin:`) in the wallet app. [onPaid] runs once + * the wallet took it, which is not a payment confirmation; [onError] gets [noWalletFound] when + * no wallet can open it. */ - fun payInvoice( - invoice: String, + fun openPaymentUri( + uri: String, noWalletFound: String, onPaid: () -> Unit, onError: (String) -> Unit, ) } +/** Opens a BOLT-11 [invoice] in the wallet app; see [WalletAppLauncher.openPaymentUri]. */ +fun WalletAppLauncher.payInvoice( + invoice: String, + noWalletFound: String, + onPaid: () -> Unit, + onError: (String) -> Unit, +) = openPaymentUri("lightning:$invoice", noWalletFound, onPaid, onError) + +/** Opens a BOLT-12 [offer] in the wallet app; see [WalletAppLauncher.openPaymentUri]. */ +fun WalletAppLauncher.payBolt12Offer( + offer: String, + noWalletFound: String, + onPaid: () -> Unit, + onError: (String) -> Unit, +) = openPaymentUri("bitcoin:?lno=$offer", noWalletFound, onPaid, onError) + @Composable expect fun rememberWalletAppLauncher(): WalletAppLauncher diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountFeedContentStates.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountFeedContentStates.kt index 0ba87e47e5..5a7dc3fcf6 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountFeedContentStates.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountFeedContentStates.kt @@ -461,6 +461,8 @@ class AccountFeedContentStates( } fun deleteNotes(newNotes: Set<Note>) { + notifications.deleteFromFeed(newNotes) + notificationsFollowing.deleteFromFeed(newNotes) homeLive.deleteFromFeed(newNotes) homeNewThreads.deleteFromFeed(newNotes) homeReplies.deleteFromFeed(newNotes) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt index b53610ad2a..b218de03fc 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt @@ -764,10 +764,13 @@ class AccountViewModel( account.concord.sendConcordTyping(communityId, channelIdHex) } - fun sendBuzzTyping(channel: RelayGroupChannel) = - viewModelScope.launch(Dispatchers.IO) { - account.relayGroups.sendBuzzTyping(channel) - } + fun sendBuzzTyping( + channel: RelayGroupChannel, + threadRootId: HexKey? = null, + replyToId: HexKey? = null, + ) = viewModelScope.launch(Dispatchers.IO) { + account.relayGroups.sendBuzzTyping(channel, threadRootId, replyToId) + } @Immutable data class NoteComposeReportState( diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/SharedMediaResolver.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/SharedMediaResolver.ios.kt new file mode 100644 index 0000000000..2f541f5d43 --- /dev/null +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/SharedMediaResolver.ios.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.actions.uploads + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.service.uploads.StringMediaUri +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +// Addresses are plain paths or URLs here; nothing reports a MIME type for them yet. +private object StringSharedMedia : SharedMediaResolver { + override suspend fun resolve(uriString: String?): SelectedMedia? = uriString?.ifBlank { null }?.let { SelectedMedia(StringMediaUri(it), null) } + + override suspend fun resolve(uriStrings: List<String>): ImmutableList<SelectedMedia> = uriStrings.mapNotNull { resolve(it) }.toImmutableList() +} + +@Composable +actual fun rememberSharedMediaResolver(): SharedMediaResolver = StringSharedMedia + +/** No app shares into this one on this platform yet. */ +@Composable +actual fun OnIncomingShare( + onText: (String) -> Unit, + onMedia: (SelectedMedia) -> Unit, +) = Unit diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/TakePicture.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/TakePicture.ios.kt new file mode 100644 index 0000000000..44675f239c --- /dev/null +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/TakePicture.ios.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.actions.uploads + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +// No camera capture wired on this platform yet: the buttons hide, and a direct call reports a cancel. + +actual val canCaptureFromCamera: Boolean = false + +@Composable +actual fun TakePicture(onPictureTaken: (ImmutableList<SelectedMedia>) -> Unit) { + LaunchedEffect(Unit) { onPictureTaken(persistentListOf()) } +} + +@Composable +actual fun TakeVideo(onVideoTaken: (ImmutableList<SelectedMedia>) -> Unit) { + LaunchedEffect(Unit) { onVideoTaken(persistentListOf()) } +} diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/FileSharer.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/FileSharer.ios.kt new file mode 100644 index 0000000000..433237d050 --- /dev/null +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/FileSharer.ios.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.runtime.Composable + +private object NoFileSharer : FileSharer { + override fun shareTextFilesAsZip( + zipName: String, + files: List<Pair<String, String>>, + title: String, + ) {} + + override fun shareTextFile( + fileName: String, + mimeType: String, + content: String, + title: String, + ) {} +} + +/** No share sheet wired yet. */ +@Composable +actual fun rememberFileSharer(): FileSharer = NoFileSharer diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.ios.kt new file mode 100644 index 0000000000..1dc27c0ede --- /dev/null +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.ios.kt @@ -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.commons.ui.components + +import androidx.compose.runtime.Composable + +private object NoLocationPermission : PlatformPermission { + override val isGranted: Boolean = false + + override fun launchRequest() = Unit +} + +/** No location source on this platform yet. */ +@Composable +actual fun rememberCoarseLocationPermission(): PlatformPermission = NoLocationPermission diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.ios.kt index 3a03a0d9e7..77cede0810 100644 --- a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.ios.kt +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.ios.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.ui.note import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import platform.Foundation.NSCalendar import platform.Foundation.NSCalendarUnitDay import platform.Foundation.NSCalendarUnitYear @@ -88,6 +89,10 @@ private val timeOfDay: (Long) -> String = { epochMillis -> timeOfDayFormatter.st @Composable actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String = timeOfDay +// The "j" template asks for the locale's preferred hour; an "a" in the result means an AM/PM clock. +@Composable +actual fun rememberIs24HourClock(): Boolean = remember { NSDateFormatter.dateFormatFromTemplate("j", 0u, NSLocale.currentLocale)?.contains('a') != true } + actual fun relativeTimeSpanShortOrNull( epochMillis: Long, nowMillis: Long, diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.ios.kt new file mode 100644 index 0000000000..ab75c29bcc --- /dev/null +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.ios.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.privacylock + +import androidx.compose.runtime.Composable + +private val ApproveAtOnce = DeviceAuthenticator { _, onApproved, _ -> onApproved() } + +/** No device-credential prompt wired on this platform yet; see the common declaration. */ +@Composable +actual fun rememberDeviceAuthenticator(): DeviceAuthenticator = ApproveAtOnce diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/PhoneCalendar.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/PhoneCalendar.ios.kt new file mode 100644 index 0000000000..0cbe7794ae --- /dev/null +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/PhoneCalendar.ios.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars + +import androidx.compose.runtime.Composable + +private val NoPhoneCalendar = PhoneCalendar { false } + +/** No calendar composer wired yet: callers fall back to the .ics share. */ +@Composable +actual fun rememberPhoneCalendar(): PhoneCalendar = NoPhoneCalendar diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/RelativeTimeSpan.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/RelativeTimeSpan.ios.kt new file mode 100644 index 0000000000..232d530b46 --- /dev/null +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/RelativeTimeSpan.ios.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars + +import platform.Foundation.NSDate +import platform.Foundation.NSRelativeDateTimeFormatter +import platform.Foundation.NSRelativeDateTimeFormatterUnitsStyleAbbreviated +import platform.Foundation.dateWithTimeIntervalSince1970 + +actual suspend fun relativeTimeSpan( + targetSeconds: Long, + nowSeconds: Long, + dayResolution: Boolean, +): String { + val formatter = NSRelativeDateTimeFormatter() + formatter.unitsStyle = NSRelativeDateTimeFormatterUnitsStyleAbbreviated + val day = 86_400L + val (target, now) = + if (dayResolution) { + Pair(targetSeconds.floorDiv(day) * day, nowSeconds.floorDiv(day) * day) + } else { + Pair(targetSeconds, nowSeconds) + } + return formatter.localizedStringForDate( + NSDate.dateWithTimeIntervalSince1970(target.toDouble()), + relativeToDate = NSDate.dateWithTimeIntervalSince1970(now.toDouble()), + ) +} diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.ios.kt index 9ecd3a05f9..e189462710 100644 --- a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.ios.kt +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.ios.kt @@ -25,14 +25,14 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalUriHandler import kotlin.coroutines.cancellation.CancellationException -/** Opens the `lightning:` link with whatever the system registered for it. */ +/** Opens the payment link with whatever the system registered for it. */ @Composable actual fun rememberWalletAppLauncher(): WalletAppLauncher { val uriHandler = LocalUriHandler.current return remember(uriHandler) { - WalletAppLauncher { invoice, noWalletFound, onPaid, onError -> + WalletAppLauncher { uri, noWalletFound, onPaid, onError -> try { - uriHandler.openUri("lightning:$invoice") + uriHandler.openUri(uri) onPaid() } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/SharedMediaResolver.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/SharedMediaResolver.jvm.kt new file mode 100644 index 0000000000..2f541f5d43 --- /dev/null +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/SharedMediaResolver.jvm.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.actions.uploads + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.service.uploads.StringMediaUri +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +// Addresses are plain paths or URLs here; nothing reports a MIME type for them yet. +private object StringSharedMedia : SharedMediaResolver { + override suspend fun resolve(uriString: String?): SelectedMedia? = uriString?.ifBlank { null }?.let { SelectedMedia(StringMediaUri(it), null) } + + override suspend fun resolve(uriStrings: List<String>): ImmutableList<SelectedMedia> = uriStrings.mapNotNull { resolve(it) }.toImmutableList() +} + +@Composable +actual fun rememberSharedMediaResolver(): SharedMediaResolver = StringSharedMedia + +/** No app shares into this one on this platform yet. */ +@Composable +actual fun OnIncomingShare( + onText: (String) -> Unit, + onMedia: (SelectedMedia) -> Unit, +) = Unit diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/TakePicture.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/TakePicture.jvm.kt new file mode 100644 index 0000000000..44675f239c --- /dev/null +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/TakePicture.jvm.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.actions.uploads + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +// No camera capture wired on this platform yet: the buttons hide, and a direct call reports a cancel. + +actual val canCaptureFromCamera: Boolean = false + +@Composable +actual fun TakePicture(onPictureTaken: (ImmutableList<SelectedMedia>) -> Unit) { + LaunchedEffect(Unit) { onPictureTaken(persistentListOf()) } +} + +@Composable +actual fun TakeVideo(onVideoTaken: (ImmutableList<SelectedMedia>) -> Unit) { + LaunchedEffect(Unit) { onVideoTaken(persistentListOf()) } +} diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/FileSharer.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/FileSharer.jvm.kt new file mode 100644 index 0000000000..433237d050 --- /dev/null +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/FileSharer.jvm.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.runtime.Composable + +private object NoFileSharer : FileSharer { + override fun shareTextFilesAsZip( + zipName: String, + files: List<Pair<String, String>>, + title: String, + ) {} + + override fun shareTextFile( + fileName: String, + mimeType: String, + content: String, + title: String, + ) {} +} + +/** No share sheet wired yet. */ +@Composable +actual fun rememberFileSharer(): FileSharer = NoFileSharer diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.jvm.kt new file mode 100644 index 0000000000..1dc27c0ede --- /dev/null +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.jvm.kt @@ -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.commons.ui.components + +import androidx.compose.runtime.Composable + +private object NoLocationPermission : PlatformPermission { + override val isGranted: Boolean = false + + override fun launchRequest() = Unit +} + +/** No location source on this platform yet. */ +@Composable +actual fun rememberCoarseLocationPermission(): PlatformPermission = NoLocationPermission diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.jvm.kt index 3ad02fe674..b95d0d321a 100644 --- a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.jvm.kt +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.jvm.kt @@ -21,9 +21,12 @@ package com.vitorpamplona.amethyst.commons.ui.note import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import java.time.Instant import java.time.ZoneId +import java.time.chrono.IsoChronology import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder import java.time.format.FormatStyle import java.util.Locale @@ -80,6 +83,14 @@ private val timeOfDay: (Long) -> String = { epochMillis -> @Composable actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String = timeOfDay +@Composable +actual fun rememberIs24HourClock(): Boolean = + remember { + DateTimeFormatterBuilder + .getLocalizedDateTimePattern(null, FormatStyle.SHORT, IsoChronology.INSTANCE, Locale.getDefault()) + .contains('H') + } + actual fun relativeTimeSpanShortOrNull( epochMillis: Long, nowMillis: Long, diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.jvm.kt new file mode 100644 index 0000000000..ab75c29bcc --- /dev/null +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.jvm.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.privacylock + +import androidx.compose.runtime.Composable + +private val ApproveAtOnce = DeviceAuthenticator { _, onApproved, _ -> onApproved() } + +/** No device-credential prompt wired on this platform yet; see the common declaration. */ +@Composable +actual fun rememberDeviceAuthenticator(): DeviceAuthenticator = ApproveAtOnce diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/PhoneCalendar.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/PhoneCalendar.jvm.kt new file mode 100644 index 0000000000..0cbe7794ae --- /dev/null +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/PhoneCalendar.jvm.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars + +import androidx.compose.runtime.Composable + +private val NoPhoneCalendar = PhoneCalendar { false } + +/** No calendar composer wired yet: callers fall back to the .ics share. */ +@Composable +actual fun rememberPhoneCalendar(): PhoneCalendar = NoPhoneCalendar diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/RelativeTimeSpan.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/RelativeTimeSpan.jvm.kt new file mode 100644 index 0000000000..285118e8f4 --- /dev/null +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/RelativeTimeSpan.jvm.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars + +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.d +import com.vitorpamplona.amethyst.commons.resources.h +import com.vitorpamplona.amethyst.commons.resources.m +import com.vitorpamplona.amethyst.commons.resources.now +import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.math.abs + +/** The JDK has no relative-time formatter: a compact signed span ("+2h", "-5m"). */ +actual suspend fun relativeTimeSpan( + targetSeconds: Long, + nowSeconds: Long, + dayResolution: Boolean, +): String { + val diff = targetSeconds - nowSeconds + val span = abs(diff) + val sign = if (diff < 0) "-" else "+" + return when { + span >= TimeUtils.ONE_DAY -> sign + (span / TimeUtils.ONE_DAY) + loadStringRes(Res.string.d) + dayResolution -> loadStringRes(Res.string.now) + span >= TimeUtils.ONE_HOUR -> sign + (span / TimeUtils.ONE_HOUR) + loadStringRes(Res.string.h) + span >= TimeUtils.ONE_MINUTE -> sign + (span / TimeUtils.ONE_MINUTE) + loadStringRes(Res.string.m) + else -> loadStringRes(Res.string.now) + } +} diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.jvm.kt index 9ecd3a05f9..e189462710 100644 --- a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.jvm.kt +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.jvm.kt @@ -25,14 +25,14 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalUriHandler import kotlin.coroutines.cancellation.CancellationException -/** Opens the `lightning:` link with whatever the system registered for it. */ +/** Opens the payment link with whatever the system registered for it. */ @Composable actual fun rememberWalletAppLauncher(): WalletAppLauncher { val uriHandler = LocalUriHandler.current return remember(uriHandler) { - WalletAppLauncher { invoice, noWalletFound, onPaid, onError -> + WalletAppLauncher { uri, noWalletFound, onPaid, onError -> try { - uriHandler.openUri("lightning:$invoice") + uriHandler.openUri(uri) onPaid() } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModelTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/nip22Comments/CommentPostViewModelTest.kt similarity index 98% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModelTest.kt rename to commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/nip22Comments/CommentPostViewModelTest.kt index f493318dcf..777cc259dd 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModelTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/nip22Comments/CommentPostViewModelTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.nip22Comments +package com.vitorpamplona.amethyst.commons.ui.note.nip22Comments import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCardTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/share/SharedNoteCardTest.kt similarity index 99% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCardTest.kt rename to commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/share/SharedNoteCardTest.kt index 7d76a5852f..063ed1de85 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCardTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/share/SharedNoteCardTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.share +package com.vitorpamplona.amethyst.commons.ui.note.share import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/CordnCoordinatorNameTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/cordnGroup/CordnCoordinatorNameTest.kt similarity index 95% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/CordnCoordinatorNameTest.kt rename to commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/cordnGroup/CordnCoordinatorNameTest.kt index 339fa80822..a540b1387a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/CordnCoordinatorNameTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/cordnGroup/CordnCoordinatorNameTest.kt @@ -18,9 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.disambiguate import org.junit.Assert.assertEquals import org.junit.Test diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsRenderTest.kt new file mode 100644 index 0000000000..9c5b1c853a --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsRenderTest.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingAccess +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState +import com.vitorpamplona.amethyst.commons.ui.theme.AmethystPreviewTheme +import org.jetbrains.skia.EncodedImageFormat +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.File +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Renders the Now Playing settings headlessly in both themes. Like the other render tests it + * checks structure, not exact pixels: every string resolves (a missing one throws here) and the + * screen follows the theme. Set NOW_PLAYING_RENDER_DIR to also write the PNGs for a human to look at. + */ +class NowPlayingSettingsRenderTest { + private val width = 820 + private val height = 1900 + + private class FakeAccess( + val granted: Boolean, + ) : NowPlayingAccess { + override fun hasAccess() = granted + + override fun requestAccess() {} + } + + private fun state(settings: NowPlayingSettings) = NowPlayingSettingsState().apply { restore(settings) } + + private val apps = + mapOf( + "com.spotify.music" to "Spotify", + "com.google.android.apps.youtube.music" to "YouTube Music", + "de.danoeh.antennapod" to "AntennaPod", + ) + + private val scenarios: Map<String, @Composable () -> Unit> = + mapOf( + "off" to { NowPlayingSettingsContent(state(NowPlayingSettings()), FakeAccess(false)) }, + "needs-access" to { + NowPlayingSettingsContent(state(NowPlayingSettings(shareInApp = true, shareOtherApps = true)), FakeAccess(false)) + }, + "on" to { + NowPlayingSettingsContent( + state( + NowPlayingSettings( + shareInApp = true, + shareOtherApps = true, + knownApps = apps, + blockedApps = setOf("com.google.android.apps.youtube.music"), + ), + ), + FakeAccess(true), + preview = { isOn -> + NowPlayingPreviewCard( + avatar = { MonogramBadge("Vitor", size = 44, shape = CircleShape) }, + name = "Vitor", + liveStatus = "One More Time - Daft Punk", + isOn = isOn, + ) + }, + ) + }, + "desktop" to { + NowPlayingSettingsContent( + state(NowPlayingSettings(shareOtherApps = true, knownApps = mapOf("spotify" to "Spotify", "vlc" to "VLC media player"))), + access = null, + showInApp = false, + ) + }, + ) + + private fun render( + dark: Boolean, + content: @Composable () -> Unit, + ): BufferedImage { + val scene = + ImageComposeScene(width = width, height = height, density = Density(2f)) { + AmethystPreviewTheme(dark = dark) { + Column( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + content() + } + } + } + return try { + val png = scene.render().encodeToData(EncodedImageFormat.PNG)!!.bytes + System.getenv("NOW_PLAYING_RENDER_DIR")?.let { dir -> + val name = scenarios.entries.firstOrNull { it.value === content }?.key ?: return@let + File(dir, "now-playing-$name-${if (dark) "dark" else "light"}.png").writeBytes(png) + } + ImageIO.read(ByteArrayInputStream(png)) + } finally { + scene.close() + } + } + + private fun BufferedImage.distinctColours(): Int { + val seen = mutableSetOf<Int>() + for (x in 0 until width step 3) { + for (y in 0 until height step 3) seen += getRGB(x, y) + } + return seen.size + } + + @Test + fun theAvatarCornersAreNotClipped() { + // ClickableUserPicture draws the follow mark in the top-right corner of its square box, + // outside the round picture. A circular clip around the slot used to cut it off. + val marker = Color(0xFFFF00FF) + val image = + render(false) { + NowPlayingPreviewCard( + avatar = { + Box(Modifier.fillMaxSize()) { + Box(Modifier.size(12.dp).align(Alignment.TopEnd).background(marker)) + } + }, + name = "Vitor", + liveStatus = null, + isOn = true, + ) + } + + var markerPixels = 0 + for (x in 0 until width) { + for (y in 0 until height) { + if (image.getRGB(x, y) == marker.toArgb()) markerPixels++ + } + } + // 12dp at density 2 is a 24x24 square: all of it must reach the screen. + assertTrue(markerPixels >= 24 * 24 * 95 / 100, "only $markerPixels of ${24 * 24} corner pixels were drawn") + } + + @Test + fun everyStateRendersInBothThemes() { + scenarios.values.forEach { content -> + val light = render(false, content) + val dark = render(true, content) + + assertTrue(light.distinctColours() > 20, "the light screen drew ${light.distinctColours()} colours") + assertTrue(dark.distinctColours() > 20, "the dark screen drew ${dark.distinctColours()} colours") + assertTrue(light.getRGB(2, 2) != dark.getRGB(2, 2), "the screen ignored the theme") + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 88859f79c6..42ceaf2b33 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -97,6 +97,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccoun import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.NowPlayingSettingsContent import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady import com.vitorpamplona.amethyst.commons.wot.LocalWoTService import com.vitorpamplona.amethyst.desktop.account.AccountManager @@ -109,6 +110,9 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher +import com.vitorpamplona.amethyst.desktop.nowPlaying.DesktopNowPlayingCoordinator +import com.vitorpamplona.amethyst.desktop.nowPlaying.DesktopNowPlayingSettings +import com.vitorpamplona.amethyst.desktop.nowPlaying.OsNowPlayingReader import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore @@ -1459,6 +1463,18 @@ private fun AppInner( onDispose { iAccount.wotService.close() } } + // Shares what other apps on this computer play as the account's NIP-38 + // music status, once the user turns it on in Settings > Now Playing. + if (!account.isReadOnly) { + LaunchedEffect(iAccount) { + DesktopNowPlayingCoordinator( + settings = DesktopNowPlayingSettings.forAccount(account.pubKeyHex), + signer = iAccount.signer, + publishEvent = { relayManager.publish(it, iAccount.outboxHomeRelays()) }, + ).run() + } + } + // Lazy-load Namecoin services. The Core RPC HTTP // client is sourced from the Tor-aware DesktopHttpClient // singleton so .onion RPC URLs route through the @@ -2724,6 +2740,35 @@ private fun settingsEntries( }, ) + if (OsNowPlayingReader.forThisOs() != null) { + add( + SettingsEntry( + SettingsMeta( + id = "nowPlaying", + icon = MaterialSymbols.MusicNote, + title = "Now Playing", + subtitle = "Share what you listen to as your status", + keywords = + persistentListOf( + "music", + "listening", + "status", + "song", + "podcast", + "spotify", + "nip-38", + ), + ), + ) { + NowPlayingSettingsContent( + state = DesktopNowPlayingSettings.forAccount(account.pubKeyHex), + access = null, + showInApp = false, + ) + }, + ) + } + add( SettingsEntry( SettingsMeta( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingCoordinator.kt new file mode 100644 index 0000000000..16da363837 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingCoordinator.kt @@ -0,0 +1,96 @@ +/* + * 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.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction +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.NowPlayingSettingsState +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingStatusService +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * Desktop: shares what another app on this computer is playing as the account's NIP-38 music + * status. The OS is polled every [pollMs] while something plays ([idlePollMs] while nothing + * does), and only while the account has + * [com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings.shareOtherApps] + * on, so nothing is spawned (and macOS never asks for Automation access) until the user opts in. + * + * Desktop has no music or podcast player of its own, so there is no in-app source here. + */ +class DesktopNowPlayingCoordinator( + private val settings: NowPlayingSettingsState, + private val signer: NostrSigner, + private val publishEvent: (Event) -> Unit, + private val reader: OsNowPlayingReader? = OsNowPlayingReader.forThisOs(), + private val pollMs: Long = 15_000, + private val idlePollMs: Long = 30_000, +) { + /** Runs until the calling coroutine (the logged-in account's composition) is cancelled. */ + suspend fun run() { + val reader = reader ?: return + + coroutineScope { + val otherApps = MutableStateFlow<NowPlaying?>(null) + + launch { + settings.flow + .map { it.shareOtherApps } + .distinctUntilChanged() + .collectLatest { enabled -> + if (!enabled) { + otherApps.value = null + return@collectLatest + } + while (true) { + val track = reader.read() + // Keep the last value when only the position moved, so nothing downstream re-runs. + if (track == null || !track.isSameMoment(otherApps.value)) otherApps.value = track + // Each poll spawns processes (PowerShell on Windows is the heavy one), so + // look less often while nothing plays; a new song shows up within 30s. + delay(if (track == null) idlePollMs else pollMs) + } + } + } + + val publisher = + NowPlayingPublisher( + publish = { track, expiration -> publishEvent(UserStatusAction.createMusic(track, expiration, signer)) }, + clear = { publishEvent(UserStatusAction.clearMusic(signer)) }, + ) + + NowPlayingStatusService( + settings = settings.flow, + sources = listOf(otherApps), + onOtherAppSeen = { settings.rememberApp(it.id, it.label) }, + publisher = publisher, + ).run() + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingSettings.kt new file mode 100644 index 0000000000..a5c7af3dc1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingSettings.kt @@ -0,0 +1,78 @@ +/* + * 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.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState +import com.vitorpamplona.amethyst.commons.model.preferences.NowPlayingSettingsStore +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import java.util.prefs.Preferences + +/** + * Desktop's per-account [NowPlayingSettingsState]s, persisted with `java.util.prefs` like the + * rest of the desktop preferences (the DataStore-backed `NowPlayingSettingsStore` is Android's). + * One state per pubkey for the life of the process, so the settings card and the publisher share it. + */ +object DesktopNowPlayingSettings { + private val prefs: Preferences = Preferences.userNodeForPackage(DesktopNowPlayingSettings::class.java) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val states = mutableMapOf<HexKey, NowPlayingSettingsState>() + + @Synchronized + fun forAccount(pubKeyHex: HexKey): NowPlayingSettingsState = + states.getOrPut(pubKeyHex) { + NowPlayingSettingsState().also { state -> + state.restore(load(pubKeyHex)) + scope.launch { state.flow.drop(1).collect { save(pubKeyHex, it) } } + } + } + + private fun key( + pubKeyHex: HexKey, + name: String, + ) = "nowPlaying.${pubKeyHex.take(16)}.$name" + + private fun load(pubKeyHex: HexKey) = + NowPlayingSettings( + shareInApp = false, + shareOtherApps = prefs.getBoolean(key(pubKeyHex, "shareOtherApps"), false), + blockedApps = split(prefs.get(key(pubKeyHex, "blockedApps"), "")).toSet(), + knownApps = NowPlayingSettingsStore.decodeApps(split(prefs.get(key(pubKeyHex, "knownApps"), "")).toSet()), + ) + + private fun save( + pubKeyHex: HexKey, + settings: NowPlayingSettings, + ) { + prefs.putBoolean(key(pubKeyHex, "shareOtherApps"), settings.shareOtherApps) + prefs.put(key(pubKeyHex, "blockedApps"), settings.blockedApps.joinToString(LINE)) + prefs.put(key(pubKeyHex, "knownApps"), NowPlayingSettingsStore.encodeApps(settings.knownApps).joinToString(LINE)) + } + + private fun split(value: String) = value.split(LINE).filter { it.isNotEmpty() } + + private const val LINE = "\n" +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MacNowPlayingReader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MacNowPlayingReader.kt new file mode 100644 index 0000000000..aca2f75602 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MacNowPlayingReader.kt @@ -0,0 +1,98 @@ +/* + * 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.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * macOS: the system-wide "Now Playing" is a private framework apps can no longer read (macOS + * 15.4+), so this asks the two players that matter most, Spotify and Apple Music, over + * AppleScript. Each is only asked while it is running: addressing an app that is not running + * would launch it. The first time, macOS asks the user to allow Amethyst to control the player. + */ +class MacNowPlayingReader( + private val run: suspend (List<String>) -> String? = { runCommand(it) }, + private val nowSeconds: () -> Long = TimeUtils::now, +) : OsNowPlayingReader { + private class Player( + val bundleId: String, + val label: String, + val executable: String, + // Spotify reports the track duration in milliseconds, Music in seconds. + val durationInMs: Boolean, + ) + + override suspend fun read(): NowPlaying? { + val running = run(listOf("ps", "-A", "-o", "comm=")) ?: return null + + for (player in PLAYERS) { + if (!running.contains(player.executable)) continue + val output = run(listOf("osascript", "-e", script(player))) ?: continue + return parse(player.bundleId, player.label, output, player.durationInMs, nowSeconds()) ?: continue + } + + return null + } + + companion object { + private val PLAYERS = + listOf( + Player("com.spotify.client", "Spotify", "Spotify.app/Contents/MacOS/Spotify", durationInMs = true), + Player("com.apple.Music", "Music", "Music.app/Contents/MacOS/Music", durationInMs = false), + ) + + private fun script(player: Player) = + """ + tell application id "${player.bundleId}" + if player state is playing then + return (name of current track) & tab & (artist of current track) & tab & (duration of current track) & tab & (player position) + end if + end tell + return "" + """.trimIndent() + + /** Parses `title<TAB>artist<TAB>duration<TAB>position seconds`; numbers may use a decimal comma. */ + fun parse( + id: String, + label: String, + output: String, + durationInMs: Boolean, + nowSeconds: Long, + ): NowPlaying? { + val fields = output.trimEnd('\n', '\r').split('\t') + if (fields.size < 4) return null + + val title = fields[0].ifBlank { null } ?: return null + val duration = fields[2].replace(',', '.').toDoubleOrNull() + val durationMs = duration?.let { if (durationInMs) it.toLong() else (it * 1000).toLong() } + val positionMs = fields[3].replace(',', '.').toDoubleOrNull()?.let { (it * 1000).toLong() } ?: 0L + + return NowPlaying( + title = title, + artist = fields[1].ifBlank { null }, + source = NowPlayingSource.OtherApp(id, label), + endsAt = NowPlaying.endsAt(nowSeconds, durationMs, positionMs), + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MprisNowPlayingReader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MprisNowPlayingReader.kt new file mode 100644 index 0000000000..9290de3642 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MprisNowPlayingReader.kt @@ -0,0 +1,205 @@ +/* + * 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.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Linux/BSD: asks every MPRIS player on the session bus (Spotify, VLC, Rhythmbox, browsers, ...) + * what it is playing, through the `dbus-send` tool that ships with D-Bus itself, so no D-Bus + * library is linked in. + */ +class MprisNowPlayingReader( + private val run: suspend (List<String>) -> String? = { runCommand(it) }, + private val nowSeconds: () -> Long = TimeUtils::now, +) : OsNowPlayingReader { + // A player's name never changes while its bus name lives; asking once saves a process per poll. + private val identities = mutableMapOf<String, String?>() + + override suspend fun read(): NowPlaying? { + val names = run(LIST_NAMES)?.let(MprisParser::playerNames).orEmpty() + identities.keys.retainAll(names.toSet()) + + for (busName in names) { + val properties = run(getAll(busName))?.let(MprisParser::properties) ?: continue + if (properties["PlaybackStatus"]?.firstOrNull() != "Playing") continue + + val identity = + identities.getOrPut(busName) { + run(getIdentity(busName))?.let(MprisParser::properties)?.get("Identity")?.firstOrNull() + } + return MprisParser.toNowPlaying(busName, identity, properties, nowSeconds()) + } + + return null + } + + companion object { + private val LIST_NAMES = + listOf( + "dbus-send", + "--session", + "--print-reply", + "--dest=org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus.ListNames", + ) + + private fun getAll(busName: String) = + listOf( + "dbus-send", + "--session", + "--print-reply", + "--dest=$busName", + "/org/mpris/MediaPlayer2", + "org.freedesktop.DBus.Properties.GetAll", + "string:org.mpris.MediaPlayer2.Player", + ) + + private fun getIdentity(busName: String) = + listOf( + "dbus-send", + "--session", + "--print-reply", + "--dest=$busName", + "/org/mpris/MediaPlayer2", + "org.freedesktop.DBus.Properties.GetAll", + "string:org.mpris.MediaPlayer2", + ) + } +} + +/** Parses the text `dbus-send --print-reply` prints for the MPRIS calls above. */ +object MprisParser { + private const val PREFIX = "org.mpris.MediaPlayer2." + private val INSTANCE_SUFFIX = Regex("""\.instance_?\d+(_\d+)?$""") + + fun playerNames(output: String): List<String> = + output + .lineSequence() + .map { it.trim() } + .filter { it.startsWith("string \"$PREFIX") } + .map { unquote(it.removePrefix("string ")) } + .toList() + + /** The player id the block list keys on: `org.mpris.MediaPlayer2.vlc.instance123` is `vlc`. */ + fun playerId(busName: String): String = busName.removePrefix(PREFIX).replace(INSTANCE_SUFFIX, "") + + /** + * Flattens every `dict entry` of a GetAll reply, nested ones included (the track metadata is + * a dictionary inside the `Metadata` entry), into key -> values. Scalars have one value; + * string arrays such as `xesam:artist` keep all of them. + */ + fun properties(output: String): Map<String, List<String>> { + val result = mutableMapOf<String, MutableList<String>>() + var expectKey = false + var key: String? = null + var arrayKey: String? = null + + output.lineSequence().map { it.trim() }.forEach { line -> + when { + line.startsWith("dict entry(") -> { + expectKey = true + } + + expectKey && line.startsWith("string ") -> { + key = unquote(line.removePrefix("string ")) + expectKey = false + } + + line.startsWith("variant") -> { + val currentKey = key ?: return@forEach + val value = line.removePrefix("variant").trim() + if (value.startsWith("array [")) { + // Either a string list (artists) or a nested dictionary (Metadata); + // a nested dictionary's entries are picked up by the branches above. + arrayKey = currentKey + } else { + scalar(value)?.let { result.getOrPut(currentKey) { mutableListOf() }.add(it) } + } + key = null + } + + line == "]" -> { + arrayKey = null + } + + arrayKey != null && line.startsWith("string ") -> { + result.getOrPut(arrayKey!!) { mutableListOf() }.add(unquote(line.removePrefix("string "))) + } + } + } + + return result + } + + fun toNowPlaying( + busName: String, + identity: String?, + properties: Map<String, List<String>>, + nowSeconds: Long, + ): NowPlaying? { + val title = properties["xesam:title"]?.firstOrNull()?.ifBlank { null } ?: return null + val artist = + properties["xesam:artist"]?.filter { it.isNotBlank() }?.joinToString(", ")?.ifBlank { null } + ?: properties["xesam:albumArtist"]?.firstOrNull()?.ifBlank { null } + + // MPRIS times are microseconds. + val durationMs = properties["mpris:length"]?.firstOrNull()?.toLongOrNull()?.div(1000) + val positionMs = properties["Position"]?.firstOrNull()?.toLongOrNull()?.div(1000) ?: 0L + val rate = properties["Rate"]?.firstOrNull()?.toFloatOrNull() ?: 1f + + val id = playerId(busName) + val label = identity?.ifBlank { null } ?: id.replaceFirstChar { it.uppercase() } + + return NowPlaying( + title = title, + artist = artist, + source = NowPlayingSource.OtherApp(id, label), + endsAt = NowPlaying.endsAt(nowSeconds, durationMs, positionMs, rate), + url = properties["xesam:url"]?.firstOrNull()?.let(::shareableUrl), + ) + } + + /** + * Only links that name a track are shared. Browsers fill `xesam:url` with the page of whatever + * tab is playing, and a local player with a file path: neither belongs in a public status. + */ + fun shareableUrl(url: String): String? = url.takeIf { SHAREABLE_URL.matches(it) } + + private val SHAREABLE_URL = Regex("""^https://open\.spotify\.com/(intl-[a-z-]+/)?(track|episode)/[A-Za-z0-9]+(\?.*)?$""") + + /** `string "x"`, `object path "/x"`, `int64 5`, `double 1`, `boolean true` -> the value. */ + private fun scalar(value: String): String? = + when { + value.startsWith("string ") -> unquote(value.removePrefix("string ")) + value.startsWith("object path ") -> unquote(value.removePrefix("object path ")) + else -> value.substringAfter(' ', "").ifBlank { null } + } + + private fun unquote(value: String): String { + val start = value.indexOf('"') + val end = value.lastIndexOf('"') + return if (start >= 0 && end > start) value.substring(start + 1, end) else value + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt new file mode 100644 index 0000000000..f8aad95b00 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt @@ -0,0 +1,92 @@ +/* + * 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.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +/** Reads what the OS says another app is playing right now. */ +interface OsNowPlayingReader { + /** The track a desktop app is playing, or null when nothing is (or the OS cannot say). */ + suspend fun read(): NowPlaying? + + companion object { + /** The reader for this OS, or null where there is none. */ + fun forThisOs(osName: String = System.getProperty("os.name").orEmpty()): OsNowPlayingReader? { + val os = osName.lowercase() + return when { + os.contains("linux") || os.contains("bsd") -> MprisNowPlayingReader() + os.contains("mac") -> MacNowPlayingReader() + os.contains("windows") -> WindowsNowPlayingReader() + else -> null + } + } + } +} + +/** + * Runs a short-lived command and returns its stdout, or null if it failed or took too long. + * Interruptible, so turning sharing off or closing the app does not wait out a slow command, and + * the process never outlives the call. + */ +internal suspend fun runCommand( + command: List<String>, + timeoutSeconds: Long = 5, +): String? = + runInterruptible(Dispatchers.IO) { + var process: Process? = null + try { + val started = + ProcessBuilder(command) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + process = started + started.outputStream.close() + // Read while the command runs: `ps -A -o comm=` prints ~100 KB on a busy Mac, past the + // pipe buffer, so waiting for the exit before reading deadlocked until the timeout and + // the reader never saw a player. The watchdog still kills a command that hangs, which + // also ends the read. + val watchdog = + thread(isDaemon = true, name = "now-playing-command-timeout") { + try { + if (!started.waitFor(timeoutSeconds, TimeUnit.SECONDS)) started.destroyForcibly() + } catch (_: InterruptedException) { + // The command finished first. + } + } + val output = started.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val exited = started.waitFor(timeoutSeconds, TimeUnit.SECONDS) + watchdog.interrupt() + if (!exited || started.exitValue() != 0) null else output + } catch (e: InterruptedException) { + throw e + } catch (e: Exception) { + // Missing binary (no dbus-send, no osascript) or a denied permission: nothing to report. + Log.d("OsNowPlayingReader") { "${command.first()} failed: ${e.message}" } + null + } finally { + process?.takeIf { it.isAlive }?.destroyForcibly() + } + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/WindowsNowPlayingReader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/WindowsNowPlayingReader.kt new file mode 100644 index 0000000000..a48ee1e5b9 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/WindowsNowPlayingReader.kt @@ -0,0 +1,108 @@ +/* + * 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.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource +import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.Base64 + +/** + * Windows: reads the system media transport controls (the media flyout every player feeds: + * Spotify, the Media Player app, browsers, ...) through the WinRT + * `GlobalSystemMediaTransportControlsSessionManager`, called from the built-in Windows PowerShell + * since the JVM has no WinRT bridge. + */ +class WindowsNowPlayingReader( + private val run: suspend (List<String>) -> String? = { runCommand(it, timeoutSeconds = 10) }, + private val nowSeconds: () -> Long = TimeUtils::now, +) : OsNowPlayingReader { + override suspend fun read(): NowPlaying? { + val output = run(listOf("powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", ENCODED_SCRIPT)) ?: return null + return parse(output, nowSeconds()) + } + + companion object { + private val SCRIPT = + """ + ${'$'}ErrorActionPreference = 'Stop' + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + Add-Type -AssemblyName System.Runtime.WindowsRuntime + ${'$'}asTask = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { ${'$'}_.Name -eq 'AsTask' -and ${'$'}_.GetParameters().Count -eq 1 -and ${'$'}_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0] + function Await(${'$'}op, [Type]${'$'}type) { ${'$'}task = ${'$'}asTask.MakeGenericMethod(${'$'}type).Invoke(${'$'}null, @(${'$'}op)); ${'$'}task.Wait(-1) | Out-Null; ${'$'}task.Result } + [void][Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager, Windows.Media.Control, ContentType = WindowsRuntime] + ${'$'}manager = Await ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]::RequestAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]) + foreach (${'$'}session in ${'$'}manager.GetSessions()) { + if (${'$'}session.GetPlaybackInfo().PlaybackStatus -ne 'Playing') { continue } + ${'$'}props = Await (${'$'}session.TryGetMediaPropertiesAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionMediaProperties]) + ${'$'}timeline = ${'$'}session.GetTimelineProperties() + ${'$'}length = [long](${'$'}timeline.EndTime - ${'$'}timeline.StartTime).TotalMilliseconds + ${'$'}position = [long]${'$'}timeline.Position.TotalMilliseconds + [Console]::Out.WriteLine((${'$'}session.SourceAppUserModelId, ${'$'}props.Title, ${'$'}props.Artist, ${'$'}length, ${'$'}position) -join "`t") + break + } + """.trimIndent() + + // -EncodedCommand takes UTF-16LE in Base64, which sidesteps every quoting rule of the command line. + private val ENCODED_SCRIPT: String = Base64.getEncoder().encodeToString(SCRIPT.toByteArray(Charsets.UTF_16LE)) + + /** Parses `appId<TAB>title<TAB>artist<TAB>lengthMs<TAB>positionMs`. */ + fun parse( + output: String, + nowSeconds: Long, + ): NowPlaying? { + val fields = + output + .lineSequence() + .map { it.trimEnd('\r') } + .firstOrNull { it.isNotBlank() } + ?.split('\t') ?: return null + if (fields.size < 5) return null + + val appId = fields[0].ifBlank { null } ?: return null + val title = fields[1].ifBlank { null } ?: return null + val durationMs = fields[3].toLongOrNull()?.takeIf { it > 0 } + val positionMs = fields[4].toLongOrNull() ?: 0L + + return NowPlaying( + title = title, + artist = fields[2].ifBlank { null }, + source = NowPlayingSource.OtherApp(appId, appLabel(appId)), + endsAt = NowPlaying.endsAt(nowSeconds, durationMs, positionMs), + ) + } + + /** + * A readable name for an app user model id: `Spotify.exe` is `Spotify`, + * `Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic` is `ZuneMusic`. + */ + fun appLabel(appId: String): String { + val app = appId.substringAfterLast('!').substringBefore('_') + val name = + app + .removeSuffix(".exe") + .removeSuffix(".EXE") + .substringAfterLast('\\') + .substringAfterLast('.') + return name.ifBlank { appId } + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt new file mode 100644 index 0000000000..36b29a93cc --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt @@ -0,0 +1,240 @@ +/* + * 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.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OsNowPlayingReaderTest { + private val listNames = + """ + method return time=1700000000.1 sender=org.freedesktop.DBus -> destination=:1.99 serial=3 reply_serial=2 + array [ + string "org.freedesktop.DBus" + string ":1.7" + string "org.mpris.MediaPlayer2.vlc.instance4242" + string "org.mpris.MediaPlayer2.spotify" + ] + """.trimIndent() + + private val vlcPaused = + """ + method return time=1700000000.2 sender=:1.50 -> destination=:1.99 serial=10 reply_serial=2 + array [ + dict entry( + string "PlaybackStatus" + variant string "Paused" + ) + ] + """.trimIndent() + + private val spotifyPlaying = + """ + method return time=1700000000.3 sender=:1.51 -> destination=:1.99 serial=11 reply_serial=2 + array [ + dict entry( + string "PlaybackStatus" + variant string "Playing" + ) + dict entry( + string "Metadata" + variant array [ + dict entry( + string "mpris:trackid" + variant object path "/com/spotify/track/0DiWol3AO6WpXZgp0goxAV" + ) + dict entry( + string "mpris:length" + variant uint64 320000000 + ) + dict entry( + string "xesam:artist" + variant array [ + string "Daft Punk" + string "Romanthony" + ] + ) + dict entry( + string "xesam:title" + variant string "One More Time "Live" – Café" + ) + dict entry( + string "xesam:url" + variant string "https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV" + ) + ] + ) + dict entry( + string "Position" + variant int64 20000000 + ) + dict entry( + string "Rate" + variant double 1 + ) + ] + """.trimIndent() + + private val spotifyIdentity = + """ + method return time=1700000000.4 sender=:1.51 -> destination=:1.99 serial=12 reply_serial=2 + array [ + dict entry( + string "Identity" + variant string "Spotify" + ) + dict entry( + string "SupportedMimeTypes" + variant array [ + ] + ) + ] + """.trimIndent() + + @Test + fun mprisFindsThePlayingPlayer() = + runTest { + val reader = + MprisNowPlayingReader( + run = { command -> + val dest = command.firstOrNull { it.startsWith("--dest=") }?.removePrefix("--dest=") + when { + dest == "org.freedesktop.DBus" -> listNames + dest == "org.mpris.MediaPlayer2.vlc.instance4242" -> vlcPaused + command.last() == "string:org.mpris.MediaPlayer2.Player" -> spotifyPlaying + else -> spotifyIdentity + } + }, + nowSeconds = { 1_000L }, + ) + + val track = reader.read()!! + + assertEquals("One More Time \"Live\" – Café", track.title) + assertEquals("Daft Punk, Romanthony", track.artist) + assertEquals(NowPlayingSource.OtherApp("spotify", "Spotify"), track.source) + // 320s long, 20s in. + assertEquals(1_000L + 300 + 1, track.endsAt) + assertEquals("https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV", track.url) + } + + /** + * Runs the real reader, `dbus-send` included, against a live session bus. Opt-in: start an + * MPRIS player (or a fake one) under `dbus-run-session` and set MPRIS_LIVE_TITLE to its title. + */ + @Test + fun mprisReadsALiveSessionBus() = + runTest { + val expectedTitle = System.getenv("MPRIS_LIVE_TITLE") ?: return@runTest + assertEquals(expectedTitle, MprisNowPlayingReader().read()?.title) + } + + @Test + fun mprisReportsNothingWhenNoneIsPlaying() = + runTest { + val reader = + MprisNowPlayingReader( + run = { command -> if (command.any { it == "--dest=org.freedesktop.DBus" }) listNames else vlcPaused }, + ) + + assertNull(reader.read()) + } + + @Test + fun mprisSharesTrackLinksButNeverPagesOrFiles() { + assertEquals( + "https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV", + MprisParser.shareableUrl("https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV"), + ) + assertEquals( + "https://open.spotify.com/intl-pt/episode/4rOoJ6Egrf8K2IrywzwOMk?si=abc", + MprisParser.shareableUrl("https://open.spotify.com/intl-pt/episode/4rOoJ6Egrf8K2IrywzwOMk?si=abc"), + ) + // Chromium reports the playing tab's page; VLC the file it opened. + assertNull(MprisParser.shareableUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")) + assertNull(MprisParser.shareableUrl("https://mail.example.com/inbox/secret-thread")) + assertNull(MprisParser.shareableUrl("file:///home/me/Music/song.mp3")) + assertNull(MprisParser.shareableUrl("https://open.spotify.com.evil.example/track/abc")) + } + + @Test + fun mprisPlayerIdDropsTheInstanceSuffix() { + assertEquals("vlc", MprisParser.playerId("org.mpris.MediaPlayer2.vlc.instance4242")) + assertEquals("chromium", MprisParser.playerId("org.mpris.MediaPlayer2.chromium.instance_1_23")) + assertEquals("spotify", MprisParser.playerId("org.mpris.MediaPlayer2.spotify")) + } + + @Test + fun macParsesSpotifyAndMusicUnits() { + val spotify = MacNowPlayingReader.parse("com.spotify.client", "Spotify", "Song\tBand\t200000\t50,5\n", durationInMs = true, nowSeconds = 0)!! + assertEquals("Song", spotify.title) + assertEquals("Band", spotify.artist) + assertEquals(149L + 1, spotify.endsAt) + + val music = MacNowPlayingReader.parse("com.apple.Music", "Music", "Song\tBand\t200.0\t50.0\n", durationInMs = false, nowSeconds = 0)!! + assertEquals(150L + 1, music.endsAt) + + assertNull(MacNowPlayingReader.parse("com.apple.Music", "Music", "\n", durationInMs = false, nowSeconds = 0)) + } + + @Test + fun windowsParsesTheScriptOutput() { + val track = WindowsNowPlayingReader.parse("Spotify.exe\tSong\tBand\t200000\t50000\r\n", nowSeconds = 0)!! + + assertEquals("Song - Band", track.statusText()) + assertEquals(NowPlayingSource.OtherApp("Spotify.exe", "Spotify"), track.source) + assertEquals(150L + 1, track.endsAt) + + assertNull(WindowsNowPlayingReader.parse("", nowSeconds = 0)) + } + + @Test + fun windowsAppLabels() { + assertEquals("Spotify", WindowsNowPlayingReader.appLabel("Spotify.exe")) + assertEquals("ZuneMusic", WindowsNowPlayingReader.appLabel("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic")) + assertEquals("308046B0AF4A39CB", WindowsNowPlayingReader.appLabel("308046B0AF4A39CB")) + } + + private val hasShell = File("/bin/sh").canExecute() + + /** `ps -A -o comm=` prints ~100 KB on a busy Mac; reading only after the exit deadlocked on the pipe. */ + @Test + fun runCommandReturnsOutputLargerThanThePipeBuffer() = + runTest { + if (!hasShell) return@runTest + val output = runCommand(listOf("/bin/sh", "-c", "i=0; while [ \$i -lt 4000 ]; do echo 0123456789012345678901234567890123456789012345678; i=\$((i+1)); done")) + assertEquals(4000 * 50, output?.length) + } + + @Test + fun runCommandGivesUpOnAHungCommand() = + runTest { + if (!hasShell) return@runTest + val started = System.currentTimeMillis() + assertNull(runCommand(listOf("/bin/sh", "-c", "sleep 30"), timeoutSeconds = 1)) + assertTrue(System.currentTimeMillis() - started < 10_000, "the timeout must end the call") + } +} diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 4551942b1c..735dd34daa 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -81,6 +81,7 @@ { "user": "vitorpamplona", "languages": [ + "Arabic", "Arabic, Saudi Arabia", "Bengali", "Chinese Simplified", diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImeta.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImeta.kt new file mode 100644 index 0000000000..8357b4e686 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImeta.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.media + +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag + +/** + * Buzz's relay validates every `imeta` tag on ingest (`crates/buzz-relay/src/handlers/imeta.rs`) + * and refuses the WHOLE event on the first key it does not know or a repeated singleton key. + * Generic NIP-92 attachments carry extras Buzz never accepts (`ox`, `content-warning`, …), so a + * message bound for a Buzz workspace keeps only the keys Buzz allows, each singleton once. + */ +object BuzzImeta { + /** `url` is the tag's anchor; it is not part of [IMetaTag.properties]. */ + val ALLOWED_KEYS = setOf("m", "x", "size", "dim", "blurhash", "alt", "thumb", "fallback", "duration", "bitrate", "image", "filename") + + /** Every allowed key but `fallback` may appear only once. */ + private const val REPEATABLE_KEY = "fallback" + + fun sanitize(tag: IMetaTag): IMetaTag = + IMetaTag( + tag.url, + tag.properties + .filterKeys { it in ALLOWED_KEYS } + .mapValues { (key, values) -> if (key == REPEATABLE_KEY) values else values.take(1) } + .filterValues { it.isNotEmpty() }, + ) + + fun sanitize(tags: List<IMetaTag>): List<IMetaTag> = tags.map(::sanitize) + + /** + * Buzz's clients draw an attachment only where the body links it in markdown — `![image](url)` + * or `![video](url)`, the form their composer writes — and show a bare URL as a plain link. So + * each attachment URL that stands alone in [content] becomes that markdown; one already inside + * markdown is left alone. + */ + fun markdownMediaBody( + content: String, + attachments: List<IMetaTag>, + ): String { + var out = content + attachments.forEach { tag -> + val url = tag.url + if (url.isBlank() || out.contains("]($url)")) return@forEach + val label = if (tag.properties["m"]?.firstOrNull()?.startsWith("video/") == true) "video" else "image" + out = out.replace(url, "![$label]($url)") + } + return out + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizer.kt new file mode 100644 index 0000000000..d3a228fdc7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizer.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.media + +/** + * Makes an image acceptable to a Buzz workspace's media server, which refuses (HTTP 422) any blob + * carrying a metadata channel (`buzz-media/src/validation.rs`): in a JPEG every APP1–APP13/APP15 + * segment (EXIF, XMP, ICC…) and comment, plus any APP0/APP14 that isn't the canonical JFIF/Adobe + * header; in a PNG every text/EXIF/ICC chunk and every ancillary chunk that isn't a known rendering + * hint; and in both, any bytes after the end marker. Removing those leaves the pixels untouched. + * + * Other formats, and input that doesn't parse, come back unchanged — the server stays the judge. + */ +object BuzzMediaSanitizer { + fun sanitize( + bytes: ByteArray, + mimeType: String?, + ): ByteArray = + when (mimeType?.lowercase()) { + "image/jpeg", "image/jpg" -> stripJpeg(bytes) ?: bytes + "image/png" -> stripPng(bytes) ?: bytes + else -> bytes + } + + /** True when [mimeType] is one [sanitize] rewrites. */ + fun handles(mimeType: String?): Boolean = mimeType?.lowercase() in setOf("image/jpeg", "image/jpg", "image/png") + + private fun ByteArray.u8(i: Int) = this[i].toInt() and 0xff + + /** + * Walks the JPEG marker by marker, entropy-coded scans included, so a metadata segment after a + * scan (progressive files interleave tables and scans) is dropped like one before it, and the + * file ends at the end-of-image marker that closes the last scan — not at whatever `FFD9` some + * appended payload (a motion-photo video, an extra JPEG) happens to contain. + */ + fun stripJpeg(b: ByteArray): ByteArray? { + if (b.size < 4 || b.u8(0) != 0xff || b.u8(1) != 0xd8) return null + val out = ByteBuilder(b.size) + out.write(b, 0, 2) + var i = 2 + while (i < b.size) { + if (b.u8(i) != 0xff) return null + var j = i + while (j < b.size && b.u8(j) == 0xff) j++ + if (j >= b.size) return null + val marker = b.u8(j) + when { + marker == 0xd9 -> { + out.write(EOI, 0, 2) + return out.toByteArray() + } + + marker in 0xd0..0xd7 || marker == 0x01 -> { + out.write(byteArrayOf(0xff.toByte(), marker.toByte()), 0, 2) + i = j + 1 + } + + marker == 0xd8 -> return null + + else -> { + if (j + 3 > b.size) return null + val len = (b.u8(j + 1) shl 8) or b.u8(j + 2) + if (len < 2) return null + val end = j + 1 + len + if (end > b.size) return null + if (keepJpegSegment(marker, b, j + 3, end)) { + out.write(FF, 0, 1) + out.write(b, j, end - j) + } + i = end + if (marker == 0xda) { + // Entropy-coded data runs to the next marker that isn't a stuffed byte + // (FF 00) or a restart (FF D0-D7). + val scanEnd = endOfScan(b, end) ?: return null + out.write(b, end, scanEnd - end) + i = scanEnd + } + } + } + } + return null + } + + private fun endOfScan( + b: ByteArray, + from: Int, + ): Int? { + var k = from + while (k + 1 < b.size) { + if (b.u8(k) == 0xff) { + val next = b.u8(k + 1) + if (next != 0x00 && next != 0xff && next !in 0xd0..0xd7) return k + } + k++ + } + return null + } + + private fun keepJpegSegment( + marker: Int, + b: ByteArray, + payloadStart: Int, + end: Int, + ): Boolean { + val payloadLen = end - payloadStart + return when { + marker == 0xe0 -> + payloadLen >= 14 && + b.u8(payloadStart) == 'J'.code && + b.u8(payloadStart + 1) == 'F'.code && + b.u8(payloadStart + 2) == 'I'.code && + b.u8(payloadStart + 3) == 'F'.code && + b.u8(payloadStart + 4) == 0 && + payloadLen == 14 + 3 * b.u8(payloadStart + 12) * b.u8(payloadStart + 13) + + marker == 0xee -> + payloadLen == 12 && + b.decodeToString(payloadStart, payloadStart + 5) == "Adobe" + + marker in 0xe1..0xed || marker == 0xef || marker == 0xfe -> false + + else -> true + } + } + + private val FF = byteArrayOf(0xff.toByte()) + + private val EOI = byteArrayOf(0xff.toByte(), 0xd9.toByte()) + + private val PNG_SIGNATURE = byteArrayOf(0x89.toByte(), 'P'.code.toByte(), 'N'.code.toByte(), 'G'.code.toByte(), 0x0d, 0x0a, 0x1a, 0x0a) + + // Ancillary chunks Buzz keeps: pure rendering hints. pHYs is excluded on purpose (identity channel). + private val PNG_RENDERING_CHUNKS = setOf("cHRM", "gAMA", "sBIT", "sRGB", "bKGD", "hIST", "tRNS", "sPLT", "acTL", "fcTL", "fdAT") + + fun stripPng(b: ByteArray): ByteArray? { + if (b.size < PNG_SIGNATURE.size) return null + for (k in PNG_SIGNATURE.indices) if (b[k] != PNG_SIGNATURE[k]) return null + val out = ByteBuilder(b.size) + out.write(b, 0, PNG_SIGNATURE.size) + var i = PNG_SIGNATURE.size + while (i + 12 <= b.size) { + val len = (b.u8(i) shl 24) or (b.u8(i + 1) shl 16) or (b.u8(i + 2) shl 8) or b.u8(i + 3) + if (len < 0) return null + val end = i + 12 + len + if (end > b.size) return null + val kind = b.decodeToString(i + 4, i + 8) + val ancillary = (b.u8(i + 4) and 0x20) != 0 + if (!ancillary || kind in PNG_RENDERING_CHUNKS) out.write(b, i, end - i) + i = end + if (kind == "IEND") return out.toByteArray() + } + return null + } + + private class ByteBuilder( + capacity: Int, + ) { + private var buf = ByteArray(maxOf(capacity, 16)) + private var size = 0 + + fun write( + src: ByteArray, + from: Int, + count: Int, + ) { + if (size + count > buf.size) buf = buf.copyOf(maxOf(buf.size * 2, size + count)) + src.copyInto(buf, size, from, from + count) + size += count + } + + fun toByteArray() = buf.copyOf(size) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotAnswer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotAnswer.kt new file mode 100644 index 0000000000..d1d45d920a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotAnswer.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.ballots + +import androidx.compose.runtime.Immutable + +/** One answer on a ballot: the [question] (as the ballot names it, often an id) and the [answer] chosen. */ +@Immutable +data class BallotAnswer( + val question: String, + val answer: String, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotContent.kt new file mode 100644 index 0000000000..522977068f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotContent.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.ballots + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * The JSON `content` of a [BallotEvent]. Three shapes are seen in the wild, tried in order: + * + * - `responses`: `[{question_id, value}]`, one entry per question; a value may be a string, a + * number or a boolean. + * - `ballot`: an object mapping each question to its choice. + * - `vote_choice`: a single choice with no question, read as [BallotEvent.VOTE_QUESTION]. + * + * Values that are objects, arrays or null are skipped rather than stringified. + */ +@Immutable +class BallotContent( + val answers: List<BallotAnswer>, + val proofHash: String?, +) { + companion object { + /** Parses [json], or returns null when it is not a JSON object. Never throws. */ + fun parse(json: String): BallotContent? { + if (!looksLikeJsonObject(json)) return null + + val obj = + try { + Json.parseToJsonElement(json) as? JsonObject + } catch (_: Exception) { + null + } ?: return null + + return BallotContent( + answers = answers(obj), + proofHash = (obj["proof_hash"] as? JsonPrimitive)?.takeIf { it.isString }?.content?.ifBlank { null }, + ) + } + + private fun looksLikeJsonObject(text: String): Boolean { + for (c in text) { + if (!c.isWhitespace()) return c == '{' + } + return false + } + + private fun answers(obj: JsonObject): List<BallotAnswer> { + val responses = obj["responses"] as? JsonArray + if (responses != null) { + return responses.mapNotNull { response -> + val entry = response as? JsonObject ?: return@mapNotNull null + val question = scalar(entry["question_id"]) ?: scalar(entry["question"]) ?: return@mapNotNull null + val answer = scalar(entry["value"]) ?: return@mapNotNull null + BallotAnswer(question, answer) + } + } + + val ballot = obj["ballot"] as? JsonObject + if (ballot != null) { + return ballot.mapNotNull { (question, choice) -> + scalar(choice)?.let { BallotAnswer(question, it) } + } + } + + return scalar(obj["vote_choice"])?.let { listOf(BallotAnswer(BallotEvent.VOTE_QUESTION, it)) } ?: emptyList() + } + + /** A string, number or boolean as text; null for anything else, and for blank strings. */ + private fun scalar(element: JsonElement?): String? = + if (element is JsonPrimitive && element !is JsonNull) { + element.content.ifBlank { null } + } else { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEvent.kt new file mode 100644 index 0000000000..9b6a5a884e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEvent.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.ballots + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor +import com.vitorpamplona.quartz.nip50Search.SearchableEvent + +/** + * A ballot cast in an auditable-voting app (kind 38000). + * + * App-specific and **not** defined by any NIP: kind 38000 is NIP-87's mint recommendation, and + * the voting app reuses the number. [com.vitorpamplona.quartz.utils.EventFactory] tells them + * apart by the `election` tag ([isBallot]). Amethyst only reads these — it never publishes them. + * + * The schema is derived from events seen in the wild: an `election` tag naming the election, an + * optional proof hash (`proof-hash` or `proof_hash` tag, or `proof_hash` in the content), and the + * choices as JSON `content` in one of three shapes (see [BallotContent]). The content is parsed at + * most once per event, lazily, and a malformed blob just reads as a ballot with no answers. + */ +@Immutable +class BallotEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array<Array<String>>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + private val parsedContent by lazy { BallotContent.parse(content) } + + override fun indexableContent() = + buildList { + election()?.let { add(it) } + answers().forEach { add(it.answer) } + }.joinToString("\n") + + // The read path: the same fields indexableContent() joins, handed over without + // building the joined string a scan would throw away. + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + if (!visitor.visit(election())) return + for (answer in answers()) { + if (!visitor.visit(answer.answer)) return + } + } + + fun election() = tags.election() + + /** The ballot's answers in the order they were cast; empty when the content says nothing readable. */ + fun answers(): List<BallotAnswer> = parsedContent?.answers ?: emptyList() + + fun proofHash() = tags.proofHash() ?: parsedContent?.proofHash + + companion object { + const val KIND = 38000 + + /** The question a lone `vote_choice` answers; the UI shows its own label for it. */ + const val VOTE_QUESTION = "Vote" + + /** True when a kind-38000 event is a ballot: it names the `election` it is cast in. */ + fun isBallot(tags: TagArray): Boolean = tags.fastAny { it.size > 1 && it[0] == "election" && it[1].isNotBlank() } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/TagArrayExt.kt new file mode 100644 index 0000000000..5664964db1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/TagArrayExt.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.ballots + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull + +private fun TagArray.firstNonBlankValue(name: String): String? = fastFirstNotNullOfOrNull { if (it.size > 1 && it[0] == name && it[1].isNotBlank()) it[1] else null } + +/** The election this ballot is cast in. */ +fun TagArray.election() = firstNonBlankValue("election") + +/** The ballot's proof hash, from either spelling of the tag (`proof-hash` or `proof_hash`). */ +fun TagArray.proofHash() = firstNonBlankValue("proof-hash") ?: firstNonBlankValue("proof_hash") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketDetails.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketDetails.kt new file mode 100644 index 0000000000..a8ee4c5204 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketDetails.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.predictionMarkets + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * The market fields BAO Markets writes as JSON — in the `data` tag of its current shape, or as the + * whole `content` of its BAO Fund and first shapes. Every field is optional: the shapes disagree + * on which ones they carry, and nothing here is trusted to be well-formed. + */ +@Immutable +class PredictionMarketDetails( + val title: String?, + val description: String?, + val outcomes: List<PredictionMarketOutcome>, + val category: String?, + val status: String?, + val reason: String?, + val resolution: String?, + val endDate: Long?, + val resolutionSource: String?, + val minBetSats: Long?, + val maxBetSats: Long?, + val feePercent: Double?, +) { + companion object { + /** Parses [json] into details, or null when it is not a JSON object. Never throws. */ + fun parse(json: String?): PredictionMarketDetails? { + if (json == null || !looksLikeJsonObject(json)) return null + + val obj = + try { + Json.parseToJsonElement(json) as? JsonObject + } catch (_: Exception) { + null + } ?: return null + + return PredictionMarketDetails( + title = obj.text("title") ?: obj.text("question"), + description = obj.text("description"), + outcomes = obj.outcomes(), + category = obj.text("category"), + status = obj.text("status"), + reason = obj.text("reason"), + resolution = obj.text("resolution"), + endDate = obj.long("endDate") ?: obj.long("end"), + resolutionSource = obj.text("resolutionSource"), + minBetSats = obj.long("minBetSats"), + maxBetSats = obj.long("maxBetSats"), + feePercent = obj.double("feePercent"), + ) + } + + /** Skips the parser for the social-post `content` of the current shape: JSON objects start with `{`. */ + private fun looksLikeJsonObject(text: String): Boolean { + for (c in text) { + if (!c.isWhitespace()) return c == '{' + } + return false + } + + private fun JsonObject.primitive(key: String): JsonPrimitive? = this[key] as? JsonPrimitive + + /** A string field, or null when absent, blank, null or not a string. */ + private fun JsonObject.text(key: String): String? = primitive(key)?.takeIf { it.isString }?.content?.ifBlank { null } + + private fun JsonObject.long(key: String): Long? = primitive(key)?.longOrNull + + private fun JsonObject.double(key: String): Double? = primitive(key)?.doubleOrNull + + /** + * `outcomes`: a plain string per outcome (current shape), or an object per outcome with an + * `id` and a `label` (else `name`) (BAO Fund and first shapes). Once per id, at most + * [PredictionMarketOutcome.MAX]. + */ + private fun JsonObject.outcomes(): List<PredictionMarketOutcome> { + val array = this["outcomes"] as? JsonArray ?: return emptyList() + val result = LinkedHashMap<String, PredictionMarketOutcome>(minOf(array.size, PredictionMarketOutcome.MAX)) + for (element in array) { + if (result.size >= PredictionMarketOutcome.MAX) break + val outcome = outcomeOf(element) ?: continue + if (outcome.id !in result) result[outcome.id] = outcome + } + return result.values.toList() + } + + private fun outcomeOf(element: JsonElement): PredictionMarketOutcome? = + when (element) { + is JsonPrimitive -> { + element + .takeIf { it.isString } + ?.content + ?.ifBlank { null } + ?.let { PredictionMarketOutcome(it, it) } + } + + is JsonObject -> { + val label = element.text("label") ?: element.text("name") + val id = element.text("id") ?: label + if (id == null) null else PredictionMarketOutcome(id, label ?: id) + } + + else -> { + null + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEvent.kt new file mode 100644 index 0000000000..a81262523f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEvent.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.predictionMarkets + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastForEach +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A prediction market (kind 38000), as published by BAO Markets. + * + * App-specific and **not** defined by any NIP: kind 38000 is NIP-87's mint recommendation, and + * BAO Markets reuses the number. [com.vitorpamplona.quartz.utils.EventFactory] tells the two + * apart by tags ([isPredictionMarket]). Amethyst only reads these — it never publishes them. + * + * The schema is derived from events seen in the wild, in three shapes: + * + * - **Current**: everything in tags — `market`, `status` (also `state`, `s`), `end`, `network`/`n` + * ("demo" is play money), `category`/`c`, repeated `outcome`, `resolution`, `resolution_source`, + * `min_bet`/`max_bet` (sats), `fee_percent`, `oracle`, `cancel_reason`, `client`, and a `data` + * tag holding JSON `{title, description, outcomes, …}`. `content` is a human-readable social + * post of the same words. + * - **BAO Fund**: a `title` tag, `c` = bao-fund, `n`, `pool_model`, `end`, `["outcome", id, + * label]` tags, and JSON `content` `{title, description, outcomes: [{id, label}], …}`. + * - **First**: `type`, `category`, `oracle`, `end`, `state`, `platform_fee` tags and JSON + * `content` `{title, description, outcomes: [{id, label, probability}], …}`. + * + * Tags win over JSON wherever both carry a field. The JSON is parsed at most once per event, + * lazily, and a malformed blob just reads as absent. + */ +@Immutable +class PredictionMarketEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array<Array<String>>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + private val dataDetails by lazy { PredictionMarketDetails.parse(tags.marketData()) } + + private val contentDetails by lazy { PredictionMarketDetails.parse(content) } + + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + + // The read path: the same fields indexableContent() joins, handed over without + // building the joined string a scan would throw away. + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + if (!visitor.visit(title())) return + visitor.visit(description()) + } + + fun marketId() = tags.marketId() + + /** The question being predicted. */ + fun title() = dataDetails?.title ?: tags.marketTitle() ?: contentDetails?.title + + fun description() = dataDetails?.description ?: contentDetails?.description + + /** The outcomes one can bet on, id and label: the `outcome` tags, else the JSON `outcomes`. */ + fun outcomeOptions(): List<PredictionMarketOutcome> = + tags.marketOutcomes().ifEmpty { + dataDetails?.outcomes?.ifEmpty { null } ?: contentDetails?.outcomes ?: emptyList() + } + + /** The outcomes as a reader sees them, by label. */ + fun outcomes(): List<String> = outcomeOptions().map { it.label } + + /** The winning outcome, once resolved. */ + fun resolution() = tags.marketResolution() ?: dataDetails?.resolution ?: contentDetails?.resolution + + /** When betting closes, unix seconds. */ + fun endsAt() = tags.marketEnd() ?: dataDetails?.endDate ?: contentDetails?.endDate + + fun category() = tags.marketCategory() ?: dataDetails?.category ?: contentDetails?.category + + /** `demo` (play money, most markets), `mainnet`, or whatever else the publisher names. */ + fun network() = tags.marketNetwork() + + /** True for play-money markets: nothing real is at stake. */ + fun isDemo() = network().equals(DEMO_NETWORK, ignoreCase = true) + + fun resolutionSource() = tags.marketResolutionSource() ?: dataDetails?.resolutionSource ?: contentDetails?.resolutionSource + + fun minBetSats() = tags.marketMinBet() ?: dataDetails?.minBetSats ?: contentDetails?.minBetSats + + fun maxBetSats() = tags.marketMaxBet() ?: dataDetails?.maxBetSats ?: contentDetails?.maxBetSats + + fun feePercent() = tags.marketFeePercent() ?: dataDetails?.feePercent ?: contentDetails?.feePercent + + fun cancelReason() = tags.marketCancelReason() ?: contentDetails?.reason + + fun oracle() = tags.marketOracle() + + fun client() = tags.marketClient() + + /** + * The market's status at [now] (unix seconds), or null when nothing says. + * + * The declared `status`/`state`/`s` word decides when it is one we know; a declared RESOLVED + * or CANCELLED is final. Otherwise the event's own evidence refines it: a `resolution` means + * RESOLVED, a `cancel_reason` on an undeclared market means CANCELLED, and an open market + * whose `end` has passed is CLOSED — publishers leave "active" in place long after betting + * stops. + */ + fun status(now: Long = TimeUtils.now()): PredictionMarketStatus? { + val declared = declaredStatus() + if (declared == PredictionMarketStatus.RESOLVED || declared == PredictionMarketStatus.CANCELLED) return declared + if (resolution() != null) return PredictionMarketStatus.RESOLVED + if (declared == PredictionMarketStatus.CLOSED) return declared + if (declared == null && cancelReason() != null) return PredictionMarketStatus.CANCELLED + + val end = endsAt() + return when { + end != null -> if (end > now) PredictionMarketStatus.OPEN else PredictionMarketStatus.CLOSED + else -> declared + } + } + + private fun declaredStatus(): PredictionMarketStatus? = + tags.marketStatusValues().firstNotNullOfOrNull(PredictionMarketStatus::parse) + ?: contentDetails?.status?.let(PredictionMarketStatus::parse) + + companion object { + const val KIND = 38000 + const val DEMO_NETWORK = "demo" + + /** + * True when a kind-38000 event is shaped like a prediction market: it names a `market`, + * offers at least two `outcome`s, or declares a market `type` with an `end`. Checked + * after [com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent.isMintRecommendation] + * and [com.vitorpamplona.quartz.experimental.ballots.BallotEvent.isBallot]. + */ + fun isPredictionMarket(tags: TagArray): Boolean { + var outcomes = 0 + var hasType = false + var hasEnd = false + tags.fastForEach { + if (it.size > 1) { + when (it[0]) { + "market" -> if (it[1].isNotBlank()) return true + "outcome" -> outcomes++ + "type" -> hasType = true + "end" -> hasEnd = true + } + } + } + return outcomes >= 2 || (hasType && hasEnd) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketOutcome.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketOutcome.kt new file mode 100644 index 0000000000..24ca9a1548 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketOutcome.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.predictionMarkets + +import androidx.compose.runtime.Immutable + +/** + * One answer a market can resolve to. BAO writes some as a bare word (`["outcome","YES"]`, a + * JSON string) and some as an id with a label (`["outcome","opt1","Foundry USA"]`, + * `{"id":"A","label":"Yes"}`): a `resolution` names the [id], a reader is shown the [label]. + */ +@Immutable +data class PredictionMarketOutcome( + val id: String, + val label: String, +) { + /** Whether [resolution] names this outcome — by its id, or by its label as some markets do. */ + fun isNamedBy(resolution: String?): Boolean = resolution != null && (id.equals(resolution, ignoreCase = true) || label.equals(resolution, ignoreCase = true)) + + companion object { + /** More than any market offers; a crafted event with thousands is cut here. */ + const val MAX = 64 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketStatus.kt new file mode 100644 index 0000000000..44c595f070 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketStatus.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.predictionMarkets + +/** Where a prediction market is in its life, collapsed from the many words publishers use for it. */ +enum class PredictionMarketStatus { + /** Taking bets (or, before that, being funded). */ + OPEN, + + /** No longer taking bets, not resolved yet. */ + CLOSED, + + /** The oracle has named a winning outcome. */ + RESOLVED, + + /** Voided: bets are refunded and no outcome wins. */ + CANCELLED, + ; + + companion object { + /** Maps a published `status`/`state` word to a [PredictionMarketStatus], or null when it is not one we know. */ + fun parse(value: String): PredictionMarketStatus? = + when (value.trim().lowercase()) { + "active", "open", "funding" -> OPEN + "resolving", "ended", "closed" -> CLOSED + "resolved", "settled" -> RESOLVED + "voided", "void", "cancelled", "canceled" -> CANCELLED + else -> null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/TagArrayExt.kt new file mode 100644 index 0000000000..c5b308ec43 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/TagArrayExt.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.predictionMarkets + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip01Core.core.fastForEach + +/** The first non-blank value of a tag named [name], or null. */ +private fun TagArray.firstNonBlankValue(name: String): String? = fastFirstNotNullOfOrNull { if (it.size > 1 && it[0] == name && it[1].isNotBlank()) it[1] else null } + +/** The first non-blank value among [first] and then [second], in that priority order. */ +private fun TagArray.firstNonBlankValue( + first: String, + second: String, +): String? = firstNonBlankValue(first) ?: firstNonBlankValue(second) + +fun TagArray.marketId() = firstNonBlankValue("market") + +fun TagArray.marketTitle() = firstNonBlankValue("title") + +/** The JSON blob of the `data` tag BAO Markets attaches to its current event shape. */ +fun TagArray.marketData() = firstNonBlankValue("data") + +fun TagArray.marketType() = firstNonBlankValue("type") + +/** + * The declared lifecycle state, tried by tag name in priority order: `status` first, then the + * older `state` and the one-letter `s`. BAO Markets writes all three but lets them drift apart + * (an "active" status next to an "ended" state), and `status` is the one it keeps current. + */ +fun TagArray.marketStatusValues(): List<String> = listOfNotNull(firstNonBlankValue("status"), firstNonBlankValue("state"), firstNonBlankValue("s")) + +/** When betting closes, unix seconds. */ +fun TagArray.marketEnd() = firstNonBlankValue("end")?.trim()?.toLongOrNull() + +fun TagArray.marketNetwork() = firstNonBlankValue("network", "n") + +fun TagArray.marketCategory() = firstNonBlankValue("category", "c") + +/** + * Every `outcome` tag, in order, once per id and at most [PredictionMarketOutcome.MAX]. The BAO + * Fund shape writes `["outcome", id, label]`; the current shape a bare word, its own label. + */ +fun TagArray.marketOutcomes(): List<PredictionMarketOutcome> { + val result = LinkedHashMap<String, PredictionMarketOutcome>(4) + fastForEach { + if (result.size < PredictionMarketOutcome.MAX && it.size > 1 && it[0] == "outcome" && it[1].isNotBlank() && it[1] !in result) { + result[it[1]] = PredictionMarketOutcome(it[1], it.getOrNull(2)?.ifBlank { null } ?: it[1]) + } + } + return result.values.toList() +} + +/** The winning outcome, once the market is resolved. */ +fun TagArray.marketResolution() = firstNonBlankValue("resolution") + +fun TagArray.marketResolutionSource() = firstNonBlankValue("resolution_source") + +fun TagArray.marketMinBet() = firstNonBlankValue("min_bet")?.trim()?.toLongOrNull() + +fun TagArray.marketMaxBet() = firstNonBlankValue("max_bet")?.trim()?.toLongOrNull() + +fun TagArray.marketFeePercent() = firstNonBlankValue("fee_percent")?.trim()?.toDoubleOrNull() + +fun TagArray.marketOracle() = firstNonBlankValue("oracle") + +fun TagArray.marketCancelReason() = firstNonBlankValue("cancel_reason") + +fun TagArray.marketPoolModel() = firstNonBlankValue("pool_model") + +fun TagArray.marketClient() = firstNonBlankValue("client") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt index 6b536de30b..c4046ccbfc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt @@ -29,10 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.utils.Log /** - * Listens to INostrClient's onEvent messages for caching purposes. + * Listens to INostrClient's relay messages and reports `OK`s for published events: [onRelayReceived] for an acceptance and, when given, + * [onRelayRejected] for a definitive refusal with the relay's reason. An `auth-required:` refusal + * is NOT reported — the client authenticates and resends, so it is not the relay's final answer — + * and a `duplicate:` refusal counts as an acceptance. */ class RelayInsertConfirmationCollector( val client: INostrClient, + val onRelayRejected: ((eventId: HexKey, relay: IRelayClient, reason: String) -> Unit)? = null, val onRelayReceived: (eventId: HexKey, relay: IRelayClient) -> Unit, ) { private val clientListener = @@ -42,8 +46,13 @@ class RelayInsertConfirmationCollector( msgStr: String, msg: Message, ) { - if (msg is OkMessage && msg.success) { + if (msg !is OkMessage) return + // NIP-01: "duplicate:" means the relay already has the event. Most relays send it + // with `true`, some with `false`; either way the event is there. + if (msg.success || msg.message.startsWith(DUPLICATE_PREFIX)) { onRelayReceived(msg.eventId, relay) + } else if (!msg.message.startsWith(AUTH_REQUIRED_PREFIX)) { + onRelayRejected?.invoke(msg.eventId, relay, msg.message) } } } @@ -59,3 +68,7 @@ class RelayInsertConfirmationCollector( client.removeConnectionListener(clientListener) } } + +private const val AUTH_REQUIRED_PREFIX = "auth-required:" + +private const val DUPLICATE_PREFIX = "duplicate:" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index 12e1fb959e..1adc50e470 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd @@ -311,6 +312,29 @@ class PoolRequests( } } + is NoticeMessage -> { + // A relay that refuses an over-cap REQ by NOTICE (Buzz: "REQ contains 12 filters, + // maximum is 10") never says WHICH sub it dropped, and that sub would wait for an + // EOSE forever. Once the cap is known, every sub whose last REQ on this relay went + // over it is treated as closed and re-decided, which now narrows it under the cap. + val cap = relayRefusals.onNotice(relay.url, msg.message) ?: return + relayState.forEach { subId, state -> + val cmd = + state.withLock(relay.url) { + val sent = state.currentFilters(relay.url) + if (sent != null && sent.size > cap) { + state.onClosed(relay.url) + decideCommandLocked(state, subId, relay.url) + } else { + null + } + } + if (cmd != null && cmd !is CloseCmd) { + relay.sendOrConnectAndSync(cmd) + } + } + } + is ClosedMessage -> { // Relay-wide capability tracking is independent of any single sub's lock. Run // it first so decideCommandLocked below already sees the block; if it newly diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt index 3c8d87bca4..4ba20dd7a9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt @@ -214,6 +214,23 @@ class RelayReqRefusals( fun disallowedKinds(relay: NormalizedRelayUrl): Set<Int> = disallowedKinds[relay] ?: emptySet() + /** The most filters [relay] has said it accepts in one REQ, if it has said so. */ + fun maxFilters(relay: NormalizedRelayUrl): Int? = maxFilters[relay] + + /** + * Learn a filter cap from a NOTICE. Some relays (Buzz) refuse an over-cap REQ with a NOTICE + * instead of a CLOSED, so it names no subscription. Returns the cap when this notice taught + * one, so the caller can re-send whichever REQs went over it. + */ + fun onNotice( + relay: NormalizedRelayUrl, + message: String, + ): Int? { + parseMaxFilters(message) ?: return null + learnMaxFilters(relay, message) + return maxFilters[relay] + } + private fun classify(reason: String): Policy? { val t = reason.lowercase() if (SEARCH_REQUIRED_MARKERS.any { it in t }) return Policy.SEARCH_ONLY @@ -229,10 +246,17 @@ class RelayReqRefusals( // "invalid number of filters: 4" (strfry policy) — the relay refused N, so it takes fewer. private val INVALID_FILTER_COUNT = Regex("""invalid number of filters:?\s*([0-9]+)""") + // "REQ contains 12 filters, maximum is 10" (Buzz, sent as a NOTICE) — names the cap itself. + private val FILTER_COUNT_WITH_MAXIMUM = Regex("""([0-9]+) filters?,?\s*(?:the )?max(?:imum)?(?: is|:)?\s*([0-9]+)""") + fun parseMaxFilters(reason: String): Int? { + val lower = reason.lowercase() + FILTER_COUNT_WITH_MAXIMUM.find(lower)?.let { match -> + return match.groupValues[2].toIntOrNull()?.takeIf { it >= 1 } + } val refusedCount = INVALID_FILTER_COUNT - .find(reason.lowercase()) + .find(lower) ?.groupValues ?.get(1) ?.toIntOrNull() ?: return null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt index b098e30d02..ed1735b0a1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt @@ -521,8 +521,9 @@ class FullTextSearchModule( /** * The distinct kinds present in `event_headers` that currently parse - * to a [SearchableEvent]. Kind alone selects the event class in - * [EventFactory], so one probe per distinct kind is authoritative; + * to a [SearchableEvent]. One [EventFactory.probe] per distinct kind + * decides (a kind whose class is picked by tags answers as its + * primary class; the insert path still checks each event itself); * the result drives a `kind IN (...)` filter on the rebuild scan so * non-searchable rows are skipped at the SQL layer. */ @@ -537,15 +538,9 @@ class FullTextSearchModule( return out } - private fun isSearchableKind(kind: Int): Boolean = EventFactory.create<Event>(PROBE_ID, PROBE_ID, 0L, kind, EMPTY_TAGS, "", "") is SearchableEvent + private fun isSearchableKind(kind: Int): Boolean = EventFactory.probe(kind) is SearchableEvent companion object { - // A non-blank id keeps kinds that lazily hash a missing id (e.g. - // NIP-17 chat messages) from doing that work — the probe only - // inspects the resulting runtime type. - private const val PROBE_ID = "0" - private val EMPTY_TAGS = emptyArray<Array<String>>() - /** Column order matches the positional `read.getText(1)`…`getText(7)` event rebuilds. */ private const val SELECT_EVENT_COLUMNS = "SELECT row_id, id, pubkey, created_at, kind, tags, content, sig FROM event_headers" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt index dafd8063e4..6fc4170d86 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip10Notes.content import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -fun findNostrUris(content: String) = Nip19Parser.parseAll(content) +/** The NIP-19 references [content] cites — not ones inside a link, like a Blossom `npub1…` subdomain. */ +fun findNostrUris(content: String) = Nip19Parser.parseAllStandalone(content) fun findNostrEventUris(content: String) = Nip19Parser.parseAllEvents(content) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt index c7e83091ac..1eef01d926 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt @@ -242,6 +242,7 @@ object Nip19Parser { content: String, fixed58Prefixes: Array<String>, variablePrefixes: Array<String>, + standaloneOnly: Boolean = false, action: (type: String, key: String, additionalChars: String) -> Unit, ) { var i = 0 @@ -265,7 +266,7 @@ object Nip19Parser { else -> if (nextLower < nextUpper) nextLower else nextUpper } if (i >= len) return - if (isCandidateAt(content, i)) { + if (isCandidateAt(content, i) && !(standaloneOnly && isEmbeddedAt(content, i))) { var end = -1 for (prefix in fixed58Prefixes) { @@ -312,6 +313,26 @@ object Nip19Parser { } } + /** + * True when the entity starting at [at] is glued to the word before it (`xnpub1…`, `a.npub1…`) + * rather than standing on its own. `nostr:` and `@` prefixes are part of a reference, so they are + * skipped; a `/` is not glue, so a path link like `njump.me/npub1…` still counts as cited. + */ + private fun isEmbeddedAt( + content: String, + at: Int, + ): Boolean { + var j = at + if (j > 0 && content[j - 1] == '@') j-- + if (j >= 6 && content.regionMatches(j - 6, "nostr:", 0, 6, ignoreCase = true)) j -= 6 + if (j == 0) return false + val c = content[j - 1] + return c.isLetterOrDigit() || c == '.' || c == '-' || c == '_' + } + + /** The entity is a domain label — `npub1….blossom.band` — so it names a host, not a person. */ + private fun isDomainLabel(additionalChars: String): Boolean = additionalChars.length > 1 && additionalChars[0] == '.' && additionalChars[1].isLetterOrDigit() + private fun allBech32( content: String, from: Int, @@ -345,6 +366,21 @@ object Nip19Parser { return returningList } + /** + * Like [parseAll], but only references that stand on their own: an entity glued to the text + * before it (an `npub1…` subdomain of a Blossom URL, say) is not something the author cited, so + * tagging it would `p`-tag (and notify) whoever's key happens to be in a link. + */ + fun parseAllStandalone(content: String): List<Entity> { + val returningList = mutableListOf<Entity>() + forEachNip19Match(content, FIXED_58_PREFIXES, VARIABLE_PREFIXES, standaloneOnly = true) { type, key, additionalChars -> + if (!isDomainLabel(additionalChars)) { + parseComponents(type, key, additionalChars)?.entity?.let { returningList.add(it) } + } + } + return returningList + } + /** Same scan as [parseAll], restricted to the event-ish entities. */ fun parseAllEvents(content: String): List<Entity> { val returningList = mutableListOf<Entity>() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt index 467e9a2fd6..ec26ccff8e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt @@ -23,10 +23,13 @@ package com.vitorpamplona.quartz.nip87Ecash.recommendation import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -62,6 +65,41 @@ class MintRecommendationEvent( companion object { const val KIND = 38000 + /** + * True when a kind-38000 event is actually a NIP-87 mint recommendation. + * + * Kind 38000 is shared with unrelated apps (prediction markets, ballots, spam votes), so + * the kind number alone does not say what an event is. A recommendation names the kind of + * the mint it recommends in its `k` tag — 38172 (Cashu) or 38173 (Fedimint). Some early + * recommendations carry no `k` at all; those are accepted when they point at a mint URL + * (`u`) or at the mint's announcement (an `a` to a 38172/38173 address). A `k` naming + * only other kinds is not a mint recommendation, whatever else it carries. + */ + fun isMintRecommendation(tags: TagArray): Boolean { + // Any `k` naming a mint's kind: a relay answering `#k=38172` may hand back an event + // whose first `k` is something else. + if (tags.fastAny { it.size > 1 && it[0] == "k" && it[1].toIntOrNull().isMintKind() }) return true + if (tags.fastAny { it.size > 1 && it[0] == "k" && it[1].isNotBlank() }) return false + return tags.fastAny { + it.size > 1 && + ( + (it[0] == MintUrlTag.TAG_NAME && it[1].isNotBlank()) || + // The mint's own announcement, by address: `38172:<pubkey>:<d>`. + (it[0] == "a" && (it[1].startsWith(CASHU_ADDRESS) || it[1].startsWith(FEDIMINT_ADDRESS))) + ) + } + } + + private fun Int?.isMintKind() = this == CashuMintEvent.KIND || this == FedimintEvent.KIND + + private val CASHU_ADDRESS = "${CashuMintEvent.KIND}:" + private val FEDIMINT_ADDRESS = "${FedimintEvent.KIND}:" + + /** + * [mintKind] must be [CashuMintEvent.KIND] or [FedimintEvent.KIND]: the `k` tag it writes is + * what [isMintRecommendation] reads back, so any other value signs into a plain kind-38000 + * event rather than a [MintRecommendationEvent]. + */ fun build( mintIdentifier: String, mintKind: Int, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/UnrecognizedKind38000Event.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/UnrecognizedKind38000Event.kt new file mode 100644 index 0000000000..5f39ac627e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/UnrecognizedKind38000Event.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip87Ecash.recommendation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A kind-38000 event that is none of the formats Amethyst reads on that number — not a NIP-87 + * [MintRecommendationEvent], a ballot or a prediction market. Most of them are `d`-only spam. + * + * Still addressable, because the kind is: stores key it by `d` (`event is AddressableEvent`), so + * a newer version replaces an older one and an `a`-tag deletion reaches it, as NIP-01 requires + * of every kind in 30000–39999. Not a SearchableEvent, so it is never indexed, and no card is + * drawn for it. + */ +@Immutable +class UnrecognizedKind38000Event( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array<Array<String>>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, MintRecommendationEvent.KIND, tags, content, sig) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt index e48f60a840..902fedd5a2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt @@ -96,6 +96,10 @@ class BlossomAuthorizationEvent( createdAt: Long = TimeUtils.now(), ) = createAuth("delete", hash, null, alt, signer, servers, createdAt) + /** + * [expiration] defaults to an hour ahead. Servers that bound the token's lifetime (Buzz + * rejects `expiration > created_at + 60` in its strict mode) need a shorter one. + */ suspend fun createUploadAuth( hash: HexKey, size: Long, @@ -103,7 +107,8 @@ class BlossomAuthorizationEvent( signer: NostrSigner, servers: List<String> = emptyList(), createdAt: Long = TimeUtils.now(), - ) = createAuth("upload", hash, size, alt, signer, servers, createdAt) + expiration: Long = TimeUtils.oneHourAhead(), + ) = createAuth("upload", hash, size, alt, signer, servers, createdAt, expiration) /** * BUD-05 media-optimization auth (`t=media`). The [hash] is the sha256 of @@ -127,6 +132,7 @@ class BlossomAuthorizationEvent( signer: NostrSigner, servers: List<String> = emptyList(), createdAt: Long = TimeUtils.now(), + expiration: Long = TimeUtils.oneHourAhead(), ): BlossomAuthorizationEvent { // BUD-11 `server` tags scope the token to specific domains so an upload // or delete token can't be replayed against another server. The value @@ -141,7 +147,7 @@ class BlossomAuthorizationEvent( val tags = listOfNotNull( arrayOf("t", type), - arrayOf("expiration", TimeUtils.oneHourAhead().toString()), + arrayOf("expiration", expiration.toString()), fileSize?.let { arrayOf("size", it.toString()) }, hash?.let { arrayOf("x", it) }, ) + serverTags diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 8e42496847..8e4e8e53c9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -124,6 +124,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.ballots.BallotEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent @@ -160,6 +161,7 @@ import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent import com.vitorpamplona.quartz.experimental.publications.PublicationContentEvent @@ -370,6 +372,7 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.UnrecognizedKind38000Event import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -755,7 +758,21 @@ class EventFactory { MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig) MeetingSpaceEvent.KIND -> MeetingSpaceEvent(id, pubKey, createdAt, tags, content, sig) AdminCommandEvent.KIND -> AdminCommandEvent(id, pubKey, createdAt, tags, content, sig) - MintRecommendationEvent.KIND -> MintRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + // kind:38000 is NIP-87's mint recommendation, but unrelated apps reuse the number: + // BAO Markets publishes prediction markets on it, an auditable-voting app publishes + // ballots, and most of what a big relay holds for it is spam (`d`-only "sybil test + // votes"). Parsing all of it as a recommendation rendered markets, ballots and spam + // as ecash mint cards. Disambiguate by tags, recommendation first — its `k` (the + // recommended mint's kind) or, on old events without one, its `u` (mint URL) or the + // mint's `a` — and fall back to UnrecognizedKind38000Event: still addressable, so + // stores replace and delete it by `d`, but never indexed and drawn as no card. + MintRecommendationEvent.KIND -> + when { + MintRecommendationEvent.isMintRecommendation(tags) -> MintRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + BallotEvent.isBallot(tags) -> BallotEvent(id, pubKey, createdAt, tags, content, sig) + PredictionMarketEvent.isPredictionMarket(tags) -> PredictionMarketEvent(id, pubKey, createdAt, tags, content, sig) + else -> UnrecognizedKind38000Event(id, pubKey, createdAt, tags, content, sig) + } MediaFollowListEvent.KIND -> MediaFollowListEvent(id, pubKey, createdAt, tags, content, sig) MediaStarterPackEvent.KIND -> MediaStarterPackEvent(id, pubKey, createdAt, tags, content, sig) MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) @@ -940,6 +957,29 @@ class EventFactory { * Used to decide whether a repost's inner (boosted) kind is something * Amethyst can parse and render at all. */ - fun isKnownKind(kind: Int): Boolean = create<Event>("", "", 0L, kind, emptyArray(), "", "")::class != Event::class + fun isKnownKind(kind: Int): Boolean = probe(kind)::class != Event::class + + /** + * A tagless, contentless instance of [kind], for questions asked of a kind rather than of + * an event: does it have a typed class ([isKnownKind]), is it searchable (the stores' + * reindex pre-filters, `SearchableKinds`)? + * + * For most kinds that is just [create] with no tags. It is not for kinds whose class is + * chosen by tags, where a tagless event can land on a different class than the kind's + * real events. Kind 38000 is the case that matters: with no tags it is an + * [UnrecognizedKind38000Event] (junk, unsearchable), yet every typed shape of it — mint recommendation, ballot, prediction market — + * is a searchable, renderable class, so it answers as its primary class. (The other + * tag-split kinds, 39005 and 20001, already land on a typed class with no tags.) + * + * The id is non-blank so kinds that lazily hash a missing id (NIP-17 chat) skip that work + * — only the runtime type matters here. + */ + fun probe(kind: Int): Event = + when (kind) { + MintRecommendationEvent.KIND -> MintRecommendationEvent(PROBE_ID, PROBE_ID, 0L, emptyArray(), "", "") + else -> create(PROBE_ID, PROBE_ID, 0L, kind, emptyArray(), "", "") + } + + private const val PROBE_ID = "0" } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImetaTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImetaTest.kt new file mode 100644 index 0000000000..68fc606c47 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzImetaTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.media + +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import kotlin.test.Test +import kotlin.test.assertEquals + +class BuzzImetaTest { + @Test + fun dropsKeysBuzzRejectsAndKeepsTheRest() { + val tag = + IMetaTag( + "https://ws.example/media/abc.jpg", + mapOf( + "x" to listOf("abc"), + "ox" to listOf("def"), + "m" to listOf("image/jpeg"), + "size" to listOf("2029"), + "dim" to listOf("96x96"), + "blurhash" to listOf("U1Ku"), + "alt" to listOf("caption"), + "content-warning" to listOf("nsfw"), + ), + ) + + val clean = BuzzImeta.sanitize(tag) + + assertEquals("https://ws.example/media/abc.jpg", clean.url) + assertEquals(setOf("x", "m", "size", "dim", "blurhash", "alt"), clean.properties.keys) + } + + @Test + fun keepsOneValueForSingletonsButEveryFallback() { + val tag = + IMetaTag( + "https://ws.example/media/abc.jpg", + mapOf( + "alt" to listOf("one", "two"), + "fallback" to listOf("https://a/1", "https://b/1"), + ), + ) + + val clean = BuzzImeta.sanitize(tag) + + assertEquals(listOf("one"), clean.properties["alt"]) + assertEquals(listOf("https://a/1", "https://b/1"), clean.properties["fallback"]) + } + + @Test + fun bareAttachmentUrlsBecomeBuzzMarkdown() { + val img = IMetaTag("https://ws.example/media/a.jpg", mapOf("m" to listOf("image/jpeg"))) + val vid = IMetaTag("https://ws.example/media/b.mp4", mapOf("m" to listOf("video/mp4"))) + + assertEquals( + "look ![image](https://ws.example/media/a.jpg) and ![video](https://ws.example/media/b.mp4)", + BuzzImeta.markdownMediaBody("look https://ws.example/media/a.jpg and https://ws.example/media/b.mp4", listOf(img, vid)), + ) + } + + @Test + fun urlsAlreadyInMarkdownStayAsTheyAre() { + val img = IMetaTag("https://ws.example/media/a.jpg", mapOf("m" to listOf("image/jpeg"))) + val body = "![image](https://ws.example/media/a.jpg)" + + assertEquals(body, BuzzImeta.markdownMediaBody(body, listOf(img))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizerTest.kt new file mode 100644 index 0000000000..36d60cf45a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/media/BuzzMediaSanitizerTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.media + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class BuzzMediaSanitizerTest { + private fun bytes(vararg v: Int) = ByteArray(v.size) { v[it].toByte() } + + private fun seg( + marker: Int, + payload: ByteArray, + ): ByteArray { + val len = payload.size + 2 + return bytes(0xff, marker, len shr 8, len and 0xff) + payload + } + + private val jfif = seg(0xe0, "JFIF".encodeToByteArray() + bytes(0, 1, 1, 0, 0, 1, 0, 1, 0, 0)) + private val exif = seg(0xe1, "Exif".encodeToByteArray() + bytes(0, 0, 1, 2, 3, 4)) + private val icc = seg(0xe2, "ICC_PROFILE".encodeToByteArray() + bytes(0, 1, 1)) + private val comment = seg(0xfe, "made with a phone".encodeToByteArray()) + private val dqt = seg(0xdb, ByteArray(65) { 1 }) + private val sosAndData = seg(0xda, bytes(1, 1, 0, 0, 63, 0)) + bytes(0x12, 0x34, 0xff, 0x00, 0x56) + private val eoi = bytes(0xff, 0xd9) + + @Test + fun jpegLosesExifIccCommentsAndTrailingBytes() { + val input = bytes(0xff, 0xd8) + jfif + exif + icc + comment + dqt + sosAndData + eoi + "trailer".encodeToByteArray() + + val clean = BuzzMediaSanitizer.sanitize(input, "image/jpeg") + + assertContentEquals(bytes(0xff, 0xd8) + jfif + dqt + sosAndData + eoi, clean) + } + + @Test + fun nonCanonicalApp0IsDropped() { + val jfxx = seg(0xe0, "JFXX".encodeToByteArray() + bytes(0, 0x10, 1, 2, 3)) + val input = bytes(0xff, 0xd8) + jfxx + dqt + sosAndData + eoi + + assertContentEquals(bytes(0xff, 0xd8) + dqt + sosAndData + eoi, BuzzMediaSanitizer.sanitize(input, "image/jpeg")) + } + + private fun chunk( + kind: String, + data: ByteArray, + ) = bytes(data.size shr 24, data.size shr 16 and 0xff, data.size shr 8 and 0xff, data.size and 0xff) + kind.encodeToByteArray() + data + bytes(0, 0, 0, 0) + + private val pngSig = bytes(0x89, 'P'.code, 'N'.code, 'G'.code, 0x0d, 0x0a, 0x1a, 0x0a) + + @Test + fun pngKeepsRenderingChunksAndDropsMetadata() { + val ihdr = chunk("IHDR", ByteArray(13)) + val srgb = chunk("sRGB", bytes(0)) + val text = chunk("tEXt", "Comment\u0000hello".encodeToByteArray()) + val phys = chunk("pHYs", ByteArray(9)) + val iccp = chunk("iCCP", ByteArray(4)) + val idat = chunk("IDAT", bytes(1, 2, 3)) + val iend = chunk("IEND", ByteArray(0)) + val input = pngSig + ihdr + srgb + text + phys + iccp + idat + iend + bytes(9, 9) + + val clean = BuzzMediaSanitizer.sanitize(input, "image/png") + + assertContentEquals(pngSig + ihdr + srgb + idat + iend, clean) + } + + @Test + fun otherFormatsAndBrokenInputPassThrough() { + val webp = "RIFF....WEBP".encodeToByteArray() + assertContentEquals(webp, BuzzMediaSanitizer.sanitize(webp, "image/webp")) + val notJpeg = bytes(1, 2, 3, 4) + assertContentEquals(notJpeg, BuzzMediaSanitizer.sanitize(notJpeg, "image/jpeg")) + assertEquals(true, BuzzMediaSanitizer.handles("IMAGE/JPEG")) + } + + @Test + fun metadataAfterAScanIsDroppedToo() { + val secondScan = seg(0xda, bytes(1, 1, 0, 0, 63, 0)) + bytes(0x77, 0x01) + val input = bytes(0xff, 0xd8) + jfif + dqt + sosAndData + exif + seg(0xc4, ByteArray(5) { 2 }) + secondScan + eoi + + val clean = BuzzMediaSanitizer.sanitize(input, "image/jpeg") + + assertContentEquals(bytes(0xff, 0xd8) + jfif + dqt + sosAndData + seg(0xc4, ByteArray(5) { 2 }) + secondScan + eoi, clean) + } + + @Test + fun appendedPayloadEndsAtTheRealEndOfImage() { + // A motion photo appends a video (or another JPEG) that can itself contain FF D9. + val appended = bytes(0x00, 0x11, 0xff, 0xd9, 0x22) + val input = bytes(0xff, 0xd8) + jfif + dqt + sosAndData + eoi + appended + + assertContentEquals(bytes(0xff, 0xd8) + jfif + dqt + sosAndData + eoi, BuzzMediaSanitizer.sanitize(input, "image/jpeg")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEventTest.kt new file mode 100644 index 0000000000..87529c7bea --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEventTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.ballots + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.Kind38000Fixtures +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class BallotEventTest { + private fun parse(json: String) = assertIs<BallotEvent>(Event.fromJson(json)) + + private fun ballotWith( + content: String, + vararg extraTags: Array<String>, + ) = BallotEvent("0".repeat(64), "1".repeat(64), 1L, arrayOf(arrayOf("election", "e1"), *extraTags), content, "") + + @Test + fun responsesShape() { + val event = parse(Kind38000Fixtures.BALLOT_RESPONSES) + + assertEquals("sec06-feedback-1774311582", event.election()) + val answers = event.answers() + assertEquals(10, answers.size) + assertEquals(BallotAnswer("q1", "Yes"), answers[0]) + // Numbers read as text. + assertEquals(BallotAnswer("q3", "5"), answers[2]) + assertEquals(BallotAnswer("q9", "NA"), answers[8]) + assertNull(event.proofHash()) + } + + @Test + fun responsesWithProofHashTag() { + val event = parse(Kind38000Fixtures.BALLOT_RESPONSES_PROOF_HASH) + + assertEquals("spring-2026-council", event.election()) + assertEquals(listOf(BallotAnswer("proposal_approval", "Yes")), event.answers()) + assertEquals("361689b5ccd44f35eebba995129d8f2b00dd369aca4c51b55f00065b2b477ddd", event.proofHash()) + assertEquals("spring-2026-council\nYes", event.indexableContent()) + } + + @Test + fun ballotObjectShape() { + val event = parse(Kind38000Fixtures.BALLOT_OBJECT) + + assertEquals("spring-2026-council", event.election()) + assertEquals( + listOf( + BallotAnswer("funding_priority", "community-grants"), + BallotAnswer("audit_policy", "every-election"), + ), + event.answers(), + ) + assertEquals("bb64227a0b57fa6af765881b0952fe9d01a3a18faf52a3e34eeb7a896ee36b95", event.proofHash()) + } + + @Test + fun voteChoiceShape() { + val event = parse(Kind38000Fixtures.BALLOT_VOTE_CHOICE) + + assertEquals("election1", event.election()) + assertEquals(listOf(BallotAnswer(BallotEvent.VOTE_QUESTION, "yes")), event.answers()) + assertNull(event.proofHash()) + } + + @Test + fun proofHashFallsBackToContent() { + assertEquals("abc", ballotWith("""{"proof_hash":"abc","vote_choice":"no"}""").proofHash()) + assertEquals("tag", ballotWith("""{"proof_hash":"abc"}""", arrayOf("proof_hash", "tag")).proofHash()) + assertEquals("dash", ballotWith("""{"proof_hash":"abc"}""", arrayOf("proof_hash", "underscore"), arrayOf("proof-hash", "dash")).proofHash()) + } + + @Test + fun nonScalarValuesAreSkipped() { + val responses = + ballotWith( + """{"responses":[1,{"question_id":"q","value":{"x":1}},{"question_id":"q2","value":null},{"value":"orphan"},""" + + """{"question_id":"q3","value":true},{"question_id":4,"value":2.5},{"question_id":"q5","value":" "}]}""", + ) + assertEquals(listOf(BallotAnswer("q3", "true"), BallotAnswer("4", "2.5")), responses.answers()) + + val ballotObject = ballotWith("""{"ballot":{"a":[1],"b":2,"c":null,"d":{"x":"y"},"e":"E"}}""") + assertEquals(listOf(BallotAnswer("b", "2"), BallotAnswer("e", "E")), ballotObject.answers()) + + assertEquals(emptyList(), ballotWith("""{"vote_choice":{"x":1}}""").answers()) + } + + @Test + fun junkJsonNeverThrows() { + listOf("", "not json", "{", "[]", "null", "{\"responses\":", "{\"responses\": \"x\", \"ballot\": 3}", "{\"a\":".repeat(5000)).forEach { + val event = ballotWith(it) + assertEquals(emptyList(), event.answers()) + assertNull(event.proofHash()) + assertEquals("e1", event.indexableContent()) + } + } + + @Test + fun visitorAgreesWithIndexedContentAndStops() { + listOf(Kind38000Fixtures.BALLOT_RESPONSES, Kind38000Fixtures.BALLOT_OBJECT, Kind38000Fixtures.BALLOT_VOTE_CHOICE).forEach { json -> + val event = parse(json) + val visited = mutableListOf<String>() + event.forEachIndexableField { + if (it != null) visited.add(it) + true + } + assertEquals(event.indexableContent(), visited.joinToString(event.indexableSeparator())) + + var seen = 0 + event.forEachIndexableField { + seen++ + false + } + assertEquals(1, seen) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEventTest.kt new file mode 100644 index 0000000000..b1b361a774 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEventTest.kt @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.predictionMarkets + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.Kind38000Fixtures +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PredictionMarketEventTest { + private fun market(json: String) = assertIs<PredictionMarketEvent>(Event.fromJson(json)) + + private fun market( + tags: Array<Array<String>>, + content: String = "", + ) = PredictionMarketEvent("0".repeat(64), "1".repeat(64), 1L, tags, content, "") + + @Test + fun currentShapeResolvedBinary() { + val event = market(Kind38000Fixtures.MARKET_RESOLVED_BINARY) + + assertEquals("caec08eca65442725a5bd8f77e6d2666", event.marketId()) + assertEquals("Will an AI coding tool appear in GitHub top trending repos today? (UTC 2,May,2026)", event.title()) + assertTrue(event.description()!!.startsWith("At least one repository in GitHub's daily trending page")) + assertEquals(listOf("YES", "NO"), event.outcomes()) + assertEquals("NO", event.resolution()) + assertEquals(PredictionMarketStatus.RESOLVED, event.status()) + assertEquals(1777680000L, event.endsAt()) + assertEquals("ai-llm", event.category()) + assertEquals("demo", event.network()) + assertTrue(event.isDemo()) + assertEquals("github.com/trending daily repos", event.resolutionSource()) + assertEquals(100L, event.minBetSats()) + assertEquals(100000L, event.maxBetSats()) + assertEquals(4.21, event.feePercent()) + assertNull(event.cancelReason()) + assertEquals("BAO Markets", event.client()) + assertEquals("2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", event.oracle()) + assertEquals("${event.title()}\n${event.description()}", event.indexableContent()) + } + + @Test + fun currentShapeActiveMarketClosesAtItsEnd() { + val event = market(Kind38000Fixtures.MARKET_ACTIVE_BINARY) + val end = 1777939200L + + assertEquals(end, event.endsAt()) + assertEquals("bitcoin", event.category()) + assertNull(event.resolution()) + // `status` says "active" (it wins over `state` = "ended")… + assertEquals(PredictionMarketStatus.OPEN, event.status(now = end - 1)) + // …but once `end` passes, betting is over. + assertEquals(PredictionMarketStatus.CLOSED, event.status(now = end)) + assertEquals(PredictionMarketStatus.CLOSED, event.status()) + assertEquals("https://mempool.space/api/v1/blocks", event.resolutionSource()) + } + + @Test + fun currentShapeVoidedMultiOutcome() { + val event = market(Kind38000Fixtures.MARKET_VOIDED_MULTI_OUTCOME) + + assertEquals(listOf("Foundry USA", "AntPool", "F2Pool", "ViaBTC", "Other"), event.outcomes()) + assertEquals(PredictionMarketStatus.CANCELLED, event.status(now = 0)) + assertEquals("mining", event.category()) + assertTrue(event.title()!!.startsWith("Which mining pool will mine the most Bitcoin blocks today?")) + } + + @Test + fun currentShapeCancelled() { + val event = market(Kind38000Fixtures.MARKET_CANCELLED) + + assertEquals("Will BTC price be above \$60,000 at 2026-08-13 13:00 UTC?", event.title()) + assertNull(event.description()) + assertEquals(emptyList(), event.outcomes()) + assertEquals(PredictionMarketStatus.CANCELLED, event.status()) + assertEquals("bridge-test junk", event.cancelReason()) + assertEquals(1786626000L, event.endsAt()) + assertEquals("bitcoin", event.category()) + assertTrue(event.isDemo()) + assertEquals(event.title(), event.indexableContent()) + } + + @Test + fun baoFundShape() { + val event = market(Kind38000Fixtures.MARKET_BAO_FUND) + val end = 1790727795L + + assertEquals("Will E2E demo-rail liquid refund munbn1yg deliver: E2E milestone munbn1yg by 2026-09-30?", event.title()) + assertTrue(event.description()!!.contains("CRITERIA: E2E milestone munbn1yg")) + assertEquals(listOf("YES", "NO"), event.outcomes()) + assertEquals("bao-fund", event.category()) + assertTrue(event.isDemo()) + assertEquals(end, event.endsAt()) + // No status tag at all: the end decides. + assertEquals(PredictionMarketStatus.OPEN, event.status(now = end - 100)) + assertEquals(PredictionMarketStatus.CLOSED, event.status(now = end + 1)) + assertNull(event.minBetSats()) + assertNull(event.feePercent()) + } + + @Test + fun firstShape() { + val event = market(Kind38000Fixtures.MARKET_FIRST_SHAPE) + val end = 1769256240L + + assertEquals("will trump turn purple before May?", event.title()) + assertEquals("Trump is orange, but when purple? I think he hes been orange for far too long", event.description()) + // Outcome objects read by their label. + assertEquals(listOf("Yes", "No"), event.outcomes()) + assertEquals("nostr", event.category()) + assertEquals("community", event.oracle()) + assertNull(event.network()) + assertFalse(event.isDemo()) + assertEquals(end, event.endsAt()) + // `state` = "funding" is open, until the end passes. + assertEquals(PredictionMarketStatus.OPEN, event.status(now = end - 1)) + assertEquals(PredictionMarketStatus.CLOSED, event.status(now = end + 1)) + } + + @Test + fun statusWords() { + val expected = + mapOf( + "active" to PredictionMarketStatus.OPEN, + "Open" to PredictionMarketStatus.OPEN, + "funding" to PredictionMarketStatus.OPEN, + "resolving" to PredictionMarketStatus.CLOSED, + "ended" to PredictionMarketStatus.CLOSED, + "closed" to PredictionMarketStatus.CLOSED, + "resolved" to PredictionMarketStatus.RESOLVED, + "settled" to PredictionMarketStatus.RESOLVED, + "voided" to PredictionMarketStatus.CANCELLED, + "cancelled" to PredictionMarketStatus.CANCELLED, + " CANCELED " to PredictionMarketStatus.CANCELLED, + ) + expected.forEach { (word, status) -> assertEquals(status, PredictionMarketStatus.parse(word), word) } + assertNull(PredictionMarketStatus.parse("pending")) + assertNull(PredictionMarketStatus.parse("")) + } + + @Test + fun statusFallbacks() { + // No status at all, no end: unknown. + assertNull(market(arrayOf(arrayOf("market", "m"))).status(now = 10)) + // An unknown word falls through to the next tag name. + assertEquals( + PredictionMarketStatus.RESOLVED, + market(arrayOf(arrayOf("market", "m"), arrayOf("status", "weird"), arrayOf("state", "resolved"))).status(now = 10), + ) + // A resolution resolves an undeclared or still-open market. + assertEquals(PredictionMarketStatus.RESOLVED, market(arrayOf(arrayOf("market", "m"), arrayOf("resolution", "YES"))).status(now = 10)) + assertEquals( + PredictionMarketStatus.RESOLVED, + market(arrayOf(arrayOf("market", "m"), arrayOf("status", "active"), arrayOf("resolution", "YES"))).status(now = 10), + ) + // …but never un-cancels one. + assertEquals( + PredictionMarketStatus.CANCELLED, + market(arrayOf(arrayOf("market", "m"), arrayOf("status", "voided"), arrayOf("resolution", "YES"))).status(now = 10), + ) + // A cancel reason on an undeclared market cancels it. + assertEquals(PredictionMarketStatus.CANCELLED, market(arrayOf(arrayOf("market", "m"), arrayOf("cancel_reason", "oops"))).status(now = 10)) + // Undeclared: the end decides. + assertEquals(PredictionMarketStatus.OPEN, market(arrayOf(arrayOf("market", "m"), arrayOf("end", "20"))).status(now = 10)) + assertEquals(PredictionMarketStatus.CLOSED, market(arrayOf(arrayOf("market", "m"), arrayOf("end", "20"))).status(now = 20)) + // A status in JSON content counts when no tag declares one. + assertEquals(PredictionMarketStatus.CANCELLED, market(arrayOf(arrayOf("market", "m")), """{"status":"cancelled"}""").status(now = 10)) + } + + @Test + fun tagsWinOverJson() { + val event = + market( + arrayOf( + arrayOf("market", "m"), + arrayOf("title", "From tag"), + arrayOf("min_bet", "5"), + arrayOf("outcome", "A"), + arrayOf("outcome", "B"), + arrayOf("data", """{"title":"From data","minBetSats":7,"maxBetSats":9,"outcomes":["X","Y"]}"""), + ), + """{"title":"From content","description":"Content description"}""", + ) + + // `data` is BAO's canonical blob, so its title beats the tag; the tag beats JSON content. + assertEquals("From data", event.title()) + assertEquals("Content description", event.description()) + assertEquals(5L, event.minBetSats()) + assertEquals(9L, event.maxBetSats()) + assertEquals(listOf("A", "B"), event.outcomes()) + } + + @Test + fun junkJsonNeverThrows() { + val junk = + listOf( + "", + " ", + "not json at all", + "{", + "{\"title\":", + "[1,2,3]", + "null", + "{\"a\":".repeat(5000), + "{\"title\": 5, \"description\": null, \"outcomes\": \"nope\", \"minBetSats\": \"abc\", \"feePercent\": {}}", + "{\"outcomes\": [1, null, [], {\"id\": 7}, {\"label\": \"A\"}, \"B\", \"B\", \" \"]}", + ) + + junk.forEach { json -> + listOf( + market(arrayOf(arrayOf("market", "m"), arrayOf("data", json)), json), + market(arrayOf(arrayOf("type", "binary"), arrayOf("end", "not a number"), arrayOf("min_bet", "x"), arrayOf("fee_percent", "y")), json), + ).forEach { event -> + event.title() + event.description() + event.outcomes() + event.status(now = 10) + event.endsAt() + event.minBetSats() + event.maxBetSats() + event.feePercent() + event.cancelReason() + event.indexableContent() + } + } + + val odd = market(arrayOf(arrayOf("market", "m")), junk.last()) + assertEquals(listOf("A", "B"), odd.outcomes()) + + val typed = market(arrayOf(arrayOf("market", "m"), arrayOf("min_bet", "x")), junk[junk.size - 2]) + assertNull(typed.title()) + assertNull(typed.description()) + assertEquals(emptyList(), typed.outcomes()) + assertNull(typed.minBetSats()) + assertNull(typed.feePercent()) + assertEquals("", typed.indexableContent()) + } + + @Test + fun visitorAgreesWithIndexedContentAndStops() { + listOf( + Kind38000Fixtures.MARKET_RESOLVED_BINARY, + Kind38000Fixtures.MARKET_CANCELLED, + Kind38000Fixtures.MARKET_FIRST_SHAPE, + ).forEach { json -> + val event = market(json) + val visited = mutableListOf<String>() + event.forEachIndexableField { + if (it != null) visited.add(it) + true + } + assertEquals(event.indexableContent(), visited.joinToString(event.indexableSeparator())) + + var seen = 0 + event.forEachIndexableField { + seen++ + false + } + assertEquals(1, seen) + } + } + + @Test + fun outcomesKeepTheirIdForTheWinnerAndShowTheirLabel() { + // `resolution` names the id; a reader is shown the label. + val tagged = + market( + arrayOf( + arrayOf("market", "m"), + arrayOf("outcome", "opt1", "Foundry USA"), + arrayOf("outcome", "opt2", "AntPool"), + arrayOf("resolution", "opt1"), + ), + "", + ) + assertEquals(listOf("Foundry USA", "AntPool"), tagged.outcomes()) + val winner = tagged.outcomeOptions().single { it.isNamedBy(tagged.resolution()) } + assertEquals(PredictionMarketOutcome("opt1", "Foundry USA"), winner) + + val json = market(arrayOf(arrayOf("market", "m")), """{"outcomes":[{"id":"A","label":"Foundry"},{"id":"B","label":"Other"}]}""") + assertEquals(listOf("Foundry", "Other"), json.outcomes()) + assertTrue(json.outcomeOptions().first().isNamedBy("a")) + } + + @Test + fun aCraftedFloodOfOutcomesIsCut() { + val flood = Array(5000) { arrayOf("outcome", "o$it") } + val event = market(arrayOf(arrayOf("market", "m"), *flood), "") + assertEquals(PredictionMarketOutcome.MAX, event.outcomeOptions().size) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsFilterCapTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsFilterCapTest.kt index bc15867fcf..e5e0427ecb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsFilterCapTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsFilterCapTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.pool import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -81,8 +82,44 @@ class PoolRequestsFilterCapTest { fun learnsTheCapFromTheRefusal() { assertEquals(3, RelayReqRefusals.parseMaxFilters("ERROR: bad req: filter validation failed: invalid number of filters: 4")) assertEquals(null, RelayReqRefusals.parseMaxFilters("ERROR: bad req: filter validation failed: kind not allowed: 21059")) + assertEquals(10, RelayReqRefusals.parseMaxFilters("invalid message: Invalid message format: REQ contains 12 filters, maximum is 10")) } + /** + * Buzz refuses an over-cap REQ with a NOTICE, which names no subscription — the sub would wait + * for an EOSE forever. The cap it names must be learned and the over-cap sub sent again under it. + */ + @Test + fun aBuzzNoticeResendsTheOverCapSubUnderTheCap() = + kotlinx.coroutines.test.runTest { + val buzz = NormalizedRelayUrl("wss://ws.communities.buzz.xyz/") + val pool = PoolRequests() + pool.addOrUpdate("big", mapOf(buzz to groupFilters(12)), null) + pool.addOrUpdate("small", mapOf(buzz to groupFilters(2)), null) + pool.onConnecting(buzz) + pool.syncState(buzz) { } + + val client = RecordingRelayClient(buzz) + pool.onIncomingMessage(client, NoticeMessage("invalid message: Invalid message format: REQ contains 12 filters, maximum is 10")) + + val resent = client.sent.filterIsInstance<ReqCmd>() + assertEquals(listOf("big"), resent.map { it.subId }, "only the sub that went over the cap is re-sent") + assertTrue(resent.single().filters.size <= 10) + } + + @Test + fun anUnrelatedNoticeChangesNothing() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests() + pool.addOrUpdate("big", mapOf(relay to groupFilters(12)), null) + sync(pool, relay) + + val client = RecordingRelayClient(relay) + pool.onIncomingMessage(client, NoticeMessage("rate limited, slow down")) + + assertTrue(client.sent.isEmpty()) + } + @Test fun perGroupFiltersAreMergedUnderTheCapAndSentAgainAtOnce() = kotlinx.coroutines.test.runTest { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/FindNostrUrisTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/FindNostrUrisTest.kt new file mode 100644 index 0000000000..6985559b3f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/FindNostrUrisTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip10Notes.content + +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** An npub that is part of a link (a Blossom subdomain) is not a citation and must not be tagged. */ +class FindNostrUrisTest { + private val npub = "npub1rfahrcqpwh0rp7f2jcq8gsh4y04wawpthvw3fhmarvt8hsf80mvsr0tlnr" + + @Test + fun npubInsideABlossomHostnameIsNotCited() { + assertTrue(findNostrUris("https://$npub.blossom.band/e069d411.jpg").isEmpty()) + } + + @Test + fun npubGluedToAWordIsNotCited() { + assertTrue(findNostrUris("x$npub").isEmpty()) + } + + @Test + fun aPathLinkToAProfileIsStillCited() { + // njump-style links have always been cited; only domain labels are excluded. + assertEquals(1, findNostrUris("see https://njump.me/$npub").filterIsInstance<NPub>().size) + assertEquals(1, findNostrUris("end of sentence nostr:$npub.").filterIsInstance<NPub>().size) + } + + @Test + fun standaloneReferencesAreStillCited() { + assertEquals(1, findNostrUris("hi nostr:$npub").filterIsInstance<NPub>().size) + assertEquals(1, findNostrUris("hi @$npub, there").filterIsInstance<NPub>().size) + assertEquals(1, findNostrUris("$npub at the start").filterIsInstance<NPub>().size) + assertEquals(1, findNostrUris("($npub)").filterIsInstance<NPub>().size) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKindsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKindsTest.kt index c23e969128..c2852f1a74 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKindsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKindsTest.kt @@ -37,7 +37,9 @@ import kotlin.test.assertEquals * being left out of everything downstream that reads this list. */ class SearchableKindsTest { - private fun build(kind: Int): Event = EventFactory.create("9".repeat(64), "a".repeat(64), 1L, kind, emptyArray(), "", "") + // Through the factory's kind probe, like the stores' reindex pre-filters: a kind whose class + // is picked by tags (38000) answers as its primary class rather than as a tagless plain Event. + private fun build(kind: Int): Event = EventFactory.probe(kind) @Test fun theRecordedSetIsExactlyWhatTheFactoryBuilds() { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKind38000Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKind38000Test.kt new file mode 100644 index 0000000000..ddc351887c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKind38000Test.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import com.vitorpamplona.quartz.experimental.ballots.BallotEvent +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent +import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.UnrecognizedKind38000Event +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Kind 38000 is NIP-87's mint recommendation, but prediction markets, ballots and spam share the + * number. The factory must hand each shape its own class, and junk none. + */ +class EventFactoryKind38000Test { + private fun parse(json: String): Event = Event.fromJson(json) + + private fun build(vararg tags: Array<String>): Event = EventFactory.create("0".repeat(64), "1".repeat(64), 1L, 38000, arrayOf(*tags), "", "") + + @Test + fun fixturesAreRealSignedEvents() { + listOf( + Kind38000Fixtures.MARKET_RESOLVED_BINARY, + Kind38000Fixtures.MARKET_ACTIVE_BINARY, + Kind38000Fixtures.MARKET_VOIDED_MULTI_OUTCOME, + Kind38000Fixtures.MARKET_CANCELLED, + Kind38000Fixtures.MARKET_BAO_FUND, + Kind38000Fixtures.MARKET_FIRST_SHAPE, + Kind38000Fixtures.BALLOT_RESPONSES, + Kind38000Fixtures.BALLOT_RESPONSES_PROOF_HASH, + Kind38000Fixtures.BALLOT_OBJECT, + Kind38000Fixtures.BALLOT_VOTE_CHOICE, + Kind38000Fixtures.MINT_REVIEW_CASHU, + Kind38000Fixtures.MINT_REVIEW_FEDIMINT, + Kind38000Fixtures.MINT_REVIEW_NO_K, + Kind38000Fixtures.SPAM_SYBIL_VOTE, + Kind38000Fixtures.SPAM_APP_MANIFEST, + ).forEach { json -> + val event = parse(json) + assertEquals(38000, event.kind) + assertTrue(event.verify(), "fixture ${event.id} does not verify") + } + } + + @Test + fun mintReviewsAreMintRecommendations() { + val cashu = assertIs<MintRecommendationEvent>(parse(Kind38000Fixtures.MINT_REVIEW_CASHU)) + assertTrue(cashu.isCashuRecommendation()) + assertEquals(listOf("https://21mint.me"), cashu.mintUrls()) + + val fedimint = assertIs<MintRecommendationEvent>(parse(Kind38000Fixtures.MINT_REVIEW_FEDIMINT)) + assertTrue(fedimint.isFedimintRecommendation()) + + val noK = assertIs<MintRecommendationEvent>(parse(Kind38000Fixtures.MINT_REVIEW_NO_K)) + assertEquals(listOf("https://mint.lnpay.cz"), noK.mintUrls()) + } + + @Test + fun marketsArePredictionMarkets() { + listOf( + Kind38000Fixtures.MARKET_RESOLVED_BINARY, + Kind38000Fixtures.MARKET_ACTIVE_BINARY, + Kind38000Fixtures.MARKET_VOIDED_MULTI_OUTCOME, + Kind38000Fixtures.MARKET_CANCELLED, + Kind38000Fixtures.MARKET_BAO_FUND, + Kind38000Fixtures.MARKET_FIRST_SHAPE, + ).forEach { assertIs<PredictionMarketEvent>(parse(it)) } + } + + @Test + fun ballotsAreBallots() { + listOf( + Kind38000Fixtures.BALLOT_RESPONSES, + Kind38000Fixtures.BALLOT_RESPONSES_PROOF_HASH, + Kind38000Fixtures.BALLOT_OBJECT, + Kind38000Fixtures.BALLOT_VOTE_CHOICE, + ).forEach { assertIs<BallotEvent>(parse(it)) } + } + + @Test + fun spamAndOneOffsAreUnrecognizedButStillAddressable() { + listOf( + Kind38000Fixtures.SPAM_SYBIL_VOTE, + Kind38000Fixtures.SPAM_APP_MANIFEST, + ).forEach { + val event = parse(it) + assertIs<UnrecognizedKind38000Event>(event) + // Stores key 30000–39999 by `d` only when the event is addressable: replacement and + // `a`-tag deletion must still reach the spam. + assertIs<AddressableEvent>(event) + assertFalse(event is SearchableEvent) + } + } + + @Test + fun aKNamingOnlyOtherKindsIsNotARecommendationEvenWithAMintUrl() { + assertIs<UnrecognizedKind38000Event>(build(arrayOf("k", "1"), arrayOf("u", "https://mint.example"))) + // Any k naming a mint kind counts, wherever it sits. + assertIs<MintRecommendationEvent>(build(arrayOf("k", "1"), arrayOf("k", "38172"))) + assertIs<MintRecommendationEvent>(build(arrayOf("k", "38173"), arrayOf("k", "1"))) + } + + @Test + fun aRecommendationNamingItsMintOnlyByAddressIsOne() { + assertIs<MintRecommendationEvent>(build(arrayOf("d", "x"), arrayOf("a", "38172:${"a".repeat(64)}:x"))) + assertIs<MintRecommendationEvent>(build(arrayOf("d", "x"), arrayOf("a", "38173:${"a".repeat(64)}:x"))) + assertIs<UnrecognizedKind38000Event>(build(arrayOf("d", "x"), arrayOf("a", "30023:${"a".repeat(64)}:x"))) + } + + @Test + fun aBlankMintUrlWithoutKIsNotARecommendation() { + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("d", "x"), arrayOf("u", " "))::class) + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("u"))::class) + } + + @Test + fun recommendationWinsOverTheOtherShapes() { + assertIs<MintRecommendationEvent>(build(arrayOf("k", "38172"), arrayOf("election", "e1"), arrayOf("market", "m1"))) + } + + @Test + fun ballotWinsOverMarket() { + assertIs<BallotEvent>(build(arrayOf("election", "e1"), arrayOf("market", "m1"))) + } + + @Test + fun aBlankElectionIsNotABallot() { + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("election", ""))::class) + } + + @Test + fun marketNeedsAMarketTwoOutcomesOrATypeAndAnEnd() { + assertIs<PredictionMarketEvent>(build(arrayOf("market", "m1"))) + assertIs<PredictionMarketEvent>(build(arrayOf("outcome", "YES"), arrayOf("outcome", "NO"))) + assertIs<PredictionMarketEvent>(build(arrayOf("type", "binary"), arrayOf("end", "1"))) + + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("market", " "))::class) + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("outcome", "YES"))::class) + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("type", "binary"))::class) + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("end", "1"))::class) + } + + @Test + fun signingARecommendationRoundTripsToARecommendation() { + val signer = NostrSignerSync() + listOf(CashuMintEvent.KIND, FedimintEvent.KIND).forEach { mintKind -> + val signed = + signer.sign<MintRecommendationEvent>( + MintRecommendationEvent.build( + mintIdentifier = "https://mint.example", + mintKind = mintKind, + review = "great", + ), + ) + assertIs<MintRecommendationEvent>(signed) + assertEquals(mintKind, signed.mintEventKind()) + assertIs<MintRecommendationEvent>(Event.fromJson(signed.toJson())) + } + } + + @Test + fun theKindStillProbesAsAKnownSearchableKind() { + assertTrue(EventFactory.isKnownKind(MintRecommendationEvent.KIND)) + assertIs<MintRecommendationEvent>(EventFactory.probe(MintRecommendationEvent.KIND)) + assertTrue(EventFactory.probe(MintRecommendationEvent.KIND) is SearchableEvent) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Kind38000Fixtures.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Kind38000Fixtures.kt new file mode 100644 index 0000000000..32e406b69a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Kind38000Fixtures.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +/** + * Real kind-38000 events from a production search relay, one per shape seen in a 12-month census + * (15,347 events): NIP-87 mint recommendations, BAO Markets prediction markets, auditable-voting + * ballots, and the spam and one-offs that are none of these. + */ +object Kind38000Fixtures { + /** BAO Markets, current shape: a resolved YES/NO market (resolution = NO). */ + const val MARKET_RESOLVED_BINARY = + """{"id": "3de5a164159cd7b3925c3d7aa16a5c32f8f01e50d296e2a3821bfbf5c22db82e", "pubkey": "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", "created_at": 1778605676, "kind": 38000, "tags": [["d", "caec08eca65442725a5bd8f77e6d2666"], ["market", "caec08eca65442725a5bd8f77e6d2666"], ["m", "caec08eca65442725a5bd8f77e6d2666"], ["type", "binary"], ["w", "binary"], ["category", "ai-llm"], ["c", "ai-llm"], ["state", "resolved"], ["status", "resolved"], ["s", "resolved"], ["end", "1777680000"], ["yes_hash", "e7713ddb231bdd5fdb32eeb902786019894cfbb3d076992b5c3b9514be1870b5"], ["no_hash", "b57e7121377201283659de7144e78f87332e568142edaf24accbd97b445eff00"], ["yes_adaptor_point", "04188a0f14f632aa77b1aa8065ddf541556e5ddfe654a22304fb8661710cb1f6e48d5c606f280ec4a265e8ab45ff9c529a11d53da0ecb6aeaae285a977a4a81b3f"], ["no_adaptor_point", "04b43fe7b12d84d5c6ac9368447c85fda62b57c5bbe7e6acaf4de0e31728703b68cd977cc8e3a20a1c2a2919f3175317debfac1f85169c14f0f30802f182f151f8"], ["oracle", "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349"], ["expiry_blocks", "205"], ["network", "demo"], ["n", "demo"], ["flash", "true"], ["duration_blocks", "61"], ["oracle_tier", "2"], ["pool_model", "smj"], ["duration_tier", "now"], ["fee_percent", "4.21"], ["min_bet", "100"], ["max_bet", "100000"], ["t", "smj-market"], ["t", "prediction-market"], ["t", "ai-llm"], ["client", "BAO Markets"], ["resolution_source", "github.com/trending daily repos"], ["escrow_method", "htlc"], ["outcome", "YES"], ["outcome", "NO"], ["data", "{\"title\":\"Will an AI coding tool appear in GitHub top trending repos today? (UTC 2,May,2026)\",\"description\":\"At least one repository in GitHub's daily trending page is directly related to AI coding tools (e.g., Copilot alternatives, AI code generation, AI-powered IDEs). Resolution via github.com/trending.\",\"outcomes\":[\"YES\",\"NO\"],\"isFlash\":true,\"poolModel\":\"smj\",\"durationTier\":\"now\",\"feePercent\":4.21,\"minBetSats\":100,\"maxBetSats\":100000,\"resolutionSource\":\"github.com/trending daily repos\",\"resolutionLogic\":\"\"}"], ["poll", "9f1a858603ca85c2d52d2a63710c0c5162b8af6489b9309e547c93dcf09303bb"], ["resolution", "NO"]], "content": "🤖 Will an AI coding tool appear in GitHub top trending repos today? (UTC 2,May,2026)\n\nAt least one repository in GitHub's daily trending page is directly related to AI coding tools (e.g., Copilot alternatives, AI code generation, AI-powered IDEs). Resolution via github.com/trending.\n\n📈 🟢 YES | 🔴 NO\n\n⚡ Flash market (SMJ)\n\n⏱️ Closes: May 2 at 00:00 UTC\n\n#baomarkets #predictionmarkets #truthmachine", "sig": "7e21243b38779711181ccde0a722dbb8e10264568605bd74a5024f0d650be2c925e18cc984aa57030213b6882c61cae2f9f18647c01ae0ed05e3cbc6831e0fdb"}""" + + /** BAO Markets, current shape: status "active" but state "ended", end in the past. */ + const val MARKET_ACTIVE_BINARY = + """{"id": "4f7146bcdd339f7c1f4a56f460c79cf56587a17c61ccdb5f43ea24eb7af494c0", "pubkey": "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", "created_at": 1786702043, "kind": 38000, "tags": [["d", "812916327ded76bb2b4bba766596bac3"], ["market", "812916327ded76bb2b4bba766596bac3"], ["m", "812916327ded76bb2b4bba766596bac3"], ["type", "binary"], ["w", "binary"], ["category", "bitcoin"], ["c", "bitcoin"], ["state", "ended"], ["status", "active"], ["s", "ended"], ["end", "1777939200"], ["yes_hash", "494a22a6612b840ca5c7079fefa00b48e40fd33bc96718692ebf94af1b93779b"], ["no_hash", "e5ee4d1c1d10dc0f148cf2e54e8564b468694cd0ef94a7bd6085d20ba4b41be8"], ["yes_adaptor_point", "04763467fb0e756f204f7a53571103ec49aad8354118807edfeffc1ca1e5cc5921eb4b61cdee42810aff726a90ab162f6d605796e2687076190b5bac8aeb19c381"], ["no_adaptor_point", "04dd8f56d6d9b86abb6dade0a83f689c2286b4c8cf36168bc7465a6df5512124051ec402443c973e427730db4c462e598d4f0fadb947009d6ce4b131052391e719"], ["oracle", "4a468afe57e1c93291929d2ffe5f287976b1639f3545845655d4b447a289186d"], ["expiry_blocks", "223"], ["network", "demo"], ["n", "demo"], ["smj", "true"], ["duration_blocks", "79"], ["oracle_tier", "1"], ["pool_model", "smj"], ["duration_tier", "now"], ["fee_percent", "4.21"], ["min_bet", "100"], ["max_bet", "100000"], ["t", "smj-market"], ["t", "prediction-market"], ["t", "bitcoin"], ["client", "BAO Markets"], ["resolution_source", "https://mempool.space/api/v1/blocks"], ["escrow_method", "htlc"], ["outcome", "YES"], ["outcome", "NO"], ["data", "{\"title\":\"Will the Bitcoin network mine fewer than 140 blocks today? (May 4, 2026) (UTC 5,May,2026)\",\"description\":\"Target is 144 blocks per day. Resolves YES if total blocks mined between 00:00:00 and 23:59:59 UTC is 139 or less. \\n\\nSource of Truth: mempool.space/api/v1/blocks\",\"outcomes\":[\"YES\",\"NO\"],\"isSmj\":true,\"poolModel\":\"smj\",\"durationTier\":\"now\",\"feePercent\":4.21,\"minBetSats\":100,\"maxBetSats\":100000,\"resolutionSource\":\"https://mempool.space/api/v1/blocks\",\"resolutionLogic\":\"\"}"], ["poll", "a8e310da83e3a92c17a3ab72f32cac5bcac0dc53fa52cae0d24ba68d98504b98"]], "content": "₿ Will the Bitcoin network mine fewer than 140 blocks today? (May 4, 2026) (UTC 5,May,2026)\n\nTarget is 144 blocks per day. Resolves YES if total blocks mined between 00:00:00 and 23:59:59 UTC is 139 or less. \n\nSource of Truth: mempool.space/api/v1/blocks\n\n📈 🟢 YES | 🔴 NO\n\n⚡ Smj market (SMJ)\n\n⏱️ Closes: May 5 at 00:00 UTC\n\n#baomarkets #predictionmarkets #truthmachine", "sig": "dd4bbbdf0b62d149d0f7bc512d9017d818ab758e2cf2c292d2307fc29519ac07782f683b7b2654d7b04f5747b73dc40bdb0b794ae07d6f45492fd24db1da374d"}""" + + /** BAO Markets, current shape: five named outcomes, status "voided". */ + const val MARKET_VOIDED_MULTI_OUTCOME = + """{"id": "31da397fdc3a5c61aaa3fc12e8413279f99175ecdba65443d0a2e2495d73f5df", "pubkey": "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", "created_at": 1786702119, "kind": 38000, "tags": [["d", "181f3a7acd28280a49e81bf70ec22998"], ["market", "181f3a7acd28280a49e81bf70ec22998"], ["m", "181f3a7acd28280a49e81bf70ec22998"], ["type", "binary"], ["w", "binary"], ["state", "ended"], ["status", "voided"], ["s", "ended"], ["end", "1778889600"], ["yes_hash", "8379c8fb7f7c5fcf48b92e114bbade3e44cb9e918f9b529e4618f29d92c46f4b"], ["no_hash", "f978de5abf4264a4638e5d90da140f46a510c578ed916852729156f06bd5a45c"], ["yes_adaptor_point", "04ccddc9b30d3689a9478aa020155f5cacd833d304b3296cf4a9f65e8f0b76dea1993581ea15331502e4fc96641ad3c7d3c782d6e5aadfc83b97d3647989195f33"], ["no_adaptor_point", "0458bd7dcb9b57064489e79724f4ece3f0df725c91ffe6427318ec20d022f6e47ec8ef7f5f445d95a92a3228957334b47efabd6f17fe183c34e39a1839997af197"], ["oracle", "73d9e19ef07e0d098fc0fc5fb75db0f854824e8b4e43905acce638ddf6469960"], ["expiry_blocks", "287"], ["network", "demo"], ["n", "demo"], ["smj", "true"], ["duration_blocks", "143"], ["oracle_tier", "1"], ["pool_model", "smj"], ["duration_tier", "now"], ["fee_percent", "4.21"], ["min_bet", "100"], ["max_bet", "100000"], ["t", "smj-market"], ["t", "prediction-market"], ["t", "mining-pools"], ["client", "BAO Markets"], ["resolution_source", "https://mempool.space/api/v1/blocks"], ["escrow_method", "htlc"], ["outcome", "Foundry USA"], ["outcome", "AntPool"], ["outcome", "F2Pool"], ["outcome", "ViaBTC"], ["outcome", "Other"], ["data", "{\"title\":\"Which mining pool will mine the most Bitcoin blocks today? (May 15, 2026) (UTC 16,May,2026)\",\"description\":\"Resolves based on the total block count at midnight UTC. \\n\\nSource of Truth: mempool.space/api/v1/blocks\",\"outcomes\":[\"Foundry USA\",\"AntPool\",\"F2Pool\",\"ViaBTC\",\"Other\"],\"isSmj\":true,\"poolModel\":\"smj\",\"durationTier\":\"now\",\"feePercent\":4.21,\"minBetSats\":100,\"maxBetSats\":100000,\"resolutionSource\":\"https://mempool.space/api/v1/blocks\",\"resolutionLogic\":\"\"}"], ["poll", "5c7dc9874ddbfd350d4b4d5f984cf566d8895c4e24f958278ec4401fd038ba76"], ["t", "mining"], ["c", "mining"], ["category", "mining"]], "content": "🔮 Which mining pool will mine the most Bitcoin blocks today? (May 15, 2026) (UTC 16,May,2026)\n\nResolves based on the total block count at midnight UTC. \n\nSource of Truth: mempool.space/api/v1/blocks\n\n📈 🟢 Foundry USA | 🔴 AntPool | 🔵 F2Pool | 🟡 ViaBTC | ⚪ Other\n\n⚡ Smj market (SMJ)\n\n⏱️ Closes: May 16 at 00:00 UTC\n\n#baomarkets #predictionmarkets #truthmachine", "sig": "2046a2128d384ffd3b590bcbbd466f957c76bd9ab88fcdb7e6e80ca6baedd9a77f2183092f466102f392207adac5ef13c12779b7fe2fbe7837058c56637a728e"}""" + + /** BAO Markets, current shape: cancelled, with a cancel_reason and a JSON content. */ + const val MARKET_CANCELLED = + """{"id": "22c74e00e0d123f8cee351895a0a840a4473cab0935b86b79c02051979277363", "pubkey": "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", "created_at": 1786629062, "kind": 38000, "tags": [["d", "lt-0225a12b-msrhc3x6"], ["market", "lt-0225a12b-msrhc3x6"], ["m", "lt-0225a12b-msrhc3x6"], ["state", "cancelled"], ["status", "cancelled"], ["s", "cancelled"], ["n", "demo"], ["network", "demo"], ["c", "bitcoin"], ["t", "smj-market"], ["t", "prediction-market"], ["title", "Will BTC price be above $60,000 at 2026-08-13 13:00 UTC?"], ["end", "1786626000"], ["cancel_reason", "bridge-test junk"], ["e", "fdd7ccd4fb6dc6255d1b42d3b26b0b496aed5e7c275d6c26c5497376b84c01d0", "", "replaces"]], "content": "{\"title\":\"Will BTC price be above $60,000 at 2026-08-13 13:00 UTC?\",\"status\":\"cancelled\",\"reason\":\"bridge-test junk\",\"cancelledAt\":1786629062}", "sig": "df0998ce37ee7e90e925bb621faf5b40e8dc80ae579d0b7ec54e24e100f26e79c0c0bf7bd4ff304b1d927c2bf53aff0ad06ca07735d861796b05c8625368de86"}""" + + /** BAO Fund shape: title tag, ["outcome", id, label] tags, JSON content. */ + const val MARKET_BAO_FUND = + """{"id": "90ca7c1399f8309715f243e53f14c6ce7e4e71687b501290b5ea9553d9520e52", "pubkey": "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", "created_at": 1790725353, "kind": 38000, "tags": [["d", "baofund-fr_2450b9bc52e06b51-0"], ["title", "Will E2E demo-rail liquid refund munbn1yg deliver: E2E milestone munbn1yg by 2026-09-30?"], ["c", "bao-fund"], ["n", "demo"], ["network", "demo"], ["pool_model", "amm"], ["end", "1790727795"], ["outcome", "YES", "YES"], ["outcome", "NO", "NO"]], "content": "{\"title\":\"Will E2E demo-rail liquid refund munbn1yg deliver: E2E milestone munbn1yg by 2026-09-30?\",\"description\":\"Will E2E demo-rail liquid refund munbn1yg deliver: E2E milestone munbn1yg by 2026-09-30?\\n\\nCRITERIA: E2E milestone munbn1yg\\nTREASURY: 12000 sats locked (liquid)\\nRUNNER FEE: 2.14%\\nCHALLENGE WINDOW: 7 days after proof submission\\nDEMO: signet market, admin-resolved oracle.\",\"category\":\"bao-fund\",\"type\":\"binary\",\"outcomes\":[{\"id\":\"YES\",\"label\":\"YES\",\"price\":0.5},{\"id\":\"NO\",\"label\":\"NO\",\"price\":0.5}],\"endDate\":1790727795}", "sig": "280f2f6ea98789df8f77f4d1f2b194b2b76a6efede7513efffdd795de4dff4c372fc392cbb4322b53daffab171f35bf6d48fd08b72459ee488cc6c5ebbaba1df"}""" + + /** BAO Markets' first shape: type/end/state tags, outcomes as JSON objects in content. */ + const val MARKET_FIRST_SHAPE = + """{"id": "3c69cbbecbb2578283d3978e4dfc39774adb2bd5fa6f9642c0680b9fc0cae887", "pubkey": "fba1bbd8ab57f258673157defd5afc9ceda004c6845f99db3169fe4b61ba7416", "created_at": 1769205950, "kind": 38000, "tags": [["d", "baoMarkets-mkt-1769205950092-0b985e090"], ["type", "binary"], ["category", "nostr"], ["oracle", "community"], ["end", "1769256240"], ["state", "funding"], ["platform_fee", "200"], ["t", "trump"]], "content": "{\"title\":\"will trump turn purple before May?\",\"description\":\"Trump is orange, but when purple? I think he hes been orange for far too long\",\"outcomes\":[{\"id\":\"YES\",\"label\":\"Yes\",\"probability\":0.5,\"totalVolume\":0,\"price\":50},{\"id\":\"NO\",\"label\":\"No\",\"probability\":0.5,\"totalVolume\":0,\"price\":50}],\"minLiquidity\":100000,\"currentLiquidity\":0,\"resolutionTime\":1770984240}", "sig": "cb3230e903b7b1bd12b54cef3d7de79f41a5978ff8397b3b826c87a68d23065952d0b7b25b95a30004b62d21736d39a0ff2149b84ec6db829bf2023fad52d181"}""" + + /** Ballot with a responses array; values are strings and numbers. */ + const val BALLOT_RESPONSES = + """{"id": "abebb24cb12d96514726739fba359e1327eaa193649e8faa2909f4abb7ec907b", "pubkey": "7f8e147582c364ce91fbcdceabee0059a7d44b5caa0d51eb79fbfb7bd0c57346", "created_at": 1774357688, "kind": 38000, "tags": [["election", "sec06-feedback-1774311582"]], "content": "{\"election_id\":\"sec06-feedback-1774311582\",\"responses\":[{\"question_id\":\"q1\",\"value\":\"Yes\"},{\"question_id\":\"q2\",\"value\":\"Yes\"},{\"question_id\":\"q3\",\"value\":5},{\"question_id\":\"q4\",\"value\":\"9am. 10am would’ve been better. \"},{\"question_id\":\"q5\",\"value\":\"Staple food recommendations. It’s really hard to find food around Funchal. All of them are tourist traps. \"},{\"question_id\":\"q6\",\"value\":\"Sauna talks. It was promised. \"},{\"question_id\":\"q7\",\"value\":\"Filter water near the coffee area. \"},{\"question_id\":\"q8\",\"value\":\"4 weeks. \"},{\"question_id\":\"q9\",\"value\":\"NA\"},{\"question_id\":\"q10\",\"value\":\"The t shirts could be of better quality. Happy to pay for it. \"}],\"timestamp\":1774357688}", "sig": "501788604b03e1185d4f62232f2201c876dff570a3d795de993e2e90a92426361be7096b10edbf404753fd83a69e79d054202a64d4c80c9e053db6a43bd63c3f"}""" + + /** Ballot with a responses array and a proof-hash tag. */ + const val BALLOT_RESPONSES_PROOF_HASH = + """{"id": "3c3702d747bd27b59f5890b54f4e7f68402d4ead3893656fe003abf094fab780", "pubkey": "c908bcaaf6f2edf4edaab9c4b73c2b9ab0ffe6f6d76a960cbd1bbf6f94ee8248", "created_at": 1774910824, "kind": 38000, "tags": [["election", "spring-2026-council"], ["proof-hash", "361689b5ccd44f35eebba995129d8f2b00dd369aca4c51b55f00065b2b477ddd"]], "content": "{\"election_id\":\"spring-2026-council\",\"responses\":[{\"question_id\":\"proposal_approval\",\"value\":\"Yes\"}],\"timestamp\":1774910824}", "sig": "842f2f776960985bf4414d5c3770b3763c5cfa93ff31342e941117041e6684766e1100381ccc16aafa802439b1d2fbf0ac65c4d58de0c8793757699a8a032464"}""" + + /** Ballot with a ballot object, a proof_hash tag and a mint tag. */ + const val BALLOT_OBJECT = + """{"id": "78f5f012a9518a0b39134e659ef180eae6df9f6b38ce3b4b86963409837e2776", "pubkey": "caf0b6ef4eb461677ed7bf7b569a0212443d6429f6cc555ca8f00a599e453bb2", "created_at": 1773998101, "kind": 38000, "tags": [["t", "auditable-vote"], ["election", "spring-2026-council"], ["proof_hash", "bb64227a0b57fa6af765881b0952fe9d01a3a18faf52a3e34eeb7a896ee36b95"], ["mint", "http://localhost:8787/mock-mint"]], "content": "{\"election_id\":\"spring-2026-council\",\"proof_hash\":\"bb64227a0b57fa6af765881b0952fe9d01a3a18faf52a3e34eeb7a896ee36b95\",\"ballot\":{\"funding_priority\":\"community-grants\",\"audit_policy\":\"every-election\"}}", "sig": "79b1722a28ab18634994d939963734551748eaa5f1ce1459b43ef6349242849b721dfb57beda91409984cb3e364d5be65173c0e9011bcfa2f68fb26fba456c50"}""" + + /** Ballot with a lone vote_choice. */ + const val BALLOT_VOTE_CHOICE = + """{"id": "0e6b7a37d9db693dfdc7b2d8b4747efb8cac7fa493cc4915d9f07e6da61732ba", "pubkey": "5e7debebfde8730583455268731e4f8d2079dbe01cba06542e3f4825e598e233", "created_at": 1773715079, "kind": 38000, "tags": [["election", "election1"]], "content": "{\"vote_choice\":\"yes\"}", "sig": "d3259dfa20846d70f7045bdd5b80a3dc65610a4ebd12657a21e835f9ffa6bfbd3dde520d19b19133bc17cf32cfc5702473b9afe605ed7f6c0207dee923b4ad03"}""" + + /** NIP-87 recommendation of a Cashu mint (k = 38172, u). */ + const val MINT_REVIEW_CASHU = + """{"id": "9086081aea72122e58fd9c058ce6dd8068454750ee69b9e92d51896deb6201cb", "pubkey": "9d9789c0212d5157c0ac1351e1588b055dff0b91394261eecfa276e390382c7e", "created_at": 1790670504, "kind": 38000, "tags": [["k", "38172"], ["u", "https://21mint.me"], ["d", "https://21mint.me"]], "content": "[5/5] Been using it since a year now, most reliable mint out there.", "sig": "fd461995093e15c95dd807ecab361e5ef4f78d7198229b5e9e69708edc0930924487369757265d04938834aa73198f9731b30677f63319dca68b8106536187e7"}""" + + /** NIP-87 recommendation of a Fedimint (k = 38173, no u). */ + const val MINT_REVIEW_FEDIMINT = + """{"id": "092ce058a30d09d9dbe73808a2c15fd0146a27ebfb58a3c0c5a408e3b3e09c5f", "pubkey": "d362b888c17926dd5188a5228779251d17744b8ebe9ec5d313eff52c4776fdf3", "created_at": 1790796532, "kind": 38000, "tags": [["d", "6b2bcd96ab83d6ba31e36b821c89f6cc7a57d2133cdf884018833d3770456fe6"], ["k", "38173"], ["rating", "5"]], "content": "[5/5]", "sig": "7424fdfda8c7af66b5108c90ded4b893faf5f538ec24ca09f2ce85ec0dd9484c93e7c7024493577384ad850bac230211516051845bae2e90e151ed92fd5fd06c"}""" + + /** Old NIP-87 recommendation without a k tag, pointing at a mint URL. */ + const val MINT_REVIEW_NO_K = + """{"id": "557f112e147877de3f956c86d04cb2feeded99287ad0d2e56e9990ac1eef2d82", "pubkey": "89c16b5e5ba539db0d8921e75100cc8161737c381600ce47d3788dce18083604", "created_at": 1790252206, "kind": 38000, "tags": [["d", "https://mint.lnpay.cz"], ["u", "https://mint.lnpay.cz"], ["rating", "5"], ["comment", "Stable and reliable"]], "content": "Stable and reliable", "sig": "9a126f18b435234f1c2e9bda3b4b61a155cf8060de24be42cc28250ef447c1e42bbfac2191160589515edadeaaf74857ae4b5644c92d6a60e66366ad5bc4a5da"}""" + + /** Spam: a d-only "sybil test vote" (most kind-38000 events on big relays). */ + const val SPAM_SYBIL_VOTE = + """{"id": "6c0fccc4bead821665d6f4ce6f57d8dc5d67e60277b24950040796aa6e3266ee", "pubkey": "9bd0c7b7420e312c83201fc462fbd2150ec36ef9eed3db93c9fd0f35a79d9f52", "created_at": 1789336638, "kind": 38000, "tags": [["d", "713d9b889b5e207a85d8632e2e5f6926cf74c532299bc313a5fc105534007fce"]], "content": "[1/5] fedimint-observer security PoC (PR #124) - sybil test vote, safe to delete mass2#210099", "sig": "c42fc32ea66ee311b1d68d298e4160ff389f9e08c7615ac7f769a3a276e615377147122ab21b19f244043432a8d8aaa0e5b1eac0e608e0128587d0b6b35a7006"}""" + + /** A one-off app manifest: not a format Amethyst knows. */ + const val SPAM_APP_MANIFEST = + """{"id": "3abc7024d399b8e2e1aa4cd519c60a0bbfb439f1267a2f956f3d7d975c5c3e34", "pubkey": "c99281bdc983fcba340a23a3ff6380e3b93fde8621795535f1220eafd4fa26d3", "created_at": 1779037402, "kind": 38000, "tags": [["d", "pulsar-tunnel"], ["t", "pulsar"], ["t", "pulsar-tunnel"]], "content": "{\"name\":\"Pulsar Tunnel\",\"version\":\"0.1.0\",\"pubkey\":\"c99281bdc983fcba340a23a3ff6380e3b93fde8621795535f1220eafd4fa26d3\",\"transport\":\"nostr\"}", "sig": "5498710238eb4f2bd4f7bb848db3faf2e896a0e42bf2f6a3698037e5a4ff9e3f883038df11260375c301990b111a7a1b76e0f731e2de495af31a7cace8f92c21"}""" +} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt index 62616dd7ef..6dd19fd388 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt @@ -599,13 +599,12 @@ open class FsEventStore( } /** - * True when [kind] currently parses to a [SearchableEvent]. Kind - * alone selects the event class in [EventFactory], so a single probe - * per kind is authoritative. The id is non-blank so kinds that lazily - * hash a missing id (NIP-17 chat) skip that work — only the runtime - * type matters here. + * True when [kind] currently parses to a [SearchableEvent], decided by + * one [EventFactory.probe] per kind (a kind whose class is picked by + * tags answers as its primary class; [FsIndexer.linkFts] still checks + * each event itself). */ - private fun isSearchableKind(kind: Int): Boolean = EventFactory.create<Event>("0", "0", 0L, kind, emptyArray(), "", "") is SearchableEvent + private fun isSearchableKind(kind: Int): Boolean = EventFactory.probe(kind) is SearchableEvent private fun deleteRecursively(p: java.nio.file.Path) { if (!Files.exists(p)) return diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt index eb73ec3eba..e7efd459b7 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip50Search +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.utils.EventFactory import org.junit.Assert.assertEquals import org.junit.Test @@ -69,6 +70,9 @@ class IndexableContentGoldenTest { arrayOf("t", "hashtag2"), arrayOf("image", "https://example.com/i.png"), arrayOf("published_at", "1700000000"), + // Kind 38000 picks its class by tags; without a mint `k` it would sample as the + // plain Event its junk parses to rather than as the mint recommendation it indexes. + *if (kind == MintRecommendationEvent.KIND) arrayOf(arrayOf("k", "38172")) else emptyArray(), ), "The content body.", "", diff --git a/tools/search-parity/fetch_fixtures.py b/tools/search-parity/fetch_fixtures.py index ad4f08a2f0..1bc2dbfd60 100755 --- a/tools/search-parity/fetch_fixtures.py +++ b/tools/search-parity/fetch_fixtures.py @@ -6,7 +6,7 @@ Run by hand when the fixture needs refreshing; the test that consumes its output touches the network, so `./gradlew test` stays hermetic and offline. ./gradlew :cli:installDist - tools/search-parity/fetch_fixtures.py > commons/src/jvmTest/resources/search-parity-fixture.json + tools/search-parity/fetch_fixtures.py > tools/search-parity/fixture.json Each case pins the filter FIELDS (kinds/tags/window) as flags rather than folding them into the search string, so the test can rebuild the identical NIP-01 Filter and assert our matcher @@ -18,7 +18,7 @@ import shlex import subprocess import sys -RELAY = os.environ.get("RELAY", "wss://search-staging.brainstorm.world") +RELAY = os.environ.get("RELAY", "wss://search.brainstorm.world") AMY = os.environ.get("AMY", os.path.join(os.getcwd(), "cli/build/install/amy/bin/amy")) # The relay gates and ranks results through the searcher's web of trust, and answers an # anonymous query with nothing at all. `include:spam` waives that gate, which is what makes @@ -26,9 +26,9 @@ AMY = os.environ.get("AMY", os.path.join(os.getcwd(), "cli/build/install/amy/bin # to one account's trust graph and re-records differently as that graph moves. # # It does NOT make the relay lexical. Measured on this corpus, results still include events -# carrying no literal occurrence of the term — asked for `nostr` it returns "made my display -# name refer to my npub's last characters". Retrieval is semantic; see the parity test for why -# that means text results are not compared. +# carrying no literal occurrence of the term — asked for `"open source"` it returns "FOSS you say +# will save you". Retrieval is semantic; see the parity test for why that means text results are +# not compared. LENS = os.environ.get("LENS", "include:spam") LIMIT = os.environ.get("LIMIT", "8") TIMEOUT = os.environ.get("TIMEOUT", "30") diff --git a/tools/search-parity/fixture.json b/tools/search-parity/fixture.json index 561c5d4776..8ca8ad56ee 100644 --- a/tools/search-parity/fixture.json +++ b/tools/search-parity/fixture.json @@ -20,28 +20,6 @@ ] ] }, - { - "content": "Very interesting! Around 32 minutes he makes a comment about basically hand encrypting. Which makes me think about an interesting case for maybe an air gapped messaging device that only shares encrypted marmot messages over QR to an Internet connected device. \n\n", - "created_at": 1771074608, - "id": "40921da81160cfef0710b57100f7d5fb4053e3395b3f28274a1aa3a1a8396c5e", - "kind": 1, - "pubkey": "bbb5dda0e15567979f0543407bdc2033d6f0bbb30f72512a981cfdb2f09e2747", - "sig": "9cfadea6054d22678904c1c7767ad2125af450bfbec1cf3de8c973408b7e20626f2e905f1735b7874b27beb05598aba226ec4497c5378eadcb2e9020f0b514f8", - "tags": [ - [ - "p", - "b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc", - "wss://relay.primal.net" - ], - [ - "e", - "d3da9e89cb7c45bf26b5b3df4b3d33375ccb406227deb4484f7869ae9040142b", - "wss://relay.primal.net", - "root", - "b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc" - ] - ] - }, { "content": "Was Bitcoin hijacked?\n\nInteresting critiques of #Bitcoin from one of the co-authors of *Hijacking Bitcoin*. Has anyone read it? I'm adding it to my 2025 reading list... #bookstr\nnostr:nevent1qqsy7580q8rwtr8ujd723vefk552ka6adflp9dncxdew8qpudn4n45qpr4mhxue69uhkummnw3ezucnfw33k76twv4ezuum0vd5kzmp0qgsz2vdh9xuuqxrxpn5t8vs2h5p78ywg2zgxv43p6qh5g7usdc05mpcrqsqqqqqpj0pnhd", "created_at": 1735176083, @@ -72,28 +50,6 @@ ] ] }, - { - "content": "Trump's self-serving moves could be the demise of Bitcoin.\n\nhttps://www.theverge.com/2024/9/16/24246805/trump-world-liberty-financial-crypto-platform-announcement", - "created_at": 1726552577, - "id": "62423119247288c27ebd49d40b125c7c8bf1ff1991530b75274107d9d8b4cd26", - "kind": 1, - "pubkey": "3d2673d46bc1757c0a266610b0e71abd726e4b66555a7d457265116ef68337ae", - "sig": "9b4c38dd5108221763db28cffa7de4e2f6bedcb2a9ad52946de80b5ece983e3ec8a13108b60a0952e35d6ffa4f2a54049627774131401273c709a42b568150eb", - "tags": [ - [ - "subject", - "Trump will be the end of #Bitcoin" - ], - [ - "t", - "bitcoin" - ], - [ - "client", - "voyage" - ] - ] - }, { "content": "Don't think it would pass at the moment, but I'd love to see a vote forced on this to better see where each Congressperson stands. Overall, bullish.\nhttps://www.coinspeaker.com/cynthia-lummis-bitcoin-strategic-reserve/\n#bitcoin ", "created_at": 1721899426, @@ -168,6 +124,32 @@ ] ] }, + { + "content": "8 hours left, how high will it go?\nhttps://scarce.city/auctions/buy-bitcoin\n https://image.nostr.build/27076e466ee4da429f8d36759a8bb3535d334970f2aadfd4f4f62c4a319a08cb.png\n\n#bitcoin #plebchain", + "created_at": 1713970324, + "id": "b1c98f0c793453776c0d9e6846161599a6c92e8170b6668200907c60a4b2bb99", + "kind": 1, + "pubkey": "005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2", + "sig": "ce9359cc91b271ab0baeccb62a7736c69996da491538cb82b285edf9c091209197e163e0a82a5fdacda818d78ad5100376da98149bc4321a8b558ee6afd655f3", + "tags": [ + [ + "t", + "bitcoin" + ], + [ + "t", + "plebchain" + ], + [ + "a", + "34550:005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2:General" + ], + [ + "subject", + "Buy bitcoin sign auction" + ] + ] + }, { "content": "If you are looking for a personal reserve currency, #Bitcoin is simply unbeatable. All of the arguments against it don't matter, if you hardly ever interact with it and it merely sits in cold storage to prove your solvency.\n\nIf you just want to buy a cup of coffee, pay your taxes, or order a flight online, you can use a second-rail of Bitcoin to do that, like Lightning.\n\nOr use dollars or poker chips or frequent flyer miles or a pack of cigarettes or a silver coin or wash the dishes... or whatever they'll accept in exchange.\nNone of these things negate the importance of Bitcoin, nor are they generally more-efficient or effective than something like Lightning.", "created_at": 1704962711, @@ -189,6 +171,56 @@ "Bitcoin is digital gold" ] ] + }, + { + "content": "BTC \u2022 Liquidation Levels Heatmap 03/ENE/2024 06:10\nhttps://image.nostr.build/eb619c86c281a455c0627b88a153bfa9a341579e4446b8bcfed0cbfc2dcc54e7.jpg\nhttps://image.nostr.build/35a8ef90f3ce9db9a435c9720a1d472e8220c27a77604f1df584e0ffb321a85b.jpg\nhttps://image.nostr.build/e857466246849ac273d91c99feb50b27d94db95540c50d64994e7657701207ac.jpg\nhttps://image.nostr.build/18738e3619ffff3b3546aa18690c526648cffabb4caeb2eaece6b8f9619c7157.jpg", + "created_at": 1704268836, + "id": "9c8ff40fbcf6be612ebcd5e831a08cde2b7f9e2fff9be8805ce46ca4dfd42252", + "kind": 1, + "pubkey": "c40cb64de1a1f6ec0d6c6d98ea665a42a09d37868f2919ae5703b324baa7a1e9", + "sig": "fa9f37fd6b20a8c19ec488e855a3a772f75b7adf31ce3b5903940a3a5a4e0a6fb490ae6f4c9c02d2de0c125914125b8a85565c671826ebbe4251620557d7e50f", + "tags": [ + [ + "imeta", + "url https://image.nostr.build/eb619c86c281a455c0627b88a153bfa9a341579e4446b8bcfed0cbfc2dcc54e7.jpg", + "ox eb619c86c281a455c0627b88a153bfa9a341579e4446b8bcfed0cbfc2dcc54e7 https://nostr.build", + "x c9f05304a8006a4395ebe8b6585042c9356b4689a35ad7bf17b91ccff2022d63", + "m image/jpeg", + "dim 1386x840", + "bh LoHoa.sW?aozxak9ofkA~poeV[oL", + "blurhash LoHoa.sW?aozxak9ofkA~poeV[oL" + ], + [ + "imeta", + "url https://image.nostr.build/35a8ef90f3ce9db9a435c9720a1d472e8220c27a77604f1df584e0ffb321a85b.jpg", + "ox 35a8ef90f3ce9db9a435c9720a1d472e8220c27a77604f1df584e0ffb321a85b https://nostr.build", + "x ed4f7b7ad0a35f367606c076c6d52919e75e59cbd0740999d5271bcec59e8d8c", + "m image/jpeg", + "dim 1386x840", + "bh LoH.Z]nl?aozxakAoej@~poLWBn+", + "blurhash LoH.Z]nl?aozxakAoej@~poLWBn+" + ], + [ + "imeta", + "url https://image.nostr.build/e857466246849ac273d91c99feb50b27d94db95540c50d64994e7657701207ac.jpg", + "ox e857466246849ac273d91c99feb50b27d94db95540c50d64994e7657701207ac https://nostr.build", + "x 28db9107cdb7d148617034fbff4a73db1393f6c4cfe578f33e4ae22fd5bcf199", + "m image/jpeg", + "dim 1386x840", + "bh LJRo]W%N_MxF-?o|x@Vt.k%1Q.Ny", + "blurhash LJRo]W%N_MxF-?o|x@Vt.k%1Q.Ny" + ], + [ + "imeta", + "url https://image.nostr.build/18738e3619ffff3b3546aa18690c526648cffabb4caeb2eaece6b8f9619c7157.jpg", + "ox 18738e3619ffff3b3546aa18690c526648cffabb4caeb2eaece6b8f9619c7157 https://nostr.build", + "x c87414fbd60abc358799c98dc754cc31ab07d506f858a872cbf4134f0fb24fa4", + "m image/jpeg", + "dim 1386x798", + "bh LkPQmAE0%3-;%LaykBWB~E%MInNF", + "blurhash LkPQmAE0%3-;%LaykBWB~E%MInNF" + ] + ] } ], "flags": [ @@ -256,6 +288,41 @@ ] ] }, + { + "content": "nostr:naddr1qqnhgmpdwp5kutfjv4nxzcfhxy6j6vn9veskzde3x5kky6t5vdhkjm3dwejkuer0wgq3samnwvaz7tmnwpshg6tp94shycmpdesjucm0d5hsygpwl2n3twa5dh2mu6ma4rthqqnx6yt8fwgnhqtc4hd4ctnrmxrnxypsgqqqw5cqqp2llq", + "created_at": 1780004572, + "id": "0f07af38491d37cdfb55eea1896abd83388519ec8fde7f652c8da2dc2cce1e0e", + "kind": 1, + "pubkey": "2efaa715bbb46dd5be6b7da8d7700266d11674b913b8178addb5c2e63d987331", + "sig": "2eb289b08ca3c3f79c7f221dc13e8c3395179d538467783eec72b5dee50a634f2fe033b3d26a55400e6d2a8c8f591f7cd8219079ffb6d144691fba1709dc71d0", + "tags": [ + [ + "alt", + "A short note: nostr:naddr1qqnhgmpdwp5kutfjv4nxzcfhxy6j6vn9veskzd..." + ], + [ + "e", + "a4ac96cd4bd4f9abe16f13c26816da93af7f53fcc919636c8627cfc03c516624", + "wss://relay.primal.net/", + "root", + "174ed916a8520e623293cad8419a176edbcc8c1a1e5f5271e36c922730f9470f" + ], + [ + "p", + "174ed916a8520e623293cad8419a176edbcc8c1a1e5f5271e36c922730f9470f", + "wss://profiles.nostr1.com/" + ], + [ + "p", + "2efaa715bbb46dd5be6b7da8d7700266d11674b913b8178addb5c2e63d987331", + "wss://spatia-arcana.com/" + ], + [ + "client", + "Amethyst" + ] + ] + }, { "content": "It\u2019s insane. \n\nBugging Francis to put nwc lol ", "created_at": 1780003520, @@ -435,129 +502,45 @@ ] }, { - "content": "nostr:nprofile1qyd8wumn8ghj7ctjw35kxmr9wvhxcctev4erxtnwv4mhxqpqmvlm9xukx5pek66dzt2muwkefhx3mzc4thlzt756n9h8dplrtk3qmxle2g\nBITCOIN LIGHTNING PAYMENT ZAPS ACROSS SATELLITE IN HISTORIC FIRST\n\nhttps://s.w.org/images/core/emoji/16.0.1/72x72/26a1.png\n--\n\u270d\ufe0f A Bitcoin Lightning payment request was relayed through a geostationary satellite and then paid, marking the first public demonstration of a Lightning invoice transmitted through actual space.\n--\n\ud83d\udc49 First public demonstration of a Lightning invoice transmitted through actual space\n\ud83d\udc49 Lightning payment request relayed through a geostationary satellite\n\ud83d\udc49 Decoupling Bitcoin from single points of terrestrial failure\n\ud83d\udc49 Potential for satellite infrastructure to harden Bitcoin's communications layer\n\n--\n#Printer #Europe #Africa #MiddleEast #Asia #technology\n--\nnostr:nevent1qvzqqqqqqypzpkelk2devdgrnd456yk4hcadjnwdrk932h07yhaf4xtww6r7xhdzqyd8wumn8ghj7ctjw35kxmr9wvhxcctev4erxtnwv4mhxqpqyd70x9wr4mt7vp2ukvklwa2tr4pugaww4s83g4dzda24urverpzs3am40v \n ", - "created_at": 1757588519, - "id": "94bdc9e1d70588ac5516a29bc35647db7e721e63b6be731ae07d4ebd7f5284e8", + "content": "\nnostr:nprofile1qy3hwumn8ghj7un9d3shjtt5v4ehgmn9wshxkwrn9ekxz7t9wgejumn9waesqgrjxj24tzky80sws3pf636u0rwueruy07ttwhd3jng95xdm03masuq2e9z6\n\u270d\ufe0f Block Inc. announces the launch of its Bitcoin Business Stack, a comprehensive platform for merchants to accept and manage Bitcoin payments, and makes a historic push for Lightning payments at the Bitcoin 2025 Conference.\n\ud83d\udc49 Block Inc. launches the Bitcoin Business Stack, a comprehensive platform for merchants to accept and manage Bitcoin payments\n\ud83d\udc49 Merchants can now accept Bitcoin directly in-store using Square POS\n\ud83d\udc49 Block Inc. is rolling out Bitcoin payment capability for merchants using Square POS\n\ud83d\udc49 Block Inc. is committed to making Bitcoin an everyday currency for everyone\n\ud83d\udc49 Block Inc. is focused on making Bitcoin more accessible, secure, and usable\n\n#MilesSuter #Bitcoin2025Conference #technology\nnostr:nevent1qvzqqqqqqypzqu35j42c43pmur5yg2w5whrcmhxglprlj6m4mvv56pdpnwmuwlv8qy3hwumn8ghj7un9d3shjtt5v4ehgmn9wshxkwrn9ekxz7t9wgejumn9waesqgxcr3vrdp2kws3gqywadc8xx0deudaezl9kpl9qgm7fchuum8gfyucdrx7r \n", + "created_at": 1748460431, + "id": "664a7133a599f3494362f2f7df474645878e0a213f199ed7a67125038f67b069", "kind": 1, - "pubkey": "de1abdd3127788ab39fb6f558233067d7cf07e8165598738c9a46a9cff3979a0", - "sig": "08d5eb18ea7ff848f41d3f09688ffb1914761b38a33eb663a97452c9e50fd9984add5b93129789be5bee8f6de30010fc20c22288c9afe625053c1f28b92302fb", + "pubkey": "2902af7ca5cf9039dd5846469404be1010a5460af4e52b3a857a6db84dad3caa", + "sig": "4823794334a7d9c9198932a6e7498dac7a3ee96c82dc537f985e09c36917498d2d3059a20294be1216cd4dd2bb116a52284e55558804a49ff89bd13fe7050fc8", "tags": [ [ "r", - "https://bitcoinist.com/bitcoin-lightning-payment-satellite-historic-first/" + "https://bitcoinmagazine.com/news/block-announces-bitcoin-business-stack-makes-historic-lightning-payments-push-at-bitcoin-2025" ], [ "subject", - "Bitcoin Lightning Payment Zaps Across Satellite In Historic First" + "Block Announces Bitcoin Business Stack, Makes Historic Lightning Payments Push at Bitcoin 2025" ], [ "published_at", - "1757588406" + "1748460178" ], [ "image", - "https://s.w.org/images/core/emoji/16.0.1/72x72/26a1.png" + "https://bitcoinmagazine.com/wp-content/uploads/2025/05/Miles-Suter-BTC.jpg" ], [ "p", - "de1abdd3127788ab39fb6f558233067d7cf07e8165598738c9a46a9cff3979a0", - "wss://articles.layer3.news" - ], - [ - "p", - "db3fb29b9635039b6b4d12d5be3ad94dcd1d8b155dfe25fa9a996e7687e35da2", - "wss://articles.layer3.news" + "2902af7ca5cf9039dd5846469404be1010a5460af4e52b3a857a6db84dad3caa", + "wss://relay-testnet.k8s.layer3.news" ], [ "imeta", - "url https://s.w.org/images/core/emoji/16.0.1/72x72/26a1.png" + "url https://bitcoinmagazine.com/wp-content/uploads/2025/05/Miles-Suter-BTC.jpg" ], [ "t", - "Printer" + "Miles Suter" ], [ "t", - "Europe" - ], - [ - "t", - "Africa" - ], - [ - "t", - "Middle East" - ], - [ - "t", - "Asia" - ], - [ - "t", - "technology" - ], - [ - "t", - "Technology:perspective" - ], - [ - "summary", - "The experiment, conducted by user 'Printer', uplinked a Lightning invoice as an image to the QO-100 amateur radio transponder and downlinked it back to Earth, where it was decoded and settled over the Lightning Network. This demonstration showcases the potential for satellite infrastructure to harden Bitcoin's communications layer against last-mile failures, censorship, and disaster scenarios." - ] - ] - }, - { - "content": "\nnostr:nprofile1qyd8wumn8ghj7ctjw35kxmr9wvhxcctev4erxtnwv4mhxqpqmvlm9xukx5pek66dzt2muwkefhx3mzc4thlzt756n9h8dplrtk3qmxle2g\n\u270d\ufe0f A Bitcoin Lightning payment request was relayed through a geostationary satellite and then paid, marking the first public demonstration of a Lightning invoice transmitted through actual space.\n\ud83d\udc49 First public demonstration of a Lightning invoice transmitted through actual space\n\ud83d\udc49 Lightning payment request relayed through a geostationary satellite\n\ud83d\udc49 Decoupling Bitcoin from single points of terrestrial failure\n\ud83d\udc49 Potential for satellite infrastructure to harden Bitcoin's communications layer\n\n#Printer #Europe #Africa #MiddleEast #Asia #technology\nnostr:nevent1qvzqqqqqqypzpkelk2devdgrnd456yk4hcadjnwdrk932h07yhaf4xtww6r7xhdzqyd8wumn8ghj7ctjw35kxmr9wvhxcctev4erxtnwv4mhxqpqyd70x9wr4mt7vp2ukvklwa2tr4pugaww4s83g4dzda24urverpzs3am40v \n", - "created_at": 1757588519, - "id": "f54988f41ad840926c56362ff6a7a3194b6fdcd7cf82c11544c808b9bf1d9bdb", - "kind": 1, - "pubkey": "7455e74032c0aa128d6d3458612647578d3823526dd019e7f1a33f0f4b0fdb6c", - "sig": "0c08c3fcd70e2e4ab389bb30c235f815cabd9c8fe790211beac35e4563231a8f2639c2e12a00749fcfdee076f7123b00e889529761fdd8f35f4ba076a00905ad", - "tags": [ - [ - "r", - "https://bitcoinist.com/bitcoin-lightning-payment-satellite-historic-first/" - ], - [ - "subject", - "Bitcoin Lightning Payment Zaps Across Satellite In Historic First" - ], - [ - "published_at", - "1757588406" - ], - [ - "image", - "https://s.w.org/images/core/emoji/16.0.1/72x72/26a1.png" - ], - [ - "p", - "7455e74032c0aa128d6d3458612647578d3823526dd019e7f1a33f0f4b0fdb6c", - "wss://articles.layer3.news" - ], - [ - "imeta", - "url https://s.w.org/images/core/emoji/16.0.1/72x72/26a1.png" - ], - [ - "t", - "Printer" - ], - [ - "t", - "Europe" - ], - [ - "t", - "Africa" - ], - [ - "t", - "Middle East" - ], - [ - "t", - "Asia" + "Bitcoin 2025 Conference" ], [ "t", @@ -565,19 +548,19 @@ ], [ "p", - "db3fb29b9635039b6b4d12d5be3ad94dcd1d8b155dfe25fa9a996e7687e35da2", - "wss://articles.layer3.news" + "723495558ac43be0e84429d475c78ddcc8f847f96b75db194d05a19bb7c77d87", + "wss://relay-testnet.k8s.layer3.news" ], [ "e", - "237cf315c3aed7e6055cb32df7754b1d43c475ceac0f1455a26f555e0d991845", - "wss://articles.layer3.news", + "d81c5836855674228011dd6e0e633db9e37b917cb60fca046fc9c5f9cd9d0927", + "wss://relay-testnet.k8s.layer3.news", "root" ], [ "q", - "237cf315c3aed7e6055cb32df7754b1d43c475ceac0f1455a26f555e0d991845", - "wss://articles.layer3.news" + "d81c5836855674228011dd6e0e633db9e37b917cb60fca046fc9c5f9cd9d0927", + "wss://relay-testnet.k8s.layer3.news" ] ] } @@ -593,41 +576,62 @@ { "events": [ { - "content": "**Nostr Nedir?**\n\nNostr, d\u00fc\u015f\u00fcn\u00fclenin aksine bir uygulama de\u011fil, bir sosyal a\u011f protokol\u00fcd\u00fcr. Pop\u00fcler bir araca benzetmek gerekirse Twitter diyebiliriz ancak Nostr'\u0131n belli bir patronu ya da merkezi bir sahibi yoktur. Sizin payla\u015ft\u0131\u011f\u0131n\u0131z g\u00f6nderileri silmek ya da belli bir kategoriye koymakla g\u00f6revlendirilmi\u015f tek bir kurum ya da ki\u015fi bulunmaz. Nostr, herkesin \u00f6zg\u00fcrce fikirlerini beyan edebildi\u011fi merkeziyetsiz bir sosyal a\u011fd\u0131r.\n\n\n**Nostr hesab\u0131m\u0131 nas\u0131l kurabilirim?**\n\nNostr anahtarlar\u0131 iki b\u00f6l\u00fcme ayr\u0131l\u0131r: nsec ve npub. nsec sizin gizli parolan\u0131z olmakla beraber Nostr hesab\u0131n\u0131z\u0131n kalbidir. Bu parolay\u0131 kaybetti\u011finiz anda hesab\u0131n\u0131za eri\u015fiminiz tamamen kapan\u0131r. nsec anahtar\u0131n\u0131z\u0131 kimseyle payla\u015fmad\u0131\u011f\u0131n\u0131z s\u00fcrece hesab\u0131n\u0131z ba\u015fkalar\u0131 taraf\u0131ndan ele ge\u00e7irilemez. npub ise sizin kullan\u0131c\u0131 ad\u0131n\u0131z (herkese a\u00e7\u0131k adresiniz) gibi d\u00fc\u015f\u00fcn\u00fclebilir. Nostr hesab\u0131 olu\u015fturmak i\u00e7in do\u011frudan Primal veya Amethyst gibi bir Nostr uygulamas\u0131n\u0131 indirip saniyeler i\u00e7inde anahtarlar\u0131n\u0131z\u0131 \u00fcretebilirsiniz.\n\n\n**Nostr ad\u0131 alt\u0131nda bir uygulama bulam\u0131yorum?**\n\nEvet, bu \u00e7ok normal; \u00e7\u00fcnk\u00fc yaz\u0131n\u0131n ba\u015f\u0131nda da belirtti\u011fim gibi Nostr bir uygulama de\u011fil, bir internet protokol\u00fcd\u00fcr. A\u015fa\u011f\u0131da bulabilece\u011finiz uygulamalar\u0131 kullanarak al\u0131\u015f\u0131lagelen kullan\u0131m\u0131n\u0131za devam edebilirsiniz:\nPrimal: Ak\u0131\u015f\u0131 takip etmek i\u00e7in (Twitter benzeri aray\u00fcz)\nAmethyst: Android i\u00e7in pop\u00fcler bir Nostr istemcisi\nYakihonne: Uzun makale ve blog yazmak isteyenler i\u00e7in\n\n\n**Ak\u0131\u015f\u0131m nas\u0131l takip ediliyor?**\n\nInstagram ve Twitter benzeri uygulamalar\u0131n aksine Nostr size ba\u011f\u0131ml\u0131l\u0131k yapacak yapay zek\u00e2 algoritmalar\u0131yla bir ak\u0131\u015f olu\u015fturmaz. Sadece takip etti\u011finiz ki\u015filerin ve kanallar\u0131n payla\u015ft\u0131klar\u0131n\u0131 g\u00f6r\u00fcrs\u00fcn\u00fcz. Bu da saatlerce i\u00e7erik kayd\u0131rmak gibi vakit kay\u0131plar\u0131n\u0131 minimuma d\u00fc\u015f\u00fcr\u00fcr.\n\n\n**Zap / Bitcoin**\n\nNostr, \"Zap\" destekli (mikro bah\u015fi\u015fler) bir platformdur. \u0130nsanlar payla\u015ft\u0131\u011f\u0131n\u0131z g\u00f6nderilerin alt\u0131na Zap g\u00f6ndererek size Bitcoin \u00fczerinden do\u011frudan ba\u011f\u0131\u015fta bulunabilirler.\n\n\n**Zap hesab\u0131 ve kurulumu**\n\n\u00d6ncelikle Nostr hesab\u0131n\u0131za eri\u015fim sa\u011flay\u0131n. Daha sonras\u0131nda Wallet of Satoshi veya benzeri bir Bitcoin Lightning c\u00fczdan\u0131 \u00fczerinden bir Lightning adresi olu\u015fturun. Kulland\u0131\u011f\u0131n\u0131z uygulama (Primal, Amethyst vb.) profil ayarlar\u0131ndan Lightning adresinizi ba\u011flay\u0131n ve bitti. Art\u0131k di\u011fer insanlardan Zap alabilirsiniz.\n\n\n**Genel \u00d6zet**\n\nNostr; beynimizi \u00e7\u00fcr\u00fctmeyen, zaman\u0131m\u0131z\u0131 bo\u015fa harcatmayan bir platform olarak g\u00fcny\u00fcz\u00fcne \u00e7\u0131kmaya ba\u015flad\u0131. Belli bir ki\u015finin kurdu\u011fu uygulamalarda saatlerce vakit ge\u00e7irip hesab\u0131n\u0131z\u0131n nedensizce kapat\u0131lmas\u0131 ya da sans\u00fcrlenmesi riskine gerek yok. Daha \u00f6zg\u00fcrl\u00fck\u00e7\u00fc olan bu a\u011f hepimize iyi gelecek. Sa\u011fl\u0131cakla kal\u0131n.\n\ntopluluk@nostr.org.tr", - "created_at": 1788483939, - "id": "a699579a051fa603b4ea0d7cd4a4e354c345e7b8959df02e87aa57242cdc8ec8", + "content": "## the protocol\n\n[nostr](https://github.com/nostr-protocol/nostr) is a protocol for establishing pseudonymous identities and using them to cryptographically sign messages and pass them around via relays. it grew out of a small community of bitcoin/lightning devs, specifically [fiatjaf](nostr:npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6). it was a reaction to the counter-party risks in using centralized social networks (twitter, facebook, etc) and the failings of other, semi-decentralized approaches (mastodon).\n\nbitcoin familiarity helps when groking nostr. the identity portion of nostr is private/public key pair. similar to a bitcoin wallet where one uses one\u2019s private key to sign a transaction and publish it into the network, one uses a nostr private key to sign an event and publish it to relays. there are various defined [kinds](https://github.com/nostr-protocol/nips#event-kinds) of events, from profile information (name, about, url, avatar url, etc), to contact lists (follows), to notes (a short plain text message). the bitcoin network is peer to peer (distributed), where nodes will gossip transactions around to all the other nodes, but this is not the case with nostr. events stay where you publish them, relays only talk to clients, not other relays. this makes nostr decentralized, but not distributed. since it\u2019s not peer-to-peer, one client doesn\u2019t need to see the entire traffic of the network, or to query some distributed hash table a la IPFS, to find content across the network. this middle ground between centralized and distributed allows snappy response times yet prevents the domination of network lock-in.", + "created_at": 1782172014, + "id": "4c3ffd171a535f9bac1afc47526b565833c726f82e59b3bf287db3e39cafc48e", "kind": 30023, - "pubkey": "628b2c9aedcf9da47f42128402f3fd82787dac75c3595db1bccf3b7baab0452f", - "sig": "cf0cdbf17d750c38a0fc0e168d68338761e84f9eb174e09806e656002de23d4503f5c07a3872228058bfe5154db5d5eaf01a734d063f8e361aef4c42941961ff", + "pubkey": "27c66d241898300a254f0853dfbfcf735a627dc2790c45a03de04704dd54a3b4", + "sig": "cc646b6cd05fe3e1fbcc365e9a042ca0ecd0b04523d3c228ce1504dc77703cea150ebdab0e4704fe3fb9bc5d6a08acf3ca32367386cdbab4a778e18213f85ab2", "tags": [ - [ - "client", - "YakiHonne", - "31990:20986fb83e775d96d188ca5c9df10ce6d613e0eb7e5768a0f0b12b37cdac21b3:1700732875747" - ], - [ - "d", - "579d7bcc2e06132c" - ], - [ - "image", - "" - ], [ "title", - "Nostr Nedir?" + "nostr" ], [ "summary", - "" + "## the protocol\n\n[nostr](https://github.com/nostr-protocol/nostr) is a protocol for establishing pseudonymous identities and using them to cryptographically sign messages and pass them around via" ], [ "published_at", - "1788483851" + "1676764800" ], [ - "t", - "#nostr" + "d", + "d4b8f38a8c6e1e9a9ecbffecb23fd7dfe7b68b18d08584cb7b09d7e858f5ebf6" + ], + [ + "r", + "https://plantimals.org/nostr/" + ] + ] + }, + { + "content": "## the protocol\n\n[nostr](https://github.com/nostr-protocol/nostr) is a protocol for establishing pseudonymous identities and using them to cryptographically sign messages and pass them around via relays. it grew out of a small community of bitcoin/lightning devs, specifically [fiatjaf](nostr:npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6). it was a reaction to the counter-party risks in using centralized social networks (twitter, facebook, etc) and the failings of other, semi-decentralized approaches (mastodon).\n\nbitcoin familiarity helps when groking nostr. the identity portion of nostr is private/public key pair. similar to a bitcoin wallet where one uses one\u2019s private key to sign a transaction and publish it into the network, one uses a nostr private key to sign an event and publish it to relays. there are various defined [kinds](https://github.com/nostr-protocol/nips#event-kinds) of events, from profile information (name, about, url, avatar url, etc), to contact lists (follows), to notes (a short plain text message). the bitcoin network is peer to peer (distributed), where nodes will gossip transactions around to all the other nodes, but this is not the case with nostr. events stay where you publish them, relays only talk to clients, not other relays. this makes nostr decentralized, but not distributed. since it\u2019s not peer-to-peer, one client doesn\u2019t need to see the entire traffic of the network, or to query some distributed hash table a la IPFS, to find content across the network. this middle ground between centralized and distributed allows snappy response times yet prevents the domination of network lock-in.", + "created_at": 1782142849, + "id": "d957e3daa1b7c6275cb2335edbe67bef9e21218aaa32731f7362f2b17367e297", + "kind": 30023, + "pubkey": "8d5b9142b2021c38ecbd9fe6bead324836dad84379643507eb8a4ce58dd31feb", + "sig": "f93128dc50737a678b41d8d57c051281839325e7de6692605902d3bb718ec5a450bde6e28a4715dac852797c161ba54f96d69183a4826506ca1b7232817b07cf", + "tags": [ + [ + "title", + "nostr" + ], + [ + "summary", + "## the protocol\n\n[nostr](https://github.com/nostr-protocol/nostr) is a protocol for establishing pseudonymous identities and using them to cryptographically sign messages and pass them around via" + ], + [ + "published_at", + "1676764800" + ], + [ + "d", + "d4b8f38a8c6e1e9a9ecbffecb23fd7dfe7b68b18d08584cb7b09d7e858f5ebf6" + ], + [ + "r", + "https://plantimals.org/nostr/" ] ] }, @@ -700,74 +704,6 @@ ] ] }, - { - "content": "nostr.wine nostr relay.", - "created_at": 1771436582, - "id": "85fe5aa85429bb367ce0cf8f03f71f74ba83dd02f1b42f666bfb921bb07c7c86", - "kind": 30023, - "pubkey": "e938eb5077f30bd8fd0f9a0681514e52706fb231f0ac7516e3194530618addfa", - "sig": "40c7e26e24663feb4a22906d22bb424740ddf826d6c71a2a4839e8adf533f11692ad1b19c9566414849e2519b508398b7d848e63b2b9ef6631567b3242bd0460", - "tags": [ - [ - "d", - "nostr_wine" - ], - [ - "title", - "nostr.wine Nostr Relay" - ], - [ - "image", - "" - ], - [ - "published_at", - "2026-02-18 17:43:02 +0000 UTC" - ], - [ - "t", - "nostr" - ], - [ - "t", - "relay" - ] - ] - }, - { - "content": "nostr.mom nostr relay.", - "created_at": 1771436581, - "id": "d6f0e0db71f5b276e008d7285b896e069b78f0eb5da3b34fdd87c97eac66d01a", - "kind": 30023, - "pubkey": "e938eb5077f30bd8fd0f9a0681514e52706fb231f0ac7516e3194530618addfa", - "sig": "6936e469dd04081e2fd9eb79800793258d7cbae1a0f178a05d9a2480037f48ec78b9e9e3d3486ccdaa3922f29eae73a7b7734222f7a76070d3cdbc9260fe487a", - "tags": [ - [ - "d", - "nostr_mom" - ], - [ - "title", - "nostr.mom Nostr Relay" - ], - [ - "image", - "" - ], - [ - "published_at", - "2026-02-18 17:43:01 +0000 UTC" - ], - [ - "t", - "nostr" - ], - [ - "t", - "relay" - ] - ] - }, { "content": "Nostr (Notes and Other Stuff Transmitted by Relays) is a decentralized, censorship-resistant social protocol designed to give users control over their online identities and interactions.", "created_at": 1771436580, @@ -874,6 +810,60 @@ "This is a long form article, you can read it in https://habla.news/franzap/respect-the-a-context" ] ] + }, + { + "content": "\n# Blogging Nostr!\n\n\n![avatar](https://paynym.is/PM8TJXVwNbJb8MGphGbijWucP3fw9mkDZ9BQT3pGkCsdFK21cR6v5xKrart2oBsqDMkxtqMGgkoTgn1NAczLznUeTwa4BG2rs1zZDfEtKin7WuBcdvcK/avatar)\n\n------------\n\n\n\n\nHabla is a new nostr native blogging platform. It is the second one I have found. I'm, writing this as a test, but so fair I have noticed my markdown syntax is not being rendered yet. On the other hand, I also seem to be able to see my blogstack post on Habla as well. This is why it's a test. I'm still trying to wrap my head around how this all works.\n\n\n\nI've been writing a blog about how I use bitcoin since April on a platform namedtw Ghost under a pseudonym. I wrote on that platform for the following reasons:\n\n* I want to be a writer\n\n* I spend most of my waking hours thinking about bitcoin.\n\n* Marty Bent uses Ghost and had this paywall I thought was really cool at the time.\n\nMarty's paywall is actually kind a neat, Instead of asking his readers to pay an annual subscription fee like the New York Times, You could just pay 50 cents per article. This is much better than the New York Times Paywall. When I first heard of it, I thought writers could only get paid on the Internet with a paywall or ads. The mechanics of the system did not leave many options. Lightning addresses worked, but most people never heard of them and even most Bitcoiners didn't use them. Yalls had a paywall, but the UX wasn't great. I first thought of starting a blog because of yalls. I thought I could do magic tricks over video for tips, like buskers in Vegas, but on the Internet. Then I thought, I'm not that good at magic and I don't feel comfortable throwing my face out there like a whore on the Internet (No disrespect to sex workers, but **I doubt anyone would pay for my sex services** and **my wife would not approve**.) I also have a degree in creative writing, but I'm a bit rusty. The lightning network got me interested in writing again.\n\nIt's what prompted me to run my own lightning node in the first place. I think this was back in 2018 or 2019. At the time, I tried running [Pierre Rochard's lightning node](https://github.com/lightning-power-users/node-launcher), but didn't have the technical chops to pull it off on my old Windows laptop. It might have worked if I knew that I needed to plug the damn thing into an Ethernet cable to download the bitcoin blockchain.(We didn't use the term time chain in those days).\n\n----\n\nHis paywall was an order of magnitude better than running my own node to accept tips on yalls.org, so I started my blog on ghost with the intention of using his paywall software.\n\nI spun up a virtual private server on Bitlaunch.\n\nI installed Yunohost because it was easy to set up Ghost. \n\nNow Marty finally launched his service. If you like this model, you can use it for charges 10 bucks a month.\n\nFor the record, **I really like Marty**. I listen to Rabbit Hole Recap every week. I'm not trying to ruffle any feathers, or shit on his side project, but [@dergigi](https://dergigi.com/2021/12/30/the-freedom-of-value/) convinced that the paywall model will soon go the way of the doorstep delivered newspaper. If you don't have time to read or listen to that entire article, please listen to this 30 second clip of the Bitcoin.Review podcast created by [Oscar Merry](https://fountain.fm/clip/VF7nRhSOGejzI4KbfsWG).\n\n----\n\n## Blogs Can Be Nostr Native\n\nThere's no reason to spin up a server on a VPS to start a blog anymore. No reason to host it on a Raspberry Pi. You don't need to buy a shared hosting account. You don't need to install ruby, gem or jekyll to host it on Github. You don't even need to buy a domain, although you might want to buy one for a [DIY Nip 05 verification](https://nvk.org/n00b-nip5). Hopefully, in the not too distant future, you won't even need Github. Habla.news is a Medium alternative, but there's no reason why we couldn't have a Github alternative. We're just waiting on someone to build it. \n\nThe reason I find this value 4 value model built on Nostr so exciting now is because *it is now trivial for those who produce value to receive value* and *trivial for those who cherish valuable content to send value to those who produce it*. \n\n**Why spend the time setting up ghost or pay a fee to accept lightning payments or a ghost account?** --*Especially if you are writing a blog about bitcoin.\n\nI like the idea of charging for services, but paywalls just don't jive with the old walled garden model anymore. \n\nNostr is censorship resistant. Anyone can create a nostr key.\n \n[Some freaks say it's not normie ready](https://iris.to/post/note1mn98w4szpnkdfc53sqmgwme0akrt7wz5vzvvwwm840qv0020zu4s2fe62f), but it is much easier to create a private key than create a blog using the aforementioned options. I look forward to the day when we see more no-coiners witnessing how trivial it is to send value over the Internet. [@TheGuySwann](https://iris.to/post/note1c39e8k2c0uj7l77q0p7md5a9rahumv8dr57crd3r4xq7tegecn9sx7tckq) wrote a great post about how Facebook spent years developing their shitcoin, only to get shut down by congress.\n\n----\n### Paywalls Not Required\n\n**You couldn't accept native bitcoin on any other platforms until now**. Now **plebs are zapping each other left and right**.\n\nIn the value 4 value model, the zapper sets the value on the article. The paywall sets a price on reading the full article.\n\n\n\n* We **don't need paywalls anymore.**\n\n* **I don't need to send you spam** .\n\n* If you want to read my blog, just **follow me on Nostr.**\n\n* We **don't need to rely on digital overlords** to create content. \n\n* We **don't need ads **to monetize content. \n\n**Ads suck.**\n\nThis is **pure vida.**\n\n----\n\n**Nostr is the first amendment translated into code** like Satoshi translated this math:\n\n![bitcoin supply math](https://blog.lopp.net/content/images/2022/01/subsidy_equation.jpg)\n\n\n----\n\n\nInto this code:\n\n----\n\n![bitcoin monetary supply ](https://blog.lopp.net/content/images/2022/01/Screenshot-from-2022-01-10-18-07-46.png)\n\n----\nWe can write without **undue influence** from ads, corporations, or even governments.\n\n\n\n**We can create a better Internet.**\n\n\n\n**Code is speech.\nCypherpunks write code.**\n\n\n**Nostr makes speech unstoppable**.\n\nThat's a big fucking deal.\n\n\n\n**Blogging Bitcoin**\n\n![block](https://nostr.build/i/nostr.build_62b2b95fdf1b8df642842d7a7d27c3dee975e32ed60db4ab9abf3e9f19885526.png)\n\n------------\n\n\n[![Value 4 Value](https://nostr.build/i/nostr.build_571397a69b34a41f499ad9b057a49c65531e104785428e5a00a56a500017e60f.png)](https://bloggingbitcoin.store/apps/cd4EUVkfhWbTg5UZsbCJPi5ghxZ/pos)\n", + "created_at": 1677183856, + "id": "d1d627b2e4120e4362c206a49c39d2cd7abc4bf49b83e282cb9b58cb71b2af2f", + "kind": 30023, + "pubkey": "75bf23531ae9f98c62995ba07191e488ead475975371d63d7dfd46bde1bfa895", + "sig": "a76f74be0cf073d1d197d20675092dc4e6d03a850db7ce50622e180f1a7d12ad2c1a689e8d85a35aea308293f73a1acc6d375c77df404bb3bd7b08037dbba95e", + "tags": [ + [ + "client", + "blogstack.io" + ], + [ + "title", + "Nostr" + ], + [ + "image", + "https://nostr.build/i/karnage/nostr.build_ac9b396c77ef9ef096ff7c24c8403b6653df51b56c76d65e6db17540ff7d6e3c.png" + ], + [ + "summary", + "Blogging on Nostr instead of Ghost using the value 4 value model instead of paywalls." + ], + [ + "t", + "nostr" + ], + [ + "t", + "blog" + ], + [ + "t", + "value4value" + ], + [ + "t", + "bitcoin" + ], + [ + "t", + "freespeech" + ], + [ + "d", + "3f9775d8" + ], + [ + "published_at", + "1677183856" + ] + ] } ], "flags": [ @@ -934,28 +924,6 @@ ] ] }, - { - "content": "Trump's self-serving moves could be the demise of Bitcoin.\n\nhttps://www.theverge.com/2024/9/16/24246805/trump-world-liberty-financial-crypto-platform-announcement", - "created_at": 1726552577, - "id": "62423119247288c27ebd49d40b125c7c8bf1ff1991530b75274107d9d8b4cd26", - "kind": 1, - "pubkey": "3d2673d46bc1757c0a266610b0e71abd726e4b66555a7d457265116ef68337ae", - "sig": "9b4c38dd5108221763db28cffa7de4e2f6bedcb2a9ad52946de80b5ece983e3ec8a13108b60a0952e35d6ffa4f2a54049627774131401273c709a42b568150eb", - "tags": [ - [ - "subject", - "Trump will be the end of #Bitcoin" - ], - [ - "t", - "bitcoin" - ], - [ - "client", - "voyage" - ] - ] - }, { "content": "Don't think it would pass at the moment, but I'd love to see a vote forced on this to better see where each Congressperson stands. Overall, bullish.\nhttps://www.coinspeaker.com/cynthia-lummis-bitcoin-strategic-reserve/\n#bitcoin ", "created_at": 1721899426, @@ -1030,6 +998,32 @@ ] ] }, + { + "content": "8 hours left, how high will it go?\nhttps://scarce.city/auctions/buy-bitcoin\n https://image.nostr.build/27076e466ee4da429f8d36759a8bb3535d334970f2aadfd4f4f62c4a319a08cb.png\n\n#bitcoin #plebchain", + "created_at": 1713970324, + "id": "b1c98f0c793453776c0d9e6846161599a6c92e8170b6668200907c60a4b2bb99", + "kind": 1, + "pubkey": "005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2", + "sig": "ce9359cc91b271ab0baeccb62a7736c69996da491538cb82b285edf9c091209197e163e0a82a5fdacda818d78ad5100376da98149bc4321a8b558ee6afd655f3", + "tags": [ + [ + "t", + "bitcoin" + ], + [ + "t", + "plebchain" + ], + [ + "a", + "34550:005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2:General" + ], + [ + "subject", + "Buy bitcoin sign auction" + ] + ] + }, { "content": "If you are looking for a personal reserve currency, #Bitcoin is simply unbeatable. All of the arguments against it don't matter, if you hardly ever interact with it and it merely sits in cold storage to prove your solvency.\n\nIf you just want to buy a cup of coffee, pay your taxes, or order a flight online, you can use a second-rail of Bitcoin to do that, like Lightning.\n\nOr use dollars or poker chips or frequent flyer miles or a pack of cigarettes or a silver coin or wash the dishes... or whatever they'll accept in exchange.\nNone of these things negate the importance of Bitcoin, nor are they generally more-efficient or effective than something like Lightning.", "created_at": 1704962711, @@ -1053,24 +1047,24 @@ ] }, { - "content": "CyberHornet of Bitcoin\n\nhttps://cdn.satellite.earth/4da669a843213de2f1c4a28ae28e10edcbe646134a19c7eaf183c22a21889b7b.jpg\n\nIt is imperative of the pleb to combat attacks on #Bitcoin, verbal or otherwise.", - "created_at": 1698786952, - "id": "030f7946eb8b5a6d8327fdbad510fcb45bab9730133660a17f9faaf189604a37", + "content": "Today's Bitcoin News! (10 min. video) \"Bitcoin ETF Race is ON, $1.2 Million per Bitcoin?, #Bitcoin vs Gold, OpenSats onboards Tobin Harding #Nostr #BTC\"\n\nhttps://youtu.be/8OvAm_xPM1I\n\nYouTube \u2014> https://youtu.be/8OvAm_xPM1I\n\nToday we discuss the the race for the first Bitcoin spot ETF in the US, significant leadership changes at Grayscale Investments, Cathie Wood's bold Bitcoin prediction, the ongoing debate between Bitcoin and Gold, and the latest developments from OpenSats. Join us as we explore these pivotal moments in the cryptocurrency world and what they mean for investors and enthusiasts alike. Here are all the topics and link: \n\nBitcoin Archive account on Nostr \u2014 https://nostr.com/npub15h5n4muwsgxtc74hkcs9lp2ts7hdfdyvta4np7a7hfwfneqdeuls5qtl4k\n\nThe Bitcoin spot ETF race: \n\t\u201cSEC tells spot Bitcoin ETF applicants to update submissions by December 29 if they want to be approved in the first wave\u201d - Reuters. \n\tFIDELITY BITCOIN ETF \u2014 A spot Bitcoin ETF will give 124m Americans immediate and direct access to Bitcoin through their brokerage accounts. Fidelity has 43 million brokerage accounts for customers. Fidelity also has a Bitcoin ETF application. \n\tCNN says \"The spot Bitcoin ETF could quickly reach your 401(k) retirement plan...It\u2019s a big step toward mainstream adoption of bitcoin.\" \ud83d\ude80\n\nBarry Silbert has resigned as the chairman of Grayscale Investments. He will be replaced by Mark Shifke, the current Chief Financial Officer of Digital Currency Group (DCG), effective January 1. This change was reported in a recent SEC filing by Grayscale. The company is currently awaiting a decision from the Securities and Exchange Commission regarding the transformation of its Bitcoin Trust into a U.S. spot exchange-traded fund (ETF). Additionally, Mark Murphy, DCG's president, also resigned from the board. This move comes amid ongoing legal challenges faced by DCG, including a lawsuit from the New York Attorney General for alleged investor fraud. For more details, you can read the full article on CoinDesk's website \u2014 https://www.coindesk.com/business/2023/12/26/barry-silbert-resigns-as-grayscale-chairman-to-be-replaced-by-mark-shifke/\n\nCathie Wood Bitcoin Prediction: \"People Have NO IDEA What's Coming\u2026\" \u2014 $700 Billion Bitcoin market cap right now. Prediction: $20 trillion by 2030, which is about 30x from today. That would make 1 BTC = around $1.2 Million dollars. VIDEO \u2014 https://youtu.be/f7IB6V4UiA4\n\nAn amazing visual comparison between the evolution of the price of Gold and Bitcoin since 2014. #Bitcoin or Gold? --- https://twitter.com/TheBTCTherapist/status/1739668557389070654\n\nFrustrating facts \u2014 Lopsided inflation where regular people always lose (Opt out with #Bitcoin) --- https://twitter.com/_sriramk_/status/1739692573998875114\n\nThe Nostr Report: By plebs, for plebs. A synopsis of the best content of the day. Don\u2019t miss a beat. \u2014 https://Nostr.Report\n\nOpenSats onboards Tobin Harding --- https://primal.net/e/note1c2fnn5mvmtwngxz23auq8hmemwhlsms9ae6k4stel9utn4qjxgjqtd9h4h \u2014 OpenSats is a non-profit organization dedicated to supporting open-source Bitcoin development and other related projects. Its primary mission is to provide financial assistance and resources to developers and teams working on open-source projects that contribute to the Bitcoin ecosystem. The organization operates on a transparent funding model, allowing donors to see exactly where their contributions are going. OpenSats focuses on funding initiatives that enhance the security, scalability, and usability of Bitcoin, with an emphasis on projects that remain open and accessible to the public. By supporting these endeavors, OpenSats aims to strengthen the Bitcoin network and ensure its continuous improvement, while fostering a community of developers committed to the principles of open-source software.", + "created_at": 1703627501, + "id": "35ff530dc21630e85b61ee817bb9d48de07638c36d961d628974d0508d7f83ed", "kind": 1, - "pubkey": "50c5c98ccc31ca9f1ef56a547afc4cb48195fe5603d4f7874a221db965867c8e", - "sig": "8c2450128c786b30beb83b1bfaa288e8de5a553e325541a9271741e8b01b9bd122b3ebf4bc56237ebd1d6aaa44ededb043c6db19dde18ed40af2453b0edeca4b", + "pubkey": "90aaac10e033b4d1e607d426fc0eaabb9caa86720421dad8ae30182dd3efdb01", + "sig": "f874fd91c0ce8ee8ef19d06573fb2ac2b873ff36924e52c1b302297ff606c3d85dc167a4bc76f25012a214bdc4e8f02f247db9fe9ff5de022b60ce826ff14e57", "tags": [ [ "a", - "34550:f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0:Art" + "34550:5c50661b092cc786b55cb5d69149b129bdcc8e05d7238cc653de9f465ed69f91:fractalbitcoin" ], [ "r", - "https://cdn.satellite.earth/4da669a843213de2f1c4a28ae28e10edcbe646134a19c7eaf183c22a21889b7b.jpg" + "https://youtu.be/8OvAm_xPM1I" ], [ "subject", - "CyberHornet of Bitcoin" + "Today's Bitcoin News! (10 min. video) \"Bitcoin ETF Race is ON, $1.2 Million per Bitcoin?, #Bitcoin vs Gold, OpenSats onboards Tobin Harding #Nostr #BTC\"" ], [ "t", @@ -1079,6 +1073,22 @@ [ "t", "bitcoin" + ], + [ + "t", + "Nostr" + ], + [ + "t", + "nostr" + ], + [ + "t", + "BTC" + ], + [ + "t", + "btc" ] ] } @@ -1096,407 +1106,71 @@ { "events": [ { - "content": "i did the opposite. made my display name refer to my npub's last characters lol", - "created_at": 1780026498, - "id": "00005c760c62babe02476b1823cb8b313dc05fdb76216a80ab0bcbc3a869aa95", + "content": "Nostr Wiki\n\nEstive sumido do #Nostr por um tempo, mas durante este tempo tamb\u00e9m estivo o mais pr\u00f3ximo do protocolo que algu\u00e9m pode estar... at\u00e9 domingo publico que o projeto que estou finalizando para o Nostr, e sem sombra de d\u00favidas est\u00e1 espetacular, estou muito animado.\n\nBom dia meus caros!!", + "created_at": 1758286903, + "id": "ae298ef12b30dcf70f57655eb27f3e44dae390f75756aa63302e675a0d113c15", "kind": 1, - "pubkey": "2efaa715bbb46dd5be6b7da8d7700266d11674b913b8178addb5c2e63d987331", - "sig": "2d5021debe899883103afc527b79917f10ce7051dca9f3a99794417aaa5a2846f347afc52e1bad5d7a0ab1950bdf2fd74a01894a1c97d7eebd51a4f0441e6f0e", + "pubkey": "55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27", + "sig": "c76edad7378dc377c95378e2b73d38478ce1ed78605ae9f8945578da43b2bab45349f5d9a947aea8050c6b030d3438d18e7ab65e6e3c5464457399dd4de94c32", "tags": [ [ - "e", - "e8dd3873f90a5dfc6a1e7ce06bb404040ab2e415b32994d9b80028d523b08ba7", - "wss://relay.nostr.band", - "root" + "a", + "34550:55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27:ndevs-br" ], [ - "p", - "c3c7122c63af5281fee3b9338d10ae65b166ffe6f73f1c272c5c1120607731d7" + "subject", + "Nostr Wiki" ], [ - "client", - "Wisp" + "t", + "Nostr" ], [ - "nonce", - "47903", - "16" + "t", + "nostr" ] ] }, { - "content": "Added also the nostr:nprofile1qqs9wj8mumkqgslplpdkvdgla8xzw9cpfnun3txfdrnmyrysnxqzg5cpzdmhxue69uhhyetvv9ujuerp0yhxzee03paeqn header to the latest banners. #grownostr #nostr \n\nhttps://www.bitpopart.com/banners\nhttps://blossom.primal.net/5058d980419c8fc4554360ae1c4f6df63868be8458f32dd7e5a8548909c122f6.png\nnostr:nevent1qqs2pv36kdpnmx5whwc86z0mavenycc935ufaekrdzxq9a500rntnss24qc9d", - "created_at": 1779970864, - "id": "2e7e2865e5ccfc9a4b38296ae59d2aba97bc634ea457b1258506b3f6d981f296", + "content": "To add to what nostr:nprofile1qqs06gywary09qmcp2249ztwfq3ue8wxhl2yyp3c39thzp55plvj0sgprdmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdakj7qguwaehxw309a6xsetrd96xzer9dshxummnw3erztnrdakj7qgmwaehxw309amksetpwshxsctswpuhgctkv4exutnrduhsw0qlr4 said, Aedile is intended to be a fast, efficient, and stable set of code that other developers can use to bootstrap Nostr apps.\n\nThere are a few things every Nostr clients needs to do, like connect to relays, handle user login, sign events, publish them, and search. We aim to make Aedile the best at all of those so it can be a firm foundation for future Nostr apps.", + "created_at": 1735859178, + "id": "45da80e4cdd89c184f76b3f54452a98bc605adbabf48915a8a8b2eb9888f0546", "kind": 1, - "pubkey": "43baaf0c28e6cfb195b17ee083e19eb3a4afdfac54d9b6baf170270ed193e34c", - "sig": "e301be97f1a8d7e0ab27b25b1190cb012a71f5ad8c364adff5c0c0b492d4343a41ef4c26fb7973543aeceaf607ec6955e4d05967b79850f321eac347c50f7788", + "pubkey": "70122128273bdc07af9be7725fa5c4bc0fc146866bec38d44360dc4bc6cc18b9", + "sig": "f9a86c7dee312817f1d39e3b166e792585e6b736f1712ad00fa1da6eb4798a54131358b4483f5893130081bb7f15e4e7dfa06c5f15c800e246492da453cdc7f1", "tags": [ [ "e", - "a0b23ab3433d9a8ebbb07d09fbeb333263058d389ee6c3688c02f68f78e6b9c2", + "dff2cb2931ea98da9dac8ac5441c84294ba667dd106fa48e65321cfaad9f0c52", + "", + "root" + ], + [ + "e", + "ff776994c5d217ea538c6752e64cd71d1721c459b5c5a2bc86036861511cad8b" + ], + [ + "e", + "f785909a94e788b03d74801c96b319849124cda6929c77f5149466b74c38b5cb", + "", + "reply" + ], + [ + "p", + "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", "", "mention" ], [ "p", - "5748fbe6ec0443e1f85b66351fe9cc2717014cf938acc968e7b20c9099802453", - "wss://relay.day.ag/", - "mention" - ], - [ - "t", - "grownostr" - ], - [ - "t", - "nostr" - ], - [ - "imeta", - "url https://blossom.primal.net/5058d980419c8fc4554360ae1c4f6df63868be8458f32dd7e5a8548909c122f6.png", - "m png", - "dim 1758.0x1560.0" - ] - ] - }, - { - "content": "GM\nnostr:nevent1qqs2tn7tyvfap8xh8g2m05n4x36rqxfk6905gdxyf9y3dn6yxphld3spzamhxue69uhhyetvv9ujuurjd9kkzmpwdejhgtczyq3azthcw509acn8ld35r47yrw2rfgdenp57qgfwkdx4d2akkyhg5qcyqqqqqqggk8acz", - "created_at": 1778568024, - "id": "605afbb176da20c96970b3802a4675850374ed7c399a9da76918ee81f786c99d", - "kind": 1, - "pubkey": "bbb5dda0e15567979f0543407bdc2033d6f0bbb30f72512a981cfdb2f09e2747", - "sig": "de85b213cbf6835fedb30ed2652609a8adc77d0de22cb72e1829ef2ed278902272f7850c8591271d662f6f809075f7f3c4cc58c03ac9f24225f9eab9bc6f6c74", - "tags": [ - [ - "alt", - "A short note: GM\nnostr:nevent1qqs2tn7tyvfap8xh8g2m05n4x36rqxfk69..." + "5ffb8e1b6b629c0e34a013f9298ebb0759b98a3d24029916321d5eb4255b6735" ], [ "p", - "23d12ef8751e5ee267fb6341d7c41b9434a1b99869e0212eb34d56abb6b12e8a", - "wss://relay.damus.io/" - ], - [ - "zap", - "23d12ef8751e5ee267fb6341d7c41b9434a1b99869e0212eb34d56abb6b12e8a", - "wss://relay.damus.io/", - "0.9" - ], - [ - "zap", - "bbb5dda0e15567979f0543407bdc2033d6f0bbb30f72512a981cfdb2f09e2747", - "wss://nos.lol/", - "0.1" - ], - [ - "client", - "Amethyst" + "75d12141a391994c4062501f155c2e34e41a8c3cf9bef9e4d7d298253458d1e2" ] ] }, - { - "content": "I just hosted a website over bluetooth...\n\nUsing nostr:nprofile1qy88wumn8ghj7mn0wvhxcmmv9uq3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7qpqy0gja7r4re0wyelmvdqa03qmjs62rwvcd8szzt4nf4t2hd43969qw7vhfa at #soveng\n\nCC: nostr:nprofile1qyt8wumn8ghj7etyv4hzumn0wd68ytnvv9hxgtcpzemhxue69uhks6tnwshxummnw3ezumrpdejz7qpq2rv5lskctqxxs2c8rf2zlzc7xx3qpvzs3w4etgemauy9thegr43sugh36r wen bitchat over fips...?\n https://blossom.primal.net/45d7ca76a456c8dbe32b13223340f91c65c391d08dde40ed930127acf5aa0e2d.png \n\n", - "created_at": 1775842974, - "id": "53f61389e137c717f84fc7fc8e98ed5c2d9b2ef89a9a74310cdb09d902974baa", - "kind": 1, - "pubkey": "bbb5dda0e15567979f0543407bdc2033d6f0bbb30f72512a981cfdb2f09e2747", - "sig": "b501f64195814b04d132bfbbf48f3a963b0a3aa747e6f2940a8ad36671eb5b9e58221769976245f383e73d96a1c8ccb4a43510268d2651c3b22bf53213f4de50", - "tags": [ - [ - "t", - "soveng" - ], - [ - "p", - "23d12ef8751e5ee267fb6341d7c41b9434a1b99869e0212eb34d56abb6b12e8a", - "wss://nos.lol/", - "mention" - ], - [ - "p", - "50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63", - "wss://eden.nostr.land/", - "mention" - ], - [ - "r", - "wss://nos.lol/" - ], - [ - "r", - "wss://relay.primal.net/" - ], - [ - "r", - "wss://relay.damus.io/", - "write" - ], - [ - "r", - "wss://nostr.wine/", - "write" - ], - [ - "r", - "wss://npub1spxdug4m3y24hpx5crm0el4zhkk0wafs8kp6m0xu0wecygqej2xqq8gyhx.fips.network/" - ], - [ - "imeta", - "url https://blossom.primal.net/45d7ca76a456c8dbe32b13223340f91c65c391d08dde40ed930127acf5aa0e2d.png", - "m image/png", - "dim 1634x1098", - "service nip96" - ], - [ - "client", - "Primal Web" - ] - ] - }, - { - "content": "Nostr Wiki\n\nEstive sumido do #Nostr por um tempo, mas durante este tempo tamb\u00e9m estivo o mais pr\u00f3ximo do protocolo que algu\u00e9m pode estar... at\u00e9 domingo publico que o projeto que estou finalizando para o Nostr, e sem sombra de d\u00favidas est\u00e1 espetacular, estou muito animado.\n\nBom dia meus caros!!", - "created_at": 1758286903, - "id": "ae298ef12b30dcf70f57655eb27f3e44dae390f75756aa63302e675a0d113c15", - "kind": 1, - "pubkey": "55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27", - "sig": "c76edad7378dc377c95378e2b73d38478ce1ed78605ae9f8945578da43b2bab45349f5d9a947aea8050c6b030d3438d18e7ab65e6e3c5464457399dd4de94c32", - "tags": [ - [ - "a", - "34550:55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27:ndevs-br" - ], - [ - "subject", - "Nostr Wiki" - ], - [ - "t", - "Nostr" - ], - [ - "t", - "nostr" - ] - ] - }, - { - "content": "Acabei de finalizar a publica\u00e7\u00e3o da api para a search engine de buscas no #nostr. Voc\u00ea pode buscar relays, usu\u00e1rios, pegar um usu\u00e1rio espec\u00edfico, pesqusiar no ecopo de amigos de um usu\u00e1rio(follows) e por ai vai, teste os endpoints.\n\nSe voc\u00ea tem um cliente nostr ou est\u00e1 desenvolvendo um, teste e user como desejar, est\u00e1 100% aberta: \n\nhttps://find.nosbook.org/\n\nLembrando que pubkey sempre \u00e9 tratada no formato hexadecimal, que \u00e9 como os clientes de fato utilizam por baixo dos panos, ent\u00e3o onde recebe o par\u00e2metro pubkey, \u00e9 o hexadecimal da chave p\u00fablica, acessando o link voc\u00ea pode testar direto na p\u00e1gina web da documenta\u00e7\u00e3o! \n\nDivirta-se!!!", - "created_at": 1728191395, - "id": "7cd323a4f0e21d215b6448e2d0700219fdb1466e2239d87618daf381885d4d93", - "kind": 1, - "pubkey": "55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27", - "sig": "0a909b420af53e20c700384cc62294199006ca30e5a3e556d5f6359e2e789aa5ac3a1e2c6b6f0649f4e9164161ee5b9cd54b5187557077ad097de51f0384577a", - "tags": [ - [ - "t", - "nostr" - ], - [ - "a", - "34550:55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27:ndevs-br" - ], - [ - "subject", - "API Search Engine Nostr" - ], - [ - "client", - "noStrudel", - "31990:266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5:1686066542546" - ] - ] - }, - { - "content": "Still want to see a good pinterest, instagram and tiktok competitor on nostr. There's so much value in visual content and we haven't even bothered scratching that surface. \n\nImagine the interoperability of a curation platform with a shopping platform, both contributing to the creator's sovereign identity vs fighting against one another. \n\nMassive potential. ", - "created_at": 1724727720, - "id": "a42cc0bb299bbce89e5ea77b797a2126a2f149f8a22ab665d14dc9187c1e3cfc", - "kind": 1, - "pubkey": "1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411", - "sig": "051be0cfd325158e5fbae60d7a1f4deadc409677f507166774130fea51acd952e115a8a889b2b7ed13b9ef9d6587428b0b8fc76b6aa09db3085f133e1e84acc1", - "tags": [] - }, - { - "content": "MORE CLIENTS SHOULD SUPPORT DISPOSABLE NPUBS. AMETHYST IS LEADING THE WAY HERE. WOULD BE THE SINGLE BIGGEST PRIVACY WIN FOR ONLINE SPEECH ACROSS THE BOARD.\n\nNORMALIZE SINGLE USE DISPOSABLE IDENTITIES. AS THE REST OF THE INTERNET MOVES TOWARD LINKING EVERY ACTION TO LEGAL IDENTITIES, NOSTR REPRESENTS A BRIGHTER PATH. ", - "created_at": 1723216387, - "id": "529a990178f65ec0669132589d6cf1f586db6c4e133ea94d664a6a719f215e68", - "kind": 1, - "pubkey": "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", - "sig": "a0cd66e5873e57ae11889d097a64e1b3e24bc8af7858bffd48b26e5a128d4707ae8c2247358c0af4daaa42a655851b82198855d2fdef6ce1cd1b8e72e85d9124", - "tags": [] - } - ], - "flags": [ - "--kind", - "1", - "--since", - "1700000000" - ], - "name": "window_since", - "search": "nostr include:spam", - "terms": "nostr" - }, - { - "events": [ - { - "content": "i did the opposite. made my display name refer to my npub's last characters lol", - "created_at": 1780026498, - "id": "00005c760c62babe02476b1823cb8b313dc05fdb76216a80ab0bcbc3a869aa95", - "kind": 1, - "pubkey": "2efaa715bbb46dd5be6b7da8d7700266d11674b913b8178addb5c2e63d987331", - "sig": "2d5021debe899883103afc527b79917f10ce7051dca9f3a99794417aaa5a2846f347afc52e1bad5d7a0ab1950bdf2fd74a01894a1c97d7eebd51a4f0441e6f0e", - "tags": [ - [ - "e", - "e8dd3873f90a5dfc6a1e7ce06bb404040ab2e415b32994d9b80028d523b08ba7", - "wss://relay.nostr.band", - "root" - ], - [ - "p", - "c3c7122c63af5281fee3b9338d10ae65b166ffe6f73f1c272c5c1120607731d7" - ], - [ - "client", - "Wisp" - ], - [ - "nonce", - "47903", - "16" - ] - ] - }, - { - "content": "I just hosted a website over bluetooth...\n\nUsing nostr:nprofile1qy88wumn8ghj7mn0wvhxcmmv9uq3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7qpqy0gja7r4re0wyelmvdqa03qmjs62rwvcd8szzt4nf4t2hd43969qw7vhfa at #soveng\n\nCC: nostr:nprofile1qyt8wumn8ghj7etyv4hzumn0wd68ytnvv9hxgtcpzemhxue69uhks6tnwshxummnw3ezumrpdejz7qpq2rv5lskctqxxs2c8rf2zlzc7xx3qpvzs3w4etgemauy9thegr43sugh36r wen bitchat over fips...?\n https://blossom.primal.net/45d7ca76a456c8dbe32b13223340f91c65c391d08dde40ed930127acf5aa0e2d.png \n\n", - "created_at": 1775842974, - "id": "53f61389e137c717f84fc7fc8e98ed5c2d9b2ef89a9a74310cdb09d902974baa", - "kind": 1, - "pubkey": "bbb5dda0e15567979f0543407bdc2033d6f0bbb30f72512a981cfdb2f09e2747", - "sig": "b501f64195814b04d132bfbbf48f3a963b0a3aa747e6f2940a8ad36671eb5b9e58221769976245f383e73d96a1c8ccb4a43510268d2651c3b22bf53213f4de50", - "tags": [ - [ - "t", - "soveng" - ], - [ - "p", - "23d12ef8751e5ee267fb6341d7c41b9434a1b99869e0212eb34d56abb6b12e8a", - "wss://nos.lol/", - "mention" - ], - [ - "p", - "50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63", - "wss://eden.nostr.land/", - "mention" - ], - [ - "r", - "wss://nos.lol/" - ], - [ - "r", - "wss://relay.primal.net/" - ], - [ - "r", - "wss://relay.damus.io/", - "write" - ], - [ - "r", - "wss://nostr.wine/", - "write" - ], - [ - "r", - "wss://npub1spxdug4m3y24hpx5crm0el4zhkk0wafs8kp6m0xu0wecygqej2xqq8gyhx.fips.network/" - ], - [ - "imeta", - "url https://blossom.primal.net/45d7ca76a456c8dbe32b13223340f91c65c391d08dde40ed930127acf5aa0e2d.png", - "m image/png", - "dim 1634x1098", - "service nip96" - ], - [ - "client", - "Primal Web" - ] - ] - }, - { - "content": "Nostr Wiki\n\nEstive sumido do #Nostr por um tempo, mas durante este tempo tamb\u00e9m estivo o mais pr\u00f3ximo do protocolo que algu\u00e9m pode estar... at\u00e9 domingo publico que o projeto que estou finalizando para o Nostr, e sem sombra de d\u00favidas est\u00e1 espetacular, estou muito animado.\n\nBom dia meus caros!!", - "created_at": 1758286903, - "id": "ae298ef12b30dcf70f57655eb27f3e44dae390f75756aa63302e675a0d113c15", - "kind": 1, - "pubkey": "55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27", - "sig": "c76edad7378dc377c95378e2b73d38478ce1ed78605ae9f8945578da43b2bab45349f5d9a947aea8050c6b030d3438d18e7ab65e6e3c5464457399dd4de94c32", - "tags": [ - [ - "a", - "34550:55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27:ndevs-br" - ], - [ - "subject", - "Nostr Wiki" - ], - [ - "t", - "Nostr" - ], - [ - "t", - "nostr" - ] - ] - }, - { - "content": "Acabei de finalizar a publica\u00e7\u00e3o da api para a search engine de buscas no #nostr. Voc\u00ea pode buscar relays, usu\u00e1rios, pegar um usu\u00e1rio espec\u00edfico, pesqusiar no ecopo de amigos de um usu\u00e1rio(follows) e por ai vai, teste os endpoints.\n\nSe voc\u00ea tem um cliente nostr ou est\u00e1 desenvolvendo um, teste e user como desejar, est\u00e1 100% aberta: \n\nhttps://find.nosbook.org/\n\nLembrando que pubkey sempre \u00e9 tratada no formato hexadecimal, que \u00e9 como os clientes de fato utilizam por baixo dos panos, ent\u00e3o onde recebe o par\u00e2metro pubkey, \u00e9 o hexadecimal da chave p\u00fablica, acessando o link voc\u00ea pode testar direto na p\u00e1gina web da documenta\u00e7\u00e3o! \n\nDivirta-se!!!", - "created_at": 1728191395, - "id": "7cd323a4f0e21d215b6448e2d0700219fdb1466e2239d87618daf381885d4d93", - "kind": 1, - "pubkey": "55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27", - "sig": "0a909b420af53e20c700384cc62294199006ca30e5a3e556d5f6359e2e789aa5ac3a1e2c6b6f0649f4e9164161ee5b9cd54b5187557077ad097de51f0384577a", - "tags": [ - [ - "t", - "nostr" - ], - [ - "a", - "34550:55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27:ndevs-br" - ], - [ - "subject", - "API Search Engine Nostr" - ], - [ - "client", - "noStrudel", - "31990:266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5:1686066542546" - ] - ] - }, - { - "content": "Still want to see a good pinterest, instagram and tiktok competitor on nostr. There's so much value in visual content and we haven't even bothered scratching that surface. \n\nImagine the interoperability of a curation platform with a shopping platform, both contributing to the creator's sovereign identity vs fighting against one another. \n\nMassive potential. ", - "created_at": 1724727720, - "id": "a42cc0bb299bbce89e5ea77b797a2126a2f149f8a22ab665d14dc9187c1e3cfc", - "kind": 1, - "pubkey": "1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411", - "sig": "051be0cfd325158e5fbae60d7a1f4deadc409677f507166774130fea51acd952e115a8a889b2b7ed13b9ef9d6587428b0b8fc76b6aa09db3085f133e1e84acc1", - "tags": [] - }, - { - "content": "MORE CLIENTS SHOULD SUPPORT DISPOSABLE NPUBS. AMETHYST IS LEADING THE WAY HERE. WOULD BE THE SINGLE BIGGEST PRIVACY WIN FOR ONLINE SPEECH ACROSS THE BOARD.\n\nNORMALIZE SINGLE USE DISPOSABLE IDENTITIES. AS THE REST OF THE INTERNET MOVES TOWARD LINKING EVERY ACTION TO LEGAL IDENTITIES, NOSTR REPRESENTS A BRIGHTER PATH. ", - "created_at": 1723216387, - "id": "529a990178f65ec0669132589d6cf1f586db6c4e133ea94d664a6a719f215e68", - "kind": 1, - "pubkey": "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", - "sig": "a0cd66e5873e57ae11889d097a64e1b3e24bc8af7858bffd48b26e5a128d4707ae8c2247358c0af4daaa42a655851b82198855d2fdef6ce1cd1b8e72e85d9124", - "tags": [] - }, { "content": "May 6, 2023 I started on Nostr. I kinda figured it might just be something and took a moment to blog it with a timestamp: https://hive.blog/bitcoin/@crrdlx/running-nostr\n\nSo, a couple of ways to look at today: \n1. Today is my first Nostr birthday or \"hatchday\" - I knew nothing of Nostr then, but have learned a ton since.\n2. Today starts year number two - Still learning every day and looking forward to what might come next.\n\n https://image.nostr.build/38697a68d75aebf0429085e3b771934e7250b4f976ac5112a22bb592375458d8.gif\n#nostr #plebchain", "created_at": 1714996203, @@ -1552,6 +1226,397 @@ "nostr" ] ] + }, + { + "content": "The Nostr Wishlist is growing. What else?\nhttp://nostrwiki.crrdlx.infinityfreeapp.com/doku.php?id=wiki:nostr-wishlist\n https://image.nostr.build/db84fca117575ae9b4062450cd69b05c1a80348de61e317d2256f000d2998305.png\n#nostr #grownostr", + "created_at": 1714651719, + "id": "f969e3f189cc5c6b1b64fbdbbb2cb65a07137d147601caf7bfa915961ae13e40", + "kind": 1, + "pubkey": "005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2", + "sig": "db81b8fa7d8a10d8e0ff7cec82ebbc31a0e9523ee7afa4dccc466359c35a5ced748f8fcac529105db0829540536cbdc09e2296a5f588d3559e52f29a15b12f72", + "tags": [ + [ + "t", + "nostr" + ], + [ + "t", + "grownostr" + ], + [ + "a", + "34550:005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2:General" + ], + [ + "subject", + "Nostr Wishlist" + ] + ] + }, + { + "content": "Putting this out again...don't know why, but somehow I think it's important. Who can figure this out?\n----\nI don't know if it's possible, but it certainly _feels_ possible. Can there be NFTs on Nostr?\n\nhttps://files.peakd.com/file/peakd-hive/crrdlx/23wXQUJXU4KEFX3rg8VusMYEhYF8NYKcY991K1zzqDyq6oTgBDPrs83m48DNn9rzp57c3.png\n\nI've written and pondered on NFTs a few times and come up with a definition that I think works in laying out exactly what an NFT actually is. I defined NFTs as Verified Unique Ownership (VUO for short). (See NFTs defined in three words | PeakD and ponderings at PeakD)\n\nThe upshot of VUO is this:\n\nV = verified = The NFT (which is nothing more than metadata such as a custom JSON) is openly visible to all to verify and cannot be altered.\nU = unique = The NFT/metadata is unique. Although a monkey jpeg may be the same for all 1,000 mints, the owner of mint #1 will definitely not trade with the owner of mint #859.\nO = ownership = The NFT can be transferred to another person and that transfer can also be verified.\n\nWith regards to Nostr, the V, the verified, seems to be the challenge. It seems that the hang-up would be persistence (or lack of persistence) in data. That is, having the metadata persistently stored on Nostr may be a high hurdle. It may be impossible. For instance, if I minted an NFT and the metadata got stored on several Nostr relays, it seems that would work. The data can be verified by others. It's persistently verifiable until...those relays go down or close up shop or purge that metadata note. Then, that NFT is gone. This, of course, differs from a blockchain.\n\nRegarding the O of ownership, I'm really not sure how that would transpire on Nostr. But, I think clever people could figure that out. If the persistent V of verifiability were solved, then a persistent alteration should also be possible to track on the \"Nostr blockchain\", the \"nostrchain.\"\n\nIf this were possible...what would be the use cases? I really don't know. I do think they would emerge though. Being around Nostr suggests to me that the fun-factor would be off-the-charts though. There are some clever and comical people in this space. Fun, is an important use-case in my view, by the way!\n\nWen NFTs on #Nostr?\n#plebchain", + "created_at": 1714425742, + "id": "dab6aebcf29ed7c6dbcfd539e53d6eb7ce6b93a83542ebfb629804f1f834f136", + "kind": 1, + "pubkey": "005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2", + "sig": "c0727a79b33c64111e5525ae72843077154c0d20d0057b58ee0feeef7c9b55b7c8a09591eee11866c3ced4ce0334ae621c56567b59d7a891a53756c2f8d0b5a5", + "tags": [ + [ + "t", + "1" + ], + [ + "t", + "859" + ], + [ + "t", + "nostr" + ], + [ + "t", + "plebchain" + ], + [ + "a", + "34550:005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2:General" + ], + [ + "subject", + "NFTs on Nostr" + ] + ] + }, + { + "content": "First Meme of NOSTR\n\nhttps://image.nostr.build/f7366c61d378ec12040f52f4382f48a2aa3d1dd2bf59c1b803c20b1a8d3eddbf.jpg\n\n#btc #plebchain #cryptoterrestrial #nostr #grownostr", + "created_at": 1712797491, + "id": "a8b24e884774cda42c0f01d0a3b52012aa00bd3d58479f4c6983bcca76b71f93", + "kind": 1, + "pubkey": "2cd0d8fd7003efdb16aa1a82ce25881b20de1f99b99a688cf03db34c85567da7", + "sig": "73ad64f13bd9aca844a0b93e37cfa477cf39042d1c75e345587a9c6b422c7ed65f514c1ebc8c13a002f48a2c815236d0cfc8c76a26878fae720f266ea633103e", + "tags": [ + [ + "a", + "34550:2cd0d8fd7003efdb16aa1a82ce25881b20de1f99b99a688cf03db34c85567da7:digitalarts" + ], + [ + "r", + "https://image.nostr.build/f7366c61d378ec12040f52f4382f48a2aa3d1dd2bf59c1b803c20b1a8d3eddbf.jpg" + ], + [ + "subject", + "First Meme of NOSTR" + ], + [ + "t", + "btc" + ], + [ + "t", + "plebchain" + ], + [ + "t", + "cryptoterrestrial" + ], + [ + "t", + "nostr" + ], + [ + "t", + "grownostr" + ] + ] + }, + { + "content": "Discovering #nostr feels amazing. From the surface it seems easy but with what you can build upon and using this protocol..well seems a bit overwhelming. Understanding how it works and learning about it is fascinating to say the least.\nDecentralized access to information that is censorship resistant..feed curation, lists of people you want to interact with. This is next level and with integration of #bitcoin Lightning payments..future is bright. Glad to be here in such an early stage. \n\nthanks nostr:npub1f6ugxyxkknket3kkdgu4k0fu74vmshawermkj8d06sz6jts9t4kslazcka for guidance and patience.\ud83e\udd0c", + "created_at": 1711409590, + "id": "9442ca1e321f583c3ed1eace48de854c1b55828257d2e2e1a08ffea95dd5159a", + "kind": 1, + "pubkey": "18f54af1e10c5bb7a35468b0f62b295d12347903c9f95738d065c84bef1402ef", + "sig": "21bdd10997cf217004e1532a77e6471e207246fc18d192d36253627c6935cdd618404a89714144f4edd2f52a1ef752b9d935033a481c72bda46deb33f0338a0d", + "tags": [ + [ + "subject", + "Discovering #NOSTR" + ], + [ + "p", + "4eb88310d6b4ed95c6d66a395b3d3cf559b85faec8f7691dafd405a92e055d6d" + ], + [ + "t", + "nostr" + ], + [ + "t", + "bitcoin" + ] + ] + } + ], + "flags": [ + "--kind", + "1", + "--since", + "1700000000" + ], + "name": "window_since", + "search": "nostr include:spam", + "terms": "nostr" + }, + { + "events": [ + { + "content": "Nostr Wiki\n\nEstive sumido do #Nostr por um tempo, mas durante este tempo tamb\u00e9m estivo o mais pr\u00f3ximo do protocolo que algu\u00e9m pode estar... at\u00e9 domingo publico que o projeto que estou finalizando para o Nostr, e sem sombra de d\u00favidas est\u00e1 espetacular, estou muito animado.\n\nBom dia meus caros!!", + "created_at": 1758286903, + "id": "ae298ef12b30dcf70f57655eb27f3e44dae390f75756aa63302e675a0d113c15", + "kind": 1, + "pubkey": "55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27", + "sig": "c76edad7378dc377c95378e2b73d38478ce1ed78605ae9f8945578da43b2bab45349f5d9a947aea8050c6b030d3438d18e7ab65e6e3c5464457399dd4de94c32", + "tags": [ + [ + "a", + "34550:55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27:ndevs-br" + ], + [ + "subject", + "Nostr Wiki" + ], + [ + "t", + "Nostr" + ], + [ + "t", + "nostr" + ] + ] + }, + { + "content": "To add to what nostr:nprofile1qqs06gywary09qmcp2249ztwfq3ue8wxhl2yyp3c39thzp55plvj0sgprdmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdakj7qguwaehxw309a6xsetrd96xzer9dshxummnw3erztnrdakj7qgmwaehxw309amksetpwshxsctswpuhgctkv4exutnrduhsw0qlr4 said, Aedile is intended to be a fast, efficient, and stable set of code that other developers can use to bootstrap Nostr apps.\n\nThere are a few things every Nostr clients needs to do, like connect to relays, handle user login, sign events, publish them, and search. We aim to make Aedile the best at all of those so it can be a firm foundation for future Nostr apps.", + "created_at": 1735859178, + "id": "45da80e4cdd89c184f76b3f54452a98bc605adbabf48915a8a8b2eb9888f0546", + "kind": 1, + "pubkey": "70122128273bdc07af9be7725fa5c4bc0fc146866bec38d44360dc4bc6cc18b9", + "sig": "f9a86c7dee312817f1d39e3b166e792585e6b736f1712ad00fa1da6eb4798a54131358b4483f5893130081bb7f15e4e7dfa06c5f15c800e246492da453cdc7f1", + "tags": [ + [ + "e", + "dff2cb2931ea98da9dac8ac5441c84294ba667dd106fa48e65321cfaad9f0c52", + "", + "root" + ], + [ + "e", + "ff776994c5d217ea538c6752e64cd71d1721c459b5c5a2bc86036861511cad8b" + ], + [ + "e", + "f785909a94e788b03d74801c96b319849124cda6929c77f5149466b74c38b5cb", + "", + "reply" + ], + [ + "p", + "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", + "", + "mention" + ], + [ + "p", + "5ffb8e1b6b629c0e34a013f9298ebb0759b98a3d24029916321d5eb4255b6735" + ], + [ + "p", + "75d12141a391994c4062501f155c2e34e41a8c3cf9bef9e4d7d298253458d1e2" + ] + ] + }, + { + "content": "May 6, 2023 I started on Nostr. I kinda figured it might just be something and took a moment to blog it with a timestamp: https://hive.blog/bitcoin/@crrdlx/running-nostr\n\nSo, a couple of ways to look at today: \n1. Today is my first Nostr birthday or \"hatchday\" - I knew nothing of Nostr then, but have learned a ton since.\n2. Today starts year number two - Still learning every day and looking forward to what might come next.\n\n https://image.nostr.build/38697a68d75aebf0429085e3b771934e7250b4f976ac5112a22bb592375458d8.gif\n#nostr #plebchain", + "created_at": 1714996203, + "id": "d31162a39b411614b9093e060d7cb4f4366cd38b2c9ca1727cdc02151446c814", + "kind": 1, + "pubkey": "005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2", + "sig": "893d797f5d52f1b92f4a4413c130cd36d976ee5c6a4eed1f90853fc4367f0590053070c1bfed703ac387f7c8c758ef40d643aa89b8fa0ce656d51da1194fc8d9", + "tags": [ + [ + "t", + "nostr" + ], + [ + "t", + "plebchain" + ], + [ + "a", + "34550:005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2:General" + ], + [ + "subject", + "A year on Nostr" + ] + ] + }, + { + "content": "#Nostr Fan Art\n\nhttps://blogclif.wordpress.com/2024/05/02/nostr-as-a-better-place-to-grow/\n\nNostr is a better place to grow", + "created_at": 1714721223, + "id": "13ead8b7c5f4aca351783cb85ec4d65670d8455d9c576ee8ca57b3cf9d7a7a01", + "kind": 1, + "pubkey": "f8f6b6f741bd422346579304550de64a6445fd332c50389e9a1f4d8294a101e0", + "sig": "a8b93de6c931c97265b3cb452a6bc2463e576bed4310a4fbf19185e4845732941d654c187a48d497d014f981092c3e80a9a4ea2ce0069649fde1be33bcbee727", + "tags": [ + [ + "a", + "34550:f8f6b6f741bd422346579304550de64a6445fd332c50389e9a1f4d8294a101e0:fanart" + ], + [ + "r", + "https://blogclif.wordpress.com/2024/05/02/nostr-as-a-better-place-to-grow/" + ], + [ + "subject", + "#Nostr Fan Art" + ], + [ + "t", + "Nostr" + ], + [ + "t", + "nostr" + ] + ] + }, + { + "content": "The Nostr Wishlist is growing. What else?\nhttp://nostrwiki.crrdlx.infinityfreeapp.com/doku.php?id=wiki:nostr-wishlist\n https://image.nostr.build/db84fca117575ae9b4062450cd69b05c1a80348de61e317d2256f000d2998305.png\n#nostr #grownostr", + "created_at": 1714651719, + "id": "f969e3f189cc5c6b1b64fbdbbb2cb65a07137d147601caf7bfa915961ae13e40", + "kind": 1, + "pubkey": "005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2", + "sig": "db81b8fa7d8a10d8e0ff7cec82ebbc31a0e9523ee7afa4dccc466359c35a5ced748f8fcac529105db0829540536cbdc09e2296a5f588d3559e52f29a15b12f72", + "tags": [ + [ + "t", + "nostr" + ], + [ + "t", + "grownostr" + ], + [ + "a", + "34550:005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2:General" + ], + [ + "subject", + "Nostr Wishlist" + ] + ] + }, + { + "content": "Putting this out again...don't know why, but somehow I think it's important. Who can figure this out?\n----\nI don't know if it's possible, but it certainly _feels_ possible. Can there be NFTs on Nostr?\n\nhttps://files.peakd.com/file/peakd-hive/crrdlx/23wXQUJXU4KEFX3rg8VusMYEhYF8NYKcY991K1zzqDyq6oTgBDPrs83m48DNn9rzp57c3.png\n\nI've written and pondered on NFTs a few times and come up with a definition that I think works in laying out exactly what an NFT actually is. I defined NFTs as Verified Unique Ownership (VUO for short). (See NFTs defined in three words | PeakD and ponderings at PeakD)\n\nThe upshot of VUO is this:\n\nV = verified = The NFT (which is nothing more than metadata such as a custom JSON) is openly visible to all to verify and cannot be altered.\nU = unique = The NFT/metadata is unique. Although a monkey jpeg may be the same for all 1,000 mints, the owner of mint #1 will definitely not trade with the owner of mint #859.\nO = ownership = The NFT can be transferred to another person and that transfer can also be verified.\n\nWith regards to Nostr, the V, the verified, seems to be the challenge. It seems that the hang-up would be persistence (or lack of persistence) in data. That is, having the metadata persistently stored on Nostr may be a high hurdle. It may be impossible. For instance, if I minted an NFT and the metadata got stored on several Nostr relays, it seems that would work. The data can be verified by others. It's persistently verifiable until...those relays go down or close up shop or purge that metadata note. Then, that NFT is gone. This, of course, differs from a blockchain.\n\nRegarding the O of ownership, I'm really not sure how that would transpire on Nostr. But, I think clever people could figure that out. If the persistent V of verifiability were solved, then a persistent alteration should also be possible to track on the \"Nostr blockchain\", the \"nostrchain.\"\n\nIf this were possible...what would be the use cases? I really don't know. I do think they would emerge though. Being around Nostr suggests to me that the fun-factor would be off-the-charts though. There are some clever and comical people in this space. Fun, is an important use-case in my view, by the way!\n\nWen NFTs on #Nostr?\n#plebchain", + "created_at": 1714425742, + "id": "dab6aebcf29ed7c6dbcfd539e53d6eb7ce6b93a83542ebfb629804f1f834f136", + "kind": 1, + "pubkey": "005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2", + "sig": "c0727a79b33c64111e5525ae72843077154c0d20d0057b58ee0feeef7c9b55b7c8a09591eee11866c3ced4ce0334ae621c56567b59d7a891a53756c2f8d0b5a5", + "tags": [ + [ + "t", + "1" + ], + [ + "t", + "859" + ], + [ + "t", + "nostr" + ], + [ + "t", + "plebchain" + ], + [ + "a", + "34550:005bc4de41cfcb580f71cad6ae8909a976568633a4f6a93c6b7fd5bfef11e1a2:General" + ], + [ + "subject", + "NFTs on Nostr" + ] + ] + }, + { + "content": "First Meme of NOSTR\n\nhttps://image.nostr.build/f7366c61d378ec12040f52f4382f48a2aa3d1dd2bf59c1b803c20b1a8d3eddbf.jpg\n\n#btc #plebchain #cryptoterrestrial #nostr #grownostr", + "created_at": 1712797491, + "id": "a8b24e884774cda42c0f01d0a3b52012aa00bd3d58479f4c6983bcca76b71f93", + "kind": 1, + "pubkey": "2cd0d8fd7003efdb16aa1a82ce25881b20de1f99b99a688cf03db34c85567da7", + "sig": "73ad64f13bd9aca844a0b93e37cfa477cf39042d1c75e345587a9c6b422c7ed65f514c1ebc8c13a002f48a2c815236d0cfc8c76a26878fae720f266ea633103e", + "tags": [ + [ + "a", + "34550:2cd0d8fd7003efdb16aa1a82ce25881b20de1f99b99a688cf03db34c85567da7:digitalarts" + ], + [ + "r", + "https://image.nostr.build/f7366c61d378ec12040f52f4382f48a2aa3d1dd2bf59c1b803c20b1a8d3eddbf.jpg" + ], + [ + "subject", + "First Meme of NOSTR" + ], + [ + "t", + "btc" + ], + [ + "t", + "plebchain" + ], + [ + "t", + "cryptoterrestrial" + ], + [ + "t", + "nostr" + ], + [ + "t", + "grownostr" + ] + ] + }, + { + "content": "Discovering #nostr feels amazing. From the surface it seems easy but with what you can build upon and using this protocol..well seems a bit overwhelming. Understanding how it works and learning about it is fascinating to say the least.\nDecentralized access to information that is censorship resistant..feed curation, lists of people you want to interact with. This is next level and with integration of #bitcoin Lightning payments..future is bright. Glad to be here in such an early stage. \n\nthanks nostr:npub1f6ugxyxkknket3kkdgu4k0fu74vmshawermkj8d06sz6jts9t4kslazcka for guidance and patience.\ud83e\udd0c", + "created_at": 1711409590, + "id": "9442ca1e321f583c3ed1eace48de854c1b55828257d2e2e1a08ffea95dd5159a", + "kind": 1, + "pubkey": "18f54af1e10c5bb7a35468b0f62b295d12347903c9f95738d065c84bef1402ef", + "sig": "21bdd10997cf217004e1532a77e6471e207246fc18d192d36253627c6935cdd618404a89714144f4edd2f52a1ef752b9d935033a481c72bda46deb33f0338a0d", + "tags": [ + [ + "subject", + "Discovering #NOSTR" + ], + [ + "p", + "4eb88310d6b4ed95c6d66a395b3d3cf559b85faec8f7691dafd405a92e055d6d" + ], + [ + "t", + "nostr" + ], + [ + "t", + "bitcoin" + ] + ] } ], "flags": [ @@ -1567,61 +1632,101 @@ { "events": [ { - "content": "I just hosted a website over bluetooth...\n\nUsing nostr:nprofile1qy88wumn8ghj7mn0wvhxcmmv9uq3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7qpqy0gja7r4re0wyelmvdqa03qmjs62rwvcd8szzt4nf4t2hd43969qw7vhfa at #soveng\n\nCC: nostr:nprofile1qyt8wumn8ghj7etyv4hzumn0wd68ytnvv9hxgtcpzemhxue69uhks6tnwshxummnw3ezumrpdejz7qpq2rv5lskctqxxs2c8rf2zlzc7xx3qpvzs3w4etgemauy9thegr43sugh36r wen bitchat over fips...?\n https://blossom.primal.net/45d7ca76a456c8dbe32b13223340f91c65c391d08dde40ed930127acf5aa0e2d.png \n\n", - "created_at": 1775842974, - "id": "53f61389e137c717f84fc7fc8e98ed5c2d9b2ef89a9a74310cdb09d902974baa", - "kind": 1, - "pubkey": "bbb5dda0e15567979f0543407bdc2033d6f0bbb30f72512a981cfdb2f09e2747", - "sig": "b501f64195814b04d132bfbbf48f3a963b0a3aa747e6f2940a8ad36671eb5b9e58221769976245f383e73d96a1c8ccb4a43510268d2651c3b22bf53213f4de50", + "content": "## the protocol\n\n[nostr](https://github.com/nostr-protocol/nostr) is a protocol for establishing pseudonymous identities and using them to cryptographically sign messages and pass them around via relays. it grew out of a small community of bitcoin/lightning devs, specifically [fiatjaf](nostr:npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6). it was a reaction to the counter-party risks in using centralized social networks (twitter, facebook, etc) and the failings of other, semi-decentralized approaches (mastodon).\n\nbitcoin familiarity helps when groking nostr. the identity portion of nostr is private/public key pair. similar to a bitcoin wallet where one uses one\u2019s private key to sign a transaction and publish it into the network, one uses a nostr private key to sign an event and publish it to relays. there are various defined [kinds](https://github.com/nostr-protocol/nips#event-kinds) of events, from profile information (name, about, url, avatar url, etc), to contact lists (follows), to notes (a short plain text message). the bitcoin network is peer to peer (distributed), where nodes will gossip transactions around to all the other nodes, but this is not the case with nostr. events stay where you publish them, relays only talk to clients, not other relays. this makes nostr decentralized, but not distributed. since it\u2019s not peer-to-peer, one client doesn\u2019t need to see the entire traffic of the network, or to query some distributed hash table a la IPFS, to find content across the network. this middle ground between centralized and distributed allows snappy response times yet prevents the domination of network lock-in.", + "created_at": 1782172014, + "id": "4c3ffd171a535f9bac1afc47526b565833c726f82e59b3bf287db3e39cafc48e", + "kind": 30023, + "pubkey": "27c66d241898300a254f0853dfbfcf735a627dc2790c45a03de04704dd54a3b4", + "sig": "cc646b6cd05fe3e1fbcc365e9a042ca0ecd0b04523d3c228ce1504dc77703cea150ebdab0e4704fe3fb9bc5d6a08acf3ca32367386cdbab4a778e18213f85ab2", "tags": [ [ - "t", - "soveng" + "title", + "nostr" ], [ - "p", - "23d12ef8751e5ee267fb6341d7c41b9434a1b99869e0212eb34d56abb6b12e8a", - "wss://nos.lol/", - "mention" + "summary", + "## the protocol\n\n[nostr](https://github.com/nostr-protocol/nostr) is a protocol for establishing pseudonymous identities and using them to cryptographically sign messages and pass them around via" ], [ - "p", - "50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63", - "wss://eden.nostr.land/", - "mention" + "published_at", + "1676764800" + ], + [ + "d", + "d4b8f38a8c6e1e9a9ecbffecb23fd7dfe7b68b18d08584cb7b09d7e858f5ebf6" ], [ "r", - "wss://nos.lol/" + "https://plantimals.org/nostr/" + ] + ] + }, + { + "content": "## the protocol\n\n[nostr](https://github.com/nostr-protocol/nostr) is a protocol for establishing pseudonymous identities and using them to cryptographically sign messages and pass them around via relays. it grew out of a small community of bitcoin/lightning devs, specifically [fiatjaf](nostr:npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6). it was a reaction to the counter-party risks in using centralized social networks (twitter, facebook, etc) and the failings of other, semi-decentralized approaches (mastodon).\n\nbitcoin familiarity helps when groking nostr. the identity portion of nostr is private/public key pair. similar to a bitcoin wallet where one uses one\u2019s private key to sign a transaction and publish it into the network, one uses a nostr private key to sign an event and publish it to relays. there are various defined [kinds](https://github.com/nostr-protocol/nips#event-kinds) of events, from profile information (name, about, url, avatar url, etc), to contact lists (follows), to notes (a short plain text message). the bitcoin network is peer to peer (distributed), where nodes will gossip transactions around to all the other nodes, but this is not the case with nostr. events stay where you publish them, relays only talk to clients, not other relays. this makes nostr decentralized, but not distributed. since it\u2019s not peer-to-peer, one client doesn\u2019t need to see the entire traffic of the network, or to query some distributed hash table a la IPFS, to find content across the network. this middle ground between centralized and distributed allows snappy response times yet prevents the domination of network lock-in.", + "created_at": 1782142849, + "id": "d957e3daa1b7c6275cb2335edbe67bef9e21218aaa32731f7362f2b17367e297", + "kind": 30023, + "pubkey": "8d5b9142b2021c38ecbd9fe6bead324836dad84379643507eb8a4ce58dd31feb", + "sig": "f93128dc50737a678b41d8d57c051281839325e7de6692605902d3bb718ec5a450bde6e28a4715dac852797c161ba54f96d69183a4826506ca1b7232817b07cf", + "tags": [ + [ + "title", + "nostr" + ], + [ + "summary", + "## the protocol\n\n[nostr](https://github.com/nostr-protocol/nostr) is a protocol for establishing pseudonymous identities and using them to cryptographically sign messages and pass them around via" + ], + [ + "published_at", + "1676764800" + ], + [ + "d", + "d4b8f38a8c6e1e9a9ecbffecb23fd7dfe7b68b18d08584cb7b09d7e858f5ebf6" ], [ "r", - "wss://relay.primal.net/" + "https://plantimals.org/nostr/" + ] + ] + }, + { + "content": "Join me today for Episode 758 of Bitcoin And . . . Topics for today: - nostr nostr nostr - Dito to Bring Mastodon to nostr - RFK Jr. and Sound Money - Peeps Madz at Gensler - Block Sues Visa/MC - Altszn is starting. Just Don't - Miners Selling BTC #Bitcoin #BitcoinAnd #BTC Circle P: npub: npub1ugnq57hn8va6xqr5zywy2eunem6c624583vkt0dmv40ep7tnnxkqrr898l (faster) email: shishi21m@protonmail.com (slower) Product: Comfrey One full root for 20$ Or root cutting for 1$ each Buyer pays shipping Articles: https://cointelegraph.com/news/bitcoin-miners-selling-btc-as-hedge-bitfinex https://www.coindesk.com/policy/2023/07/19/us-presidential-candidate-rfk-jr-vows-to-exempt-crypto-from-capital-gains-tax-if-elected/ https://bitcoinmagazine.com/business/blackrock-etf-would-supercharge-bitcoin-mining https://cointelegraph.com/news/lawmakers-propose-sec-chair-consider-legislation-not-enforcement-approach-crypto https://www.finextra.com/newsarticle/42653/block-sues-visa-and-mastercard-over-interchange-fees https://www.nobsbitcoin.com/simple-bitcoin-wallet-to-drop-lightning/ - https://www.cnbc.com/futures-and-commodities/ - https://bitinfocharts.com/ - https://bitcoin.clarkmoody.com/dashboard/ - https://mempool.space/ - https://fountain.fm/charts https://decrypt.co/149120/genesis-trading-saw-uptick-in-altcoin-activity-after-ripple-sec-decision https://cointelegraph.com/news/bitcoin-mining-becoming-more-environmentally-friendly https://primal.net/e/note1dnps5ceywjsx3c4dgyef660ylymudkrxc29vlxpacx5m9zghncusfjhpu3 https://www.nobsbitcoin.com/dito-bringing-best-parts-of-mastodon-to-nostr/ https://primal.net/e/note1sml79x4y6hpuqy8xr2squdztjzxa59lkmv2560vv9aqne6z3hkwq3pn7vl https://www.nobsbitcoin.com/dito-bringing-best-parts-of-mastodon-to-nostr/ https://decrypt.co/149106/openai-donates-5-million-to-the-american-journalism-project https://primal.net/p/npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk Find me on nostr npub1vwymuey3u7mf860ndrkw3r7dz30s0srg6tqmhtjzg7umtm6rn5eq2qzugd (npub) 6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32 (Hex) StackerNews: stacker.news/NunyaBidness Podcasting 2.0: fountain.fm/show/eK5XaSb3UaLRavU3lYrI Apple Podcasts: tinyurl.com/unm35bjh Instagram: instagram.com/bitcoin\\_and Mastodon: noagendasocial.com/@NunyaBidness Support Bitcoin And . . . on Patreon: patreon.com/BitcoinAndPodcast Find Lightning Network Channel partners here: https://t.me/+bj-7w\\_ePsANlOGEx (Nodestrich) https://t.me/plebnet (Plebnet) Music by: Flutey Funk Kevin MacLeod (incompetech.com) Licensed under Creative Commons: By Attribution 3.0 License creativecommons.org/licenses/by/3.0/", + "created_at": 1781766026, + "id": "aef3fd33ea4d2f7bd07e796c173a42a293ad95df140439e074af7a7f672ba030", + "kind": 30023, + "pubkey": "1ee4d19877f0f101d65c53a302126edd7cbf051eca4ef9adc47010a84931ce0c", + "sig": "a357c61d5ccefb2e8fe222abb95d27ae6a4f1265ab303cb5e58136a1fad3a52a615ab7239100a260b05baec6fe78c97cd51b24c5e72386a6c9fd47b29f0e26dd", + "tags": [ + [ + "title", + "nostr nostr nostr Ep758" + ], + [ + "summary", + "Join me today for Episode 758 of Bitcoin And . . . Topics for today: - nostr nostr nostr - Dito to Bring Mastodon to nostr - RFK Jr. and Sound Money - Peeps Madz at Gensler - Block Sues Visa/MC -" + ], + [ + "published_at", + "1689793696" + ], + [ + "d", + "nostr-nostr-nostr-ep758" ], [ "r", - "wss://relay.damus.io/", - "write" + "https://soundcloud.com/bitcoin-and/nostr-nostr-nostr-ep758" ], [ - "r", - "wss://nostr.wine/", - "write" - ], - [ - "r", - "wss://npub1spxdug4m3y24hpx5crm0el4zhkk0wafs8kp6m0xu0wecygqej2xqq8gyhx.fips.network/" + "image", + "https://assets.podhome.fm/98d1b634-3ead-490c-ade3-08dc4d9e1446/6384706134504864440x3000.jpg" ], [ "imeta", - "url https://blossom.primal.net/45d7ca76a456c8dbe32b13223340f91c65c391d08dde40ed930127acf5aa0e2d.png", - "m image/png", - "dim 1634x1098", - "service nip96" - ], - [ - "client", - "Primal Web" + "m audio/mpeg", + "url https://op3.dev/e/dts.podtrac.com/redirect.mp3/pdcn.co/e/pscrb.fm/rss/p/serve.podhome.fm/episode/98d1b634-3ead-490c-ade3-08dc4d9e1446/638470613459883050f568cac8-cc4f-4c3c-a83f-764b84cce760.mp3" ] ] }, @@ -1655,74 +1760,6 @@ ] ] }, - { - "content": "nostr.wine nostr relay.", - "created_at": 1771436582, - "id": "85fe5aa85429bb367ce0cf8f03f71f74ba83dd02f1b42f666bfb921bb07c7c86", - "kind": 30023, - "pubkey": "e938eb5077f30bd8fd0f9a0681514e52706fb231f0ac7516e3194530618addfa", - "sig": "40c7e26e24663feb4a22906d22bb424740ddf826d6c71a2a4839e8adf533f11692ad1b19c9566414849e2519b508398b7d848e63b2b9ef6631567b3242bd0460", - "tags": [ - [ - "d", - "nostr_wine" - ], - [ - "title", - "nostr.wine Nostr Relay" - ], - [ - "image", - "" - ], - [ - "published_at", - "2026-02-18 17:43:02 +0000 UTC" - ], - [ - "t", - "nostr" - ], - [ - "t", - "relay" - ] - ] - }, - { - "content": "nostr.mom nostr relay.", - "created_at": 1771436581, - "id": "d6f0e0db71f5b276e008d7285b896e069b78f0eb5da3b34fdd87c97eac66d01a", - "kind": 30023, - "pubkey": "e938eb5077f30bd8fd0f9a0681514e52706fb231f0ac7516e3194530618addfa", - "sig": "6936e469dd04081e2fd9eb79800793258d7cbae1a0f178a05d9a2480037f48ec78b9e9e3d3486ccdaa3922f29eae73a7b7734222f7a76070d3cdbc9260fe487a", - "tags": [ - [ - "d", - "nostr_mom" - ], - [ - "title", - "nostr.mom Nostr Relay" - ], - [ - "image", - "" - ], - [ - "published_at", - "2026-02-18 17:43:01 +0000 UTC" - ], - [ - "t", - "nostr" - ], - [ - "t", - "relay" - ] - ] - }, { "content": "Nostr (Notes and Other Stuff Transmitted by Relays) is a decentralized, censorship-resistant social protocol designed to give users control over their online identities and interactions.", "created_at": 1771436580, @@ -1793,39 +1830,82 @@ ] }, { - "content": "Nostr Wiki\n\nEstive sumido do #Nostr por um tempo, mas durante este tempo tamb\u00e9m estivo o mais pr\u00f3ximo do protocolo que algu\u00e9m pode estar... at\u00e9 domingo publico que o projeto que estou finalizando para o Nostr, e sem sombra de d\u00favidas est\u00e1 espetacular, estou muito animado.\n\nBom dia meus caros!!", - "created_at": 1758286903, - "id": "ae298ef12b30dcf70f57655eb27f3e44dae390f75756aa63302e675a0d113c15", + "content": "To add to what nostr:nprofile1qqs06gywary09qmcp2249ztwfq3ue8wxhl2yyp3c39thzp55plvj0sgprdmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdakj7qguwaehxw309a6xsetrd96xzer9dshxummnw3erztnrdakj7qgmwaehxw309amksetpwshxsctswpuhgctkv4exutnrduhsw0qlr4 said, Aedile is intended to be a fast, efficient, and stable set of code that other developers can use to bootstrap Nostr apps.\n\nThere are a few things every Nostr clients needs to do, like connect to relays, handle user login, sign events, publish them, and search. We aim to make Aedile the best at all of those so it can be a firm foundation for future Nostr apps.", + "created_at": 1735859178, + "id": "45da80e4cdd89c184f76b3f54452a98bc605adbabf48915a8a8b2eb9888f0546", "kind": 1, - "pubkey": "55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27", - "sig": "c76edad7378dc377c95378e2b73d38478ce1ed78605ae9f8945578da43b2bab45349f5d9a947aea8050c6b030d3438d18e7ab65e6e3c5464457399dd4de94c32", + "pubkey": "70122128273bdc07af9be7725fa5c4bc0fc146866bec38d44360dc4bc6cc18b9", + "sig": "f9a86c7dee312817f1d39e3b166e792585e6b736f1712ad00fa1da6eb4798a54131358b4483f5893130081bb7f15e4e7dfa06c5f15c800e246492da453cdc7f1", "tags": [ [ - "a", - "34550:55472e9c01f37a35f6032b9b78dade386e6e4c57d80fd1d0646abb39280e5e27:ndevs-br" + "e", + "dff2cb2931ea98da9dac8ac5441c84294ba667dd106fa48e65321cfaad9f0c52", + "", + "root" ], [ - "subject", - "Nostr Wiki" + "e", + "ff776994c5d217ea538c6752e64cd71d1721c459b5c5a2bc86036861511cad8b" ], [ - "t", - "Nostr" + "e", + "f785909a94e788b03d74801c96b319849124cda6929c77f5149466b74c38b5cb", + "", + "reply" ], [ - "t", - "nostr" + "p", + "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", + "", + "mention" + ], + [ + "p", + "5ffb8e1b6b629c0e34a013f9298ebb0759b98a3d24029916321d5eb4255b6735" + ], + [ + "p", + "75d12141a391994c4062501f155c2e34e41a8c3cf9bef9e4d7d298253458d1e2" ] ] }, { - "content": "Still want to see a good pinterest, instagram and tiktok competitor on nostr. There's so much value in visual content and we haven't even bothered scratching that surface. \n\nImagine the interoperability of a curation platform with a shopping platform, both contributing to the creator's sovereign identity vs fighting against one another. \n\nMassive potential. ", - "created_at": 1724727720, - "id": "a42cc0bb299bbce89e5ea77b797a2126a2f149f8a22ab665d14dc9187c1e3cfc", - "kind": 1, - "pubkey": "1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411", - "sig": "051be0cfd325158e5fbae60d7a1f4deadc409677f507166774130fea51acd952e115a8a889b2b7ed13b9ef9d6587428b0b8fc76b6aa09db3085f133e1e84acc1", - "tags": [] + "content": "", + "created_at": 1720461688, + "id": "cb5892408f969b7002118b108c95ebccfa094087fc4946b7f630a4da61c6848e", + "kind": 30023, + "pubkey": "726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11", + "sig": "dfb9200e48bd1b808cf61415a24b7e90ead2ea8b20f676cc131976d71125e8b2c54246a83436bfd3914153b6867d0a6ef54cbc920a340fa5fd7341b3a707cfc4", + "tags": [ + [ + "d", + "respect-the-a-context" + ], + [ + "title", + "Nostr" + ], + [ + "summary", + "Nostr" + ], + [ + "published_at", + "1696882326" + ], + [ + "t", + "nostr" + ], + [ + "published_at", + "1696882326" + ], + [ + "alt", + "This is a long form article, you can read it in https://habla.news/franzap/respect-the-a-context" + ] + ] } ], "flags": [ @@ -1867,39 +1947,6 @@ ] ] }, - { - "content": "Grok Build is Now Open Source\nExplore the harness behind our coding agent and TUI.\n\n\nnostr:naddr1qvzqqqr4gupzqfrzd2rja8n2v66f6g6g46gzske3l0fhhnpxa50h2vw6lt4xjp4gqqjxzv3sxasnjv3394jxxde3956rvvr994sn2vnr956nvdejvs6nqdmrvs6kgw83x37 ", - "created_at": 1784198593, - "id": "40d9f5c7ee4a67c5dd64d7542d8be05fcf7ddb56a1114646a7eee9f516d48fb4", - "kind": 1, - "pubkey": "24626a872e9e6a66b49d2348ae90285b31fbd37bcc26ed1f7531dafaea6906a8", - "sig": "f51de79bcfc00cab5fd98640a1fc3a9877dcb8b47c0a129ed43b91b7962e384a974842ee5031156cc7d082576c24dee6c8db6598e308f75b254d1463e99c7581", - "tags": [ - [ - "subject", - "Grok Build is Now Open Source" - ], - [ - "image", - "https://x.ai/images/news/open-source-cover.png" - ], - [ - "a", - "30023:24626a872e9e6a66b49d2348ae90285b31fbd37bcc26ed1f7531dafaea6906a8:a207a921-dc71-460e-a52c-5672d507cd5d", - "", - "mention" - ], - [ - "client", - "" - ], - [ - "p", - "24626a872e9e6a66b49d2348ae90285b31fbd37bcc26ed1f7531dafaea6906a8", - "" - ] - ] - }, { "content": "Top 10: Open Source AI Platforms\nFrom powerful frameworks to cutting-edge models, discover the top 10 open-source AI platforms that are giving developers ultimate control\n\n\nnostr:naddr1qvzqqqr4gupzq7yp3uw8pk4kj3u7mx690fenmw7wn7a3unf6l76yn2lnug4sk3xeqqjrjvf3xcenze3h94jxgdph956rqwfh95urywfc956rvvmxx4jxvc3kx3snj97cg2r ", "created_at": 1781196258, @@ -2074,6 +2121,41 @@ "Amethyst" ] ] + }, + { + "content": "He's going so hardcore cloning the repo and building from source lol", + "created_at": 1779978067, + "id": "0000dc5ab01b9a8831edd854e85de91b13fae0bde77fc64706655de3e2b38f13", + "kind": 1, + "pubkey": "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb", + "sig": "eee7678cd75cc28b468777683d0800bdd29f4d9ace6a9b18aeed0614dc34a743edb79c1ac4fe60369becc47a3dd8585e35f2abba7384d9a5f07c689fd70cf851", + "tags": [ + [ + "e", + "00004d1e487177d4f2e18a71d8db28b8009402438c0d52789eb2132ef1e035c4", + "wss://nos.lol", + "root" + ], + [ + "e", + "00002db9957e51979b7d2450e194de9f9d515a8cefa3dd0574d1b39e311fc6e5", + "wss://relay.primal.net", + "reply" + ], + [ + "p", + "1e067bfb58820576df3daf7cb051d4411b80a0b8fa12fc253cd0ab41cf1a2069" + ], + [ + "client", + "Wisp" + ], + [ + "nonce", + "78063", + "16" + ] + ] } ], "flags": [ @@ -2086,5 +2168,5 @@ } ], "lens": "include:spam", - "relay": "wss://search-staging.brainstorm.world" + "relay": "wss://search.brainstorm.world" }