Merge branch 'main' into claude/add-blossom-cache-support-ts8mK
This commit is contained in:
@@ -176,3 +176,7 @@ packaging/appimage/squashfs-root/
|
|||||||
benchmark/src/main/jniLibs/
|
benchmark/src/main/jniLibs/
|
||||||
|
|
||||||
/tools/marmot-interop/state
|
/tools/marmot-interop/state
|
||||||
|
|
||||||
|
# Cargo build artifacts for the cross-stack interop sidecars at
|
||||||
|
# nestsClient/tests/hang-interop/. Cargo.lock is committed (binary workspace).
|
||||||
|
/nestsClient/tests/hang-interop/target/
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.vitorpamplona.amethyst.service.logging.Logging
|
import com.vitorpamplona.amethyst.service.logging.Logging
|
||||||
|
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import com.vitorpamplona.quartz.utils.LogLevel
|
import com.vitorpamplona.quartz.utils.LogLevel
|
||||||
|
|
||||||
@@ -41,6 +42,17 @@ class Amethyst : Application() {
|
|||||||
Log.d("AmethystApp") { "onCreate $this" }
|
Log.d("AmethystApp") { "onCreate $this" }
|
||||||
instance = AppModules(this)
|
instance = AppModules(this)
|
||||||
|
|
||||||
|
// After-background foreground recycle: when the app returns to
|
||||||
|
// the foreground after spending more than ~5 s in the
|
||||||
|
// background, publish a network-change event so every active
|
||||||
|
// NestViewModel recycles its underlying QUIC session. Covers
|
||||||
|
// the case where Android reclaims our UDP socket FD while
|
||||||
|
// backgrounded — the connectivity callback in
|
||||||
|
// `NestForegroundService` doesn't fire there because the
|
||||||
|
// network itself is still up. See `AppForegroundRecycleHook`'s
|
||||||
|
// kdoc for the threshold rationale.
|
||||||
|
registerActivityLifecycleCallbacks(AppForegroundRecycleHook())
|
||||||
|
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
Logging.setup()
|
Logging.setup()
|
||||||
// Auto-enable the Nests session-trace recorder in debug
|
// Auto-enable the Nests session-trace recorder in debug
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind
|
|||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
|
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
|
||||||
import com.vitorpamplona.amethyst.service.safeCacheDir
|
import com.vitorpamplona.amethyst.service.safeCacheDir
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
|
||||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
|
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
|
||||||
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
|
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
|
||||||
@@ -469,6 +471,11 @@ class AppModules(
|
|||||||
// subscriptions, and NotificationRelayService.
|
// subscriptions, and NotificationRelayService.
|
||||||
val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope)
|
val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope)
|
||||||
|
|
||||||
|
// Local store for posts the user has scheduled to publish later. Backed by a
|
||||||
|
// single JSON file under the app's private filesDir; read by ScheduledPostWorker.
|
||||||
|
val scheduledPostStore =
|
||||||
|
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME))
|
||||||
|
|
||||||
// Organizes cache clearing
|
// Organizes cache clearing
|
||||||
val trimmingService by
|
val trimmingService by
|
||||||
lazy {
|
lazy {
|
||||||
@@ -587,6 +594,12 @@ class AppModules(
|
|||||||
// starts observing LocalCache for notification-worthy events
|
// starts observing LocalCache for notification-worthy events
|
||||||
notificationDispatcher.start()
|
notificationDispatcher.start()
|
||||||
|
|
||||||
|
// Schedule the scheduled-posts worker (periodic + one-time catch-up).
|
||||||
|
// Runs independently of the always-on notification setting so scheduled
|
||||||
|
// posts still fire when always-on notifications are disabled.
|
||||||
|
ScheduledPostWorker.schedule(appContext)
|
||||||
|
ScheduledPostWorker.scheduleCatchUp(appContext)
|
||||||
|
|
||||||
// Watch for account login and start/stop always-on notification service
|
// Watch for account login and start/stop always-on notification service
|
||||||
applicationIOScope.launch {
|
applicationIOScope.launch {
|
||||||
sessionManager.accountContent.collectLatest { state ->
|
sessionManager.accountContent.collectLatest { state ->
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ fun debugState(context: Context) {
|
|||||||
Log.d(
|
Log.d(
|
||||||
STATE_DUMP_TAG,
|
STATE_DUMP_TAG,
|
||||||
"Observables: " +
|
"Observables: " +
|
||||||
LocalCache.observables.size,
|
LocalCache.observables.size(),
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.d(
|
Log.d(
|
||||||
|
|||||||
@@ -529,6 +529,14 @@ class Account(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun updateDisableClientTag(disable: Boolean): Boolean {
|
||||||
|
if (settings.updateDisableClientTag(disable)) {
|
||||||
|
sendNewAppSpecificData()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun updateFilterSpam(filterSpam: Boolean): Boolean {
|
suspend fun updateFilterSpam(filterSpam: Boolean): Boolean {
|
||||||
if (settings.updateFilterSpam(filterSpam)) {
|
if (settings.updateFilterSpam(filterSpam)) {
|
||||||
if (!settings.syncedSettings.security.filterSpamFromStrangers.value) {
|
if (!settings.syncedSettings.security.filterSpamFromStrangers.value) {
|
||||||
|
|||||||
@@ -419,6 +419,14 @@ class AccountSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateDisableClientTag(disable: Boolean): Boolean =
|
||||||
|
if (syncedSettings.security.updateDisableClientTag(disable)) {
|
||||||
|
saveAccountSettings()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
// ---
|
// ---
|
||||||
// list names
|
// list names
|
||||||
// ---
|
// ---
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class AccountSyncedSettings(
|
|||||||
MutableStateFlow(internalSettings.security.filterSpamFromStrangers),
|
MutableStateFlow(internalSettings.security.filterSpamFromStrangers),
|
||||||
MutableStateFlow(internalSettings.security.maxHashtagLimit),
|
MutableStateFlow(internalSettings.security.maxHashtagLimit),
|
||||||
MutableStateFlow(internalSettings.security.sendKind0EventsToLocalRelay),
|
MutableStateFlow(internalSettings.security.sendKind0EventsToLocalRelay),
|
||||||
|
MutableStateFlow(internalSettings.security.disableClientTag),
|
||||||
)
|
)
|
||||||
val videoPlayer =
|
val videoPlayer =
|
||||||
AccountVideoPlayerPreferences(
|
AccountVideoPlayerPreferences(
|
||||||
@@ -82,6 +83,7 @@ class AccountSyncedSettings(
|
|||||||
security.filterSpamFromStrangers.value,
|
security.filterSpamFromStrangers.value,
|
||||||
security.maxHashtagLimit.value,
|
security.maxHashtagLimit.value,
|
||||||
security.sendKind0EventsToLocalRelay.value,
|
security.sendKind0EventsToLocalRelay.value,
|
||||||
|
security.disableClientTag.value,
|
||||||
),
|
),
|
||||||
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
|
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
|
||||||
)
|
)
|
||||||
@@ -138,6 +140,10 @@ class AccountSyncedSettings(
|
|||||||
security.sendKind0EventsToLocalRelay.tryEmit(syncedSettingsInternal.security.sendKind0EventsToLocalRelay)
|
security.sendKind0EventsToLocalRelay.tryEmit(syncedSettingsInternal.security.sendKind0EventsToLocalRelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (security.disableClientTag.value != syncedSettingsInternal.security.disableClientTag) {
|
||||||
|
security.disableClientTag.tryEmit(syncedSettingsInternal.security.disableClientTag)
|
||||||
|
}
|
||||||
|
|
||||||
val newVideoPlayerButtonItems = syncedSettingsInternal.videoPlayer.buttonItems.toImmutableList()
|
val newVideoPlayerButtonItems = syncedSettingsInternal.videoPlayer.buttonItems.toImmutableList()
|
||||||
if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) {
|
if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) {
|
||||||
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
|
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
|
||||||
@@ -233,6 +239,7 @@ class AccountSecurityPreferences(
|
|||||||
var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||||
val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(5),
|
val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(5),
|
||||||
var sendKind0EventsToLocalRelay: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
var sendKind0EventsToLocalRelay: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||||
|
val disableClientTag: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||||
) {
|
) {
|
||||||
fun updateShowSensitiveContent(show: Boolean?): Boolean {
|
fun updateShowSensitiveContent(show: Boolean?): Boolean {
|
||||||
if (showSensitiveContent.value != show) {
|
if (showSensitiveContent.value != show) {
|
||||||
@@ -273,4 +280,12 @@ class AccountSecurityPreferences(
|
|||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateDisableClientTag(disable: Boolean): Boolean =
|
||||||
|
if (disable != disableClientTag.value) {
|
||||||
|
disableClientTag.tryEmit(disable)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -147,4 +147,5 @@ class AccountSecurityPreferencesInternal(
|
|||||||
var filterSpamFromStrangers: Boolean = true,
|
var filterSpamFromStrangers: Boolean = true,
|
||||||
val maxHashtagLimit: Int = 5,
|
val maxHashtagLimit: Int = 5,
|
||||||
var sendKind0EventsToLocalRelay: Boolean = false,
|
var sendKind0EventsToLocalRelay: Boolean = false,
|
||||||
|
var disableClientTag: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
|||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterIndex
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
|
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
|
||||||
@@ -261,7 +262,6 @@ import java.io.File
|
|||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.SortedSet
|
import java.util.SortedSet
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
interface ILocalCache {
|
interface ILocalCache {
|
||||||
fun markAsSeen(
|
fun markAsSeen(
|
||||||
@@ -290,7 +290,15 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
|
|
||||||
val deletionIndex = DeletionIndex()
|
val deletionIndex = DeletionIndex()
|
||||||
|
|
||||||
val observables = ConcurrentHashMap<Observable, Observable>(10)
|
/**
|
||||||
|
* Inverted index over the active [Observable]s. New events fan
|
||||||
|
* out only to observers whose filter actually narrows on a field
|
||||||
|
* the event carries (author, kind, single-letter tag), instead
|
||||||
|
* of waking every observer per event. Predicate-only observers
|
||||||
|
* (no underlying [Filter]) live in the unindexed pool and still
|
||||||
|
* see every event.
|
||||||
|
*/
|
||||||
|
val observables = FilterIndex<Observable>()
|
||||||
|
|
||||||
fun Filter.match(note: Note): Boolean {
|
fun Filter.match(note: Note): Boolean {
|
||||||
val event = note.event
|
val event = note.event
|
||||||
@@ -361,10 +369,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
|
|
||||||
newFilter.init()
|
newFilter.init()
|
||||||
|
|
||||||
observables[newFilter] = newFilter
|
observables.register(filter, newFilter)
|
||||||
|
|
||||||
awaitClose {
|
awaitClose {
|
||||||
observables.remove(newFilter)
|
observables.unregister(newFilter)
|
||||||
}
|
}
|
||||||
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||||
|
|
||||||
@@ -377,10 +385,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
|
|
||||||
cachedFilter.init()
|
cachedFilter.init()
|
||||||
|
|
||||||
observables.put(cachedFilter, cachedFilter)
|
observables.register(filter, cachedFilter)
|
||||||
|
|
||||||
awaitClose {
|
awaitClose {
|
||||||
observables.remove(cachedFilter)
|
observables.unregister(cachedFilter)
|
||||||
}
|
}
|
||||||
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||||
|
|
||||||
@@ -402,14 +410,28 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
trySend(it)
|
trySend(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
observables.put(newFilter, newFilter)
|
// Unindexed: predicate is opaque, the index can't narrow.
|
||||||
|
// Caller is delivered every event and runs the predicate.
|
||||||
|
observables.registerUnindexed(newFilter)
|
||||||
|
|
||||||
awaitClose {
|
awaitClose {
|
||||||
observables.remove(newFilter)
|
observables.unregister(newFilter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T : Event> observeNewEvents(filter: Filter): Flow<T> = observeNewEvents(filter::match)
|
fun <T : Event> observeNewEvents(filter: Filter): Flow<T> =
|
||||||
|
callbackFlow {
|
||||||
|
val newFilter =
|
||||||
|
NewEventMatchingFilter<T>(filter::match) {
|
||||||
|
trySend(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
observables.register(filter, newFilter)
|
||||||
|
|
||||||
|
awaitClose {
|
||||||
|
observables.unregister(newFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() }
|
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() }
|
||||||
@@ -2514,22 +2536,21 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
private fun refreshNewNoteObservers(newNote: Note) {
|
private fun refreshNewNoteObservers(newNote: Note) {
|
||||||
val event = newNote.event as Event
|
val event = newNote.event as Event
|
||||||
|
|
||||||
val observableBiConsumer =
|
// Index-driven fanout: only observers whose filter narrows
|
||||||
java.util.function.BiConsumer<Observable, Observable> { _, u ->
|
// on a field this event carries (or that registered as
|
||||||
u.new(event, newNote)
|
// unindexed) get woken up. The match check inside each
|
||||||
|
// observer's `new()` still enforces negative constraints.
|
||||||
|
for (observer in observables.candidatesFor(event)) {
|
||||||
|
observer.new(event, newNote)
|
||||||
}
|
}
|
||||||
|
|
||||||
observables.forEach(observableBiConsumer)
|
|
||||||
live.newNote(newNote)
|
live.newNote(newNote)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshDeletedNoteObservers(newNote: Note) {
|
private fun refreshDeletedNoteObservers(newNote: Note) {
|
||||||
val observableBiConsumer =
|
// Deletes don't have a filterable shape — every observer
|
||||||
java.util.function.BiConsumer<Observable, Observable> { _, u ->
|
// might hold this note in its result set, so iterate them
|
||||||
u.remove(newNote)
|
// all. The index doesn't help here.
|
||||||
}
|
observables.forEach { it.remove(newNote) }
|
||||||
|
|
||||||
observables.forEach(observableBiConsumer)
|
|
||||||
live.removedNote(newNote)
|
live.removedNote(newNote)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -135,7 +135,12 @@ class AccountCacheState(
|
|||||||
val cached = accounts.value[signer.pubKey]
|
val cached = accounts.value[signer.pubKey]
|
||||||
if (cached != null) return cached
|
if (cached != null) return cached
|
||||||
|
|
||||||
val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME)
|
val signerWithClientTag =
|
||||||
|
NostrSignerWithClientTag(
|
||||||
|
inner = signer,
|
||||||
|
clientName = CLIENT_TAG_NAME,
|
||||||
|
disabled = { accountSettings.syncedSettings.security.disableClientTag.value },
|
||||||
|
)
|
||||||
|
|
||||||
val accountDir = File(rootFilesDir(), "accounts/${signer.pubKey}").apply { mkdirs() }
|
val accountDir = File(rootFilesDir(), "accounts/${signer.pubKey}").apply { mkdirs() }
|
||||||
|
|
||||||
|
|||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* 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.nests
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Bundle
|
||||||
|
import com.vitorpamplona.amethyst.commons.viewmodels.NestNetworkChangeBus
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-state foreground/background tracker, decoupled from
|
||||||
|
* `android.app.Activity` so the unit tests can drive it without
|
||||||
|
* Robolectric or Mockito.
|
||||||
|
*
|
||||||
|
* See [AppForegroundRecycleHook] for the production motivation /
|
||||||
|
* threshold-rationale kdoc — this class is just the testable core.
|
||||||
|
*
|
||||||
|
* Threading: all state-mutating methods are documented to run on the
|
||||||
|
* Android main thread (Application lifecycle callbacks fire there);
|
||||||
|
* tests call them serially, so no synchronisation is needed.
|
||||||
|
*/
|
||||||
|
class AppForegroundCounter(
|
||||||
|
private val backgroundThresholdMs: Long = AppForegroundRecycleHook.DEFAULT_BACKGROUND_THRESHOLD_MS,
|
||||||
|
private val publishEvent: () -> Unit = { NestNetworkChangeBus.publish() },
|
||||||
|
private val nowMillis: () -> Long = { System.currentTimeMillis() },
|
||||||
|
) {
|
||||||
|
private var startedActivities = 0
|
||||||
|
private var lastBackgroundedAtMillis: Long = -1L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count of recycle events fired since construction. Diagnostic
|
||||||
|
* surface for tests; production code observes the side-effect via
|
||||||
|
* [NestNetworkChangeBus] instead.
|
||||||
|
*/
|
||||||
|
var recyclesFired: Int = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the started-activity counter and, if this is the
|
||||||
|
* 0 → 1 transition AND the app spent ≥ [backgroundThresholdMs]
|
||||||
|
* in the background, fire [publishEvent]. The first
|
||||||
|
* onActivityStarted after process start is a no-op (no prior
|
||||||
|
* background timestamp to compare against).
|
||||||
|
*/
|
||||||
|
fun onActivityStarted() {
|
||||||
|
val wasBackgrounded = startedActivities == 0
|
||||||
|
startedActivities++
|
||||||
|
if (!wasBackgrounded) return
|
||||||
|
val backgroundedAt = lastBackgroundedAtMillis
|
||||||
|
if (backgroundedAt < 0L) return
|
||||||
|
val backgroundedFor = nowMillis() - backgroundedAt
|
||||||
|
if (backgroundedFor < backgroundThresholdMs) {
|
||||||
|
Log.d("AppForegroundCounter") {
|
||||||
|
"skipping recycle on resume after only ${backgroundedFor}ms background " +
|
||||||
|
"(threshold=${backgroundThresholdMs}ms)"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.d("AppForegroundCounter") {
|
||||||
|
"publishing recycle event on resume after ${backgroundedFor}ms background"
|
||||||
|
}
|
||||||
|
recyclesFired++
|
||||||
|
publishEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrement the counter; on N → 0 transition, record the
|
||||||
|
* background timestamp.
|
||||||
|
*/
|
||||||
|
fun onActivityStopped() {
|
||||||
|
startedActivities--
|
||||||
|
if (startedActivities <= 0) {
|
||||||
|
startedActivities = 0
|
||||||
|
lastBackgroundedAtMillis = nowMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-wide observer that publishes a
|
||||||
|
* [NestNetworkChangeBus] event when the app returns to the foreground
|
||||||
|
* after spending more than [backgroundThresholdMs] in the background.
|
||||||
|
*
|
||||||
|
* Production motivation: Android may reclaim a backgrounded app's
|
||||||
|
* UDP-socket file descriptors as it ages out of the foreground app
|
||||||
|
* pool (the kernel's watcher trims after roughly 30 s of no foreground
|
||||||
|
* activity, with quite a bit of variance per OEM). When the user
|
||||||
|
* resumes the app, the QUIC connection sitting on the now-reclaimed
|
||||||
|
* socket has dead OS-level state, but the connection-level FSM
|
||||||
|
* doesn't know that yet — the next `socket.send` throws, and only
|
||||||
|
* then does the send-loop catch surface CLOSED.
|
||||||
|
*
|
||||||
|
* The downstream
|
||||||
|
* [com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel]
|
||||||
|
* already observes [NestNetworkChangeBus] for the network-handover
|
||||||
|
* case (Wi-Fi ↔ cellular). We piggy-back on the same bus here: a
|
||||||
|
* "long enough background" event has the same shape from the QUIC
|
||||||
|
* driver's perspective as a network change — recycle the underlying
|
||||||
|
* session and let the [com.vitorpamplona.nestsclient.connectReconnectingNestsListener]
|
||||||
|
* / `connectReconnectingNestsSpeaker` orchestrators reconnect.
|
||||||
|
*
|
||||||
|
* Why a threshold instead of fire-on-every-resume:
|
||||||
|
* - A short background (notification pulldown, biometric auth, lock-
|
||||||
|
* screen glance) lasts < 1 s and the socket is still healthy. A
|
||||||
|
* forced recycle there is a wasted ~1 s re-handshake gap of audio
|
||||||
|
* silence — annoying to users for no benefit.
|
||||||
|
* - A long background (call from another app, screen off > 30 s,
|
||||||
|
* home-button-then-back) commonly leaves the socket dead. Better
|
||||||
|
* to eat one re-handshake gap than 30 s of silence while the QUIC
|
||||||
|
* PTO times out.
|
||||||
|
* 5 seconds is the sweet spot: well over typical UI transitions but
|
||||||
|
* well under any plausible socket-reclaim window.
|
||||||
|
*/
|
||||||
|
class AppForegroundRecycleHook(
|
||||||
|
backgroundThresholdMs: Long = DEFAULT_BACKGROUND_THRESHOLD_MS,
|
||||||
|
publishEvent: () -> Unit = { NestNetworkChangeBus.publish() },
|
||||||
|
nowMillis: () -> Long = { System.currentTimeMillis() },
|
||||||
|
) : Application.ActivityLifecycleCallbacks {
|
||||||
|
private val counter = AppForegroundCounter(backgroundThresholdMs, publishEvent, nowMillis)
|
||||||
|
|
||||||
|
override fun onActivityStarted(activity: Activity) {
|
||||||
|
counter.onActivityStarted()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityStopped(activity: Activity) {
|
||||||
|
counter.onActivityStopped()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityCreated(
|
||||||
|
activity: Activity,
|
||||||
|
savedInstanceState: Bundle?,
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun onActivityResumed(activity: Activity) = Unit
|
||||||
|
|
||||||
|
override fun onActivityPaused(activity: Activity) = Unit
|
||||||
|
|
||||||
|
override fun onActivitySaveInstanceState(
|
||||||
|
activity: Activity,
|
||||||
|
outState: Bundle,
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Default 5 000 ms — well above the longest plausible UI
|
||||||
|
* transition (notification pull, biometric prompt) and well
|
||||||
|
* below the 30 s timing the Android kernel uses to reclaim
|
||||||
|
* idle UDP sockets.
|
||||||
|
*/
|
||||||
|
const val DEFAULT_BACKGROUND_THRESHOLD_MS = 5_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* 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.scheduledposts
|
||||||
|
|
||||||
|
enum class ScheduledPostStatus {
|
||||||
|
PENDING,
|
||||||
|
PUBLISHING,
|
||||||
|
SENT,
|
||||||
|
FAILED,
|
||||||
|
CANCELLED,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ScheduledPost(
|
||||||
|
val id: String,
|
||||||
|
val accountPubkey: String,
|
||||||
|
val signedEventJson: String,
|
||||||
|
val relayUrls: List<String>,
|
||||||
|
val extraEventsJson: List<String>,
|
||||||
|
val publishAtSec: Long,
|
||||||
|
val createdAtSec: Long,
|
||||||
|
val status: ScheduledPostStatus = ScheduledPostStatus.PENDING,
|
||||||
|
val lastAttemptAtSec: Long? = null,
|
||||||
|
val attemptCount: Int = 0,
|
||||||
|
val lastError: String? = null,
|
||||||
|
// Set when the row enters a terminal state (SENT/CANCELLED). Drives retention.
|
||||||
|
val terminatedAtSec: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduledPostFile(
|
||||||
|
val version: Int = 1,
|
||||||
|
val posts: List<ScheduledPost> = emptyList(),
|
||||||
|
)
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
* 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.scheduledposts
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.extractContentPreview
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts user-visible system notifications when a scheduled post completes
|
||||||
|
* (sent or failed). Without this, a worker firing in background offers zero
|
||||||
|
* diagnostic to the user — see the silent-publish bug the ack-aware worker
|
||||||
|
* now guards against; the notification closes the loop.
|
||||||
|
*/
|
||||||
|
object ScheduledPostNotifier {
|
||||||
|
// @Volatile so the channel reference is visible across the WorkManager IO
|
||||||
|
// thread pool — two workers firing in the same window can race on
|
||||||
|
// ensureChannel. createNotificationChannel itself is idempotent.
|
||||||
|
@Volatile
|
||||||
|
private var channel: NotificationChannel? = null
|
||||||
|
private const val SCHEDULED_POST_NOT_ID_BASE = 0x70000
|
||||||
|
|
||||||
|
fun notifySent(
|
||||||
|
context: Context,
|
||||||
|
post: ScheduledPost,
|
||||||
|
) {
|
||||||
|
ensureChannel(context)
|
||||||
|
post(
|
||||||
|
context = context,
|
||||||
|
notId = idFor(post.id),
|
||||||
|
title = stringRes(context, R.string.scheduled_posts_notification_sent_title),
|
||||||
|
body = extractContentPreview(post, 120),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyFailed(
|
||||||
|
context: Context,
|
||||||
|
post: ScheduledPost,
|
||||||
|
error: String?,
|
||||||
|
) {
|
||||||
|
ensureChannel(context)
|
||||||
|
val snippet = extractContentPreview(post, 120)
|
||||||
|
val body =
|
||||||
|
if (error.isNullOrBlank()) {
|
||||||
|
snippet
|
||||||
|
} else {
|
||||||
|
"$snippet\n${stringRes(context, R.string.scheduled_posts_error_prefix, error)}"
|
||||||
|
}
|
||||||
|
post(
|
||||||
|
context = context,
|
||||||
|
notId = idFor(post.id),
|
||||||
|
title = stringRes(context, R.string.scheduled_posts_notification_failed_title),
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(
|
||||||
|
context: Context,
|
||||||
|
notId: Int,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
) {
|
||||||
|
val channelId = stringRes(context, R.string.app_notification_scheduled_posts_channel_id)
|
||||||
|
val tapIntent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
}
|
||||||
|
val tapPendingIntent =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
notId,
|
||||||
|
tapIntent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
val notificationManager = NotificationManagerCompat.from(context)
|
||||||
|
// POST_NOTIFICATIONS is runtime-granted on Android 13+; bail out
|
||||||
|
// explicitly so lint doesn't flag the notify() call and so a denied
|
||||||
|
// user doesn't see a misleading no-op log.
|
||||||
|
if (!notificationManager.areNotificationsEnabled()) return
|
||||||
|
val builder =
|
||||||
|
NotificationCompat
|
||||||
|
.Builder(context, channelId)
|
||||||
|
.setSmallIcon(R.drawable.amethyst)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||||
|
.setContentIntent(tapPendingIntent)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setWhen(System.currentTimeMillis())
|
||||||
|
try {
|
||||||
|
notificationManager.notify(notId, builder.build())
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
// Race: permission revoked between the check above and notify().
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureChannel(context: Context) {
|
||||||
|
if (channel != null) return
|
||||||
|
channel =
|
||||||
|
NotificationChannel(
|
||||||
|
stringRes(context, R.string.app_notification_scheduled_posts_channel_id),
|
||||||
|
stringRes(context, R.string.app_notification_scheduled_posts_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
).apply {
|
||||||
|
description = stringRes(context, R.string.app_notification_scheduled_posts_channel_description)
|
||||||
|
}
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
nm.createNotificationChannel(channel!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct id per post so multiple completions don't collapse onto one row.
|
||||||
|
private fun idFor(postId: String): Int = SCHEDULED_POST_NOT_ID_BASE xor postId.hashCode()
|
||||||
|
}
|
||||||
+280
@@ -0,0 +1,280 @@
|
|||||||
|
/*
|
||||||
|
* 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.scheduledposts
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class ScheduledPostStore(
|
||||||
|
private val storageFile: File,
|
||||||
|
private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 },
|
||||||
|
) {
|
||||||
|
private val mapper =
|
||||||
|
jacksonObjectMapper()
|
||||||
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var loaded = false
|
||||||
|
private var posts: MutableList<ScheduledPost> = mutableListOf()
|
||||||
|
|
||||||
|
private val _flow = MutableStateFlow<List<ScheduledPost>>(emptyList())
|
||||||
|
|
||||||
|
/** Live snapshot of all stored posts. Updated on every mutation. */
|
||||||
|
val flow: StateFlow<List<ScheduledPost>> = _flow.asStateFlow()
|
||||||
|
|
||||||
|
suspend fun add(post: ScheduledPost) =
|
||||||
|
mutex.withLock {
|
||||||
|
ensureLoaded()
|
||||||
|
posts.add(post)
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun cancel(id: String): Boolean =
|
||||||
|
mutex.withLock {
|
||||||
|
ensureLoaded()
|
||||||
|
val now = nowSec()
|
||||||
|
val updated = mutate(id) { it.copy(status = ScheduledPostStatus.CANCELLED, terminatedAtSec = now) }
|
||||||
|
if (updated) persist()
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun list(): List<ScheduledPost> =
|
||||||
|
mutex.withLock {
|
||||||
|
ensureLoaded()
|
||||||
|
posts.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun listFor(accountPubkey: String): List<ScheduledPost> =
|
||||||
|
mutex.withLock {
|
||||||
|
ensureLoaded()
|
||||||
|
posts.filter { it.accountPubkey == accountPubkey }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically claim posts due at or before [nowSec]: each PENDING post with
|
||||||
|
* publishAtSec <= now is flipped to PUBLISHING and returned. A concurrent
|
||||||
|
* claim from another worker will see those posts as PUBLISHING and skip them.
|
||||||
|
*/
|
||||||
|
suspend fun claimDuePosts(nowSec: Long): List<ScheduledPost> =
|
||||||
|
mutex.withLock {
|
||||||
|
ensureLoaded()
|
||||||
|
val dueIds =
|
||||||
|
posts
|
||||||
|
.filter { it.status == ScheduledPostStatus.PENDING && it.publishAtSec <= nowSec }
|
||||||
|
.map { it.id }
|
||||||
|
.toSet()
|
||||||
|
if (dueIds.isEmpty()) return@withLock emptyList()
|
||||||
|
dueIds.forEach { id ->
|
||||||
|
mutate(id) {
|
||||||
|
it.copy(
|
||||||
|
status = ScheduledPostStatus.PUBLISHING,
|
||||||
|
lastAttemptAtSec = nowSec,
|
||||||
|
attemptCount = it.attemptCount + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
persist()
|
||||||
|
posts.filter { it.id in dueIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun markSent(id: String) =
|
||||||
|
mutex.withLock {
|
||||||
|
ensureLoaded()
|
||||||
|
val now = nowSec()
|
||||||
|
if (mutate(id) { it.copy(status = ScheduledPostStatus.SENT, lastError = null, terminatedAtSec = now) }) persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun markFailed(
|
||||||
|
id: String,
|
||||||
|
error: String?,
|
||||||
|
) = mutex.withLock {
|
||||||
|
ensureLoaded()
|
||||||
|
if (mutate(id) { it.copy(status = ScheduledPostStatus.FAILED, lastError = error) }) persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a post to publish immediately by setting its publishAtSec to [nowSec]
|
||||||
|
* and resetting status to PENDING. Handles two cases with one method:
|
||||||
|
* - PENDING (future-scheduled): user wants to push it out now
|
||||||
|
* - FAILED: user wants to retry
|
||||||
|
* Caller is expected to enqueue ScheduledPostWorker.scheduleCatchUp() afterwards
|
||||||
|
* so the worker picks it up promptly.
|
||||||
|
*/
|
||||||
|
suspend fun publishNow(
|
||||||
|
id: String,
|
||||||
|
nowSec: Long = System.currentTimeMillis() / 1000,
|
||||||
|
): Boolean =
|
||||||
|
mutex.withLock {
|
||||||
|
ensureLoaded()
|
||||||
|
val updated =
|
||||||
|
mutate(id) {
|
||||||
|
it.copy(
|
||||||
|
publishAtSec = nowSec,
|
||||||
|
status = ScheduledPostStatus.PENDING,
|
||||||
|
lastError = null,
|
||||||
|
terminatedAtSec = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (updated) persist()
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove every row owned by [accountPubkey]. Used when the user deletes
|
||||||
|
* an account — the account's signed events should not linger. Returns the
|
||||||
|
* number of rows removed; persists once if any rows matched.
|
||||||
|
*/
|
||||||
|
suspend fun removeForAccount(accountPubkey: String): Int =
|
||||||
|
mutex.withLock {
|
||||||
|
ensureLoaded()
|
||||||
|
val before = posts.size
|
||||||
|
val removed = posts.removeAll { it.accountPubkey == accountPubkey }
|
||||||
|
val count = before - posts.size
|
||||||
|
if (removed) persist()
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revert a PUBLISHING claim back to PENDING (e.g. when the account is not
|
||||||
|
* loaded at fire time, so we should retry on the next cycle rather than
|
||||||
|
* marking the post failed permanently).
|
||||||
|
*/
|
||||||
|
suspend fun releaseClaim(id: String) =
|
||||||
|
mutex.withLock {
|
||||||
|
ensureLoaded()
|
||||||
|
val changed =
|
||||||
|
mutate(id) {
|
||||||
|
if (it.status == ScheduledPostStatus.PUBLISHING) {
|
||||||
|
it.copy(status = ScheduledPostStatus.PENDING)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mutate(
|
||||||
|
id: String,
|
||||||
|
transform: (ScheduledPost) -> ScheduledPost,
|
||||||
|
): Boolean {
|
||||||
|
val idx = posts.indexOfFirst { it.id == id }
|
||||||
|
if (idx < 0) return false
|
||||||
|
val before = posts[idx]
|
||||||
|
val after = transform(before)
|
||||||
|
if (after == before) return false
|
||||||
|
posts[idx] = after
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureLoaded() {
|
||||||
|
if (loaded) return
|
||||||
|
posts =
|
||||||
|
try {
|
||||||
|
if (storageFile.exists() && storageFile.length() > 0) {
|
||||||
|
mapper.readValue<ScheduledPostFile>(storageFile).posts.toMutableList()
|
||||||
|
} else {
|
||||||
|
mutableListOf()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load scheduled posts from $storageFile", e)
|
||||||
|
mutableListOf()
|
||||||
|
}
|
||||||
|
loaded = true
|
||||||
|
val purged = purgeStale(nowSec())
|
||||||
|
_flow.value = posts.toList()
|
||||||
|
if (purged) persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop SENT rows older than [SENT_RETENTION_SEC] and CANCELLED rows older
|
||||||
|
* than [CANCELLED_RETENTION_SEC]. Returns true if any row was removed.
|
||||||
|
* FAILED rows are kept indefinitely so the user can still see and retry them;
|
||||||
|
* PENDING / PUBLISHING rows are never purged.
|
||||||
|
*/
|
||||||
|
private fun purgeStale(now: Long): Boolean {
|
||||||
|
val before = posts.size
|
||||||
|
posts.removeAll { post ->
|
||||||
|
// terminatedAtSec is the post-PR field. Legacy rows lack it; fall back to
|
||||||
|
// lastAttemptAtSec (set at SENT) and finally createdAtSec. The fallback
|
||||||
|
// can purge old CANCELLED rows up to 30d earlier than intended on first
|
||||||
|
// run after upgrade — self-healing once new rows are written.
|
||||||
|
val age = now - (post.terminatedAtSec ?: post.lastAttemptAtSec ?: post.createdAtSec)
|
||||||
|
when (post.status) {
|
||||||
|
ScheduledPostStatus.SENT -> age > SENT_RETENTION_SEC
|
||||||
|
ScheduledPostStatus.CANCELLED -> age > CANCELLED_RETENTION_SEC
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return posts.size < before
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the snapshot to disk *while holding the data mutex*. This is a
|
||||||
|
* deliberate tradeoff: moving the write outside the lock would require a
|
||||||
|
* separate write-mutex (or a sequence number) to preserve write ordering
|
||||||
|
* across concurrent mutations — otherwise an older snapshot can clobber a
|
||||||
|
* newer one if the OS schedules the second write to finish first. For a
|
||||||
|
* file that's a few KB and a single-process owner with infrequent writes,
|
||||||
|
* holding the mutex across the rename is the simpler and correct choice.
|
||||||
|
* Revisit if the store ever grows past a hundred rows or starts seeing
|
||||||
|
* concurrent multi-writer pressure.
|
||||||
|
*/
|
||||||
|
private fun persist() {
|
||||||
|
val snapshot = posts.toList()
|
||||||
|
_flow.value = snapshot
|
||||||
|
storageFile.parentFile?.mkdirs()
|
||||||
|
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
|
||||||
|
try {
|
||||||
|
mapper.writeValue(tmp, ScheduledPostFile(version = 1, posts = snapshot))
|
||||||
|
if (!tmp.renameTo(storageFile)) {
|
||||||
|
if (!storageFile.delete()) {
|
||||||
|
Log.w(TAG) { "Failed to delete existing $storageFile before rename retry" }
|
||||||
|
}
|
||||||
|
if (!tmp.renameTo(storageFile)) {
|
||||||
|
Log.e(TAG, "Failed to rename $tmp to $storageFile")
|
||||||
|
if (!tmp.delete()) {
|
||||||
|
Log.w(TAG) { "Failed to clean up temp file $tmp after rename failure" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to persist scheduled posts to $storageFile", e)
|
||||||
|
if (!tmp.delete()) {
|
||||||
|
Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ScheduledPostStore"
|
||||||
|
const val FILE_NAME = "scheduled_posts.json"
|
||||||
|
private const val SENT_RETENTION_SEC = 7L * 24 * 3600
|
||||||
|
private const val CANCELLED_RETENTION_SEC = 30L * 24 * 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
/*
|
||||||
|
* 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.scheduledposts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans the scheduled-post store and publishes posts whose publish time has arrived.
|
||||||
|
*
|
||||||
|
* - schedule(context): periodic, every 15 min (WorkManager minimum).
|
||||||
|
* - scheduleCatchUp(context): one-time, on app start, to flush posts that came
|
||||||
|
* due while the device was off or while WorkManager
|
||||||
|
* was deferred by Doze.
|
||||||
|
*/
|
||||||
|
class ScheduledPostWorker(
|
||||||
|
appContext: Context,
|
||||||
|
workerParams: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
init {
|
||||||
|
// Logs every worker instantiation. Without this, "doWork never ran" looks
|
||||||
|
// identical to "constructor never invoked" — and the latter means the OS
|
||||||
|
// (Doze, battery-opt, JobScheduler quotas) never woke us at all.
|
||||||
|
Log.d(TAG) { "Worker instantiated (runAttempt=${workerParams.runAttemptCount}, tags=${workerParams.tags})" }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ScheduledPostWorker"
|
||||||
|
private const val WORK_NAME = "scheduled_post_worker"
|
||||||
|
private const val WORK_NAME_CATCH_UP = "scheduled_post_worker_catch_up"
|
||||||
|
private const val OK_TIMEOUT_SEC = 30L
|
||||||
|
private const val OK_POLL_MS = 500L
|
||||||
|
|
||||||
|
fun schedule(context: Context) {
|
||||||
|
val constraints =
|
||||||
|
Constraints
|
||||||
|
.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request =
|
||||||
|
PeriodicWorkRequestBuilder<ScheduledPostWorker>(15, TimeUnit.MINUTES)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
Log.d(TAG) {
|
||||||
|
"schedule(): enqueueUniquePeriodicWork($WORK_NAME, 15 MIN, KEEP) — KEEP policy preserves any existing schedule"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scheduleCatchUp(context: Context) {
|
||||||
|
val constraints =
|
||||||
|
Constraints
|
||||||
|
.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request =
|
||||||
|
OneTimeWorkRequestBuilder<ScheduledPostWorker>()
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
WORK_NAME_CATCH_UP,
|
||||||
|
ExistingWorkPolicy.KEEP,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
Log.d(TAG) { "scheduleCatchUp(): enqueueUniqueWork($WORK_NAME_CATCH_UP, KEEP)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_CATCH_UP)
|
||||||
|
Log.d(TAG) { "cancel(): cancelled both periodic and catch-up workers" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val nowSec = System.currentTimeMillis() / 1000
|
||||||
|
Log.d(TAG) { "doWork() ENTER nowSec=$nowSec runAttempt=$runAttemptCount tags=$tags" }
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val appModules = Amethyst.instance
|
||||||
|
val store = appModules.scheduledPostStore
|
||||||
|
|
||||||
|
val all = store.list()
|
||||||
|
val pending = all.count { it.status == ScheduledPostStatus.PENDING }
|
||||||
|
Log.d(TAG) { "doWork() store has ${all.size} total, $pending PENDING" }
|
||||||
|
|
||||||
|
val claimed = store.claimDuePosts(nowSec)
|
||||||
|
if (claimed.isEmpty()) {
|
||||||
|
Log.d(TAG) { "doWork() EXIT no posts due" }
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG) { "doWork() claimed ${claimed.size} due post(s)" }
|
||||||
|
|
||||||
|
for (post in claimed) {
|
||||||
|
val ageSec = nowSec - post.publishAtSec
|
||||||
|
Log.d(TAG) {
|
||||||
|
"Publishing post id=${post.id} publishAtSec=${post.publishAtSec} ageSec=$ageSec relays=${post.relayUrls.size}"
|
||||||
|
}
|
||||||
|
val account = appModules.accountsCache.accounts.value[post.accountPubkey]
|
||||||
|
if (account == null) {
|
||||||
|
Log.w(TAG, "Account ${post.accountPubkey} not loaded; releasing ${post.id} for retry")
|
||||||
|
store.releaseClaim(post.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val event = Event.fromJson(post.signedEventJson)
|
||||||
|
val relays = post.relayUrls.map { NormalizedRelayUrl(it) }.toSet()
|
||||||
|
val extras = post.extraEventsJson.map { Event.fromJson(it) }
|
||||||
|
|
||||||
|
Log.d(TAG) { "client.publish(${post.id}) starting on ${relays.size} relay(s)" }
|
||||||
|
account.client.publish(event, relays)
|
||||||
|
account.consumePostEvent(event, relays, extras)
|
||||||
|
|
||||||
|
val acks = waitForOk(account.client, event.id, relays.size)
|
||||||
|
if (acks > 0) {
|
||||||
|
store.markSent(post.id)
|
||||||
|
ScheduledPostNotifier.notifySent(applicationContext, post)
|
||||||
|
Log.d(TAG) { "client.publish(${post.id}) acked by $acks/${relays.size}; marked SENT" }
|
||||||
|
} else {
|
||||||
|
val msg = "no relay acknowledged within ${OK_TIMEOUT_SEC}s"
|
||||||
|
store.markFailed(post.id, msg)
|
||||||
|
ScheduledPostNotifier.notifyFailed(applicationContext, post, msg)
|
||||||
|
Log.w(TAG, "client.publish(${post.id}) failed: $msg")
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to publish scheduled post ${post.id}", e)
|
||||||
|
store.markFailed(post.id, e.message)
|
||||||
|
ScheduledPostNotifier.notifyFailed(applicationContext, post, e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG) { "doWork() EXIT success" }
|
||||||
|
Result.success()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "doWork() unexpected failure", e)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls [INostrClient.pendingPublishRelaysFor] until at least one relay sends
|
||||||
|
* an OK ack or [OK_TIMEOUT_SEC] elapses. Returns the number of relays that
|
||||||
|
* acked. The publish call itself is fire-and-forget; without this wait the
|
||||||
|
* worker can be torn down before the websocket finishes delivery.
|
||||||
|
*/
|
||||||
|
private suspend fun waitForOk(
|
||||||
|
client: INostrClient,
|
||||||
|
eventId: String,
|
||||||
|
totalRelays: Int,
|
||||||
|
): Int {
|
||||||
|
val deadline = System.currentTimeMillis() + OK_TIMEOUT_SEC * 1000
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
// null means the outbox dropped the entry — every relay either OK'd
|
||||||
|
// or hit the discard cap (replaced/pow/deleted/invalid). Treat as full ack.
|
||||||
|
val pending = client.pendingPublishRelaysFor(eventId) ?: return totalRelays
|
||||||
|
val acked = totalRelays - pending.size
|
||||||
|
if (acked > 0) return acked
|
||||||
|
delay(OK_POLL_MS)
|
||||||
|
}
|
||||||
|
val pending = client.pendingPublishRelaysFor(eventId) ?: return totalRelays
|
||||||
|
return totalRelays - pending.size
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.PluralsRes
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
@@ -131,6 +132,15 @@ fun stringRes(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plural resolver for non-composable scope (e.g. onClick callbacks). Not cached:
|
||||||
|
// the resolved string varies by `count` quantity and the resourceCache is keyed by id.
|
||||||
|
fun pluralStringRes(
|
||||||
|
ctx: Context,
|
||||||
|
@PluralsRes id: Int,
|
||||||
|
count: Int,
|
||||||
|
vararg formatArgs: Any?,
|
||||||
|
): String = ctx.resources.getQuantityString(id, count, *formatArgs)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This cache can only be used if the painter is the only copy on the screen
|
* This cache can only be used if the painter is the only copy on the screen
|
||||||
* It should store a separate Painter for each size. It's safe to just assume
|
* It should store a separate Painter for each size. It's safe to just assume
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ fun SwipeToDeleteContainer(
|
|||||||
fun SwipeToDeleteWithConfirmation(
|
fun SwipeToDeleteWithConfirmation(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
|
confirmLabelRes: Int = R.string.request_deletion,
|
||||||
content: @Composable (RowScope.() -> Unit),
|
content: @Composable (RowScope.() -> Unit),
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -103,6 +104,7 @@ fun SwipeToDeleteWithConfirmation(
|
|||||||
onCancel = {
|
onCancel = {
|
||||||
scope.launch { dismissState.reset() }
|
scope.launch { dismissState.reset() }
|
||||||
},
|
},
|
||||||
|
confirmLabelRes = confirmLabelRes,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
enableDismissFromEndToStart = true,
|
enableDismissFromEndToStart = true,
|
||||||
@@ -156,6 +158,7 @@ fun ConfirmDeleteBackground(
|
|||||||
dismissState: SwipeToDismissBoxState,
|
dismissState: SwipeToDismissBoxState,
|
||||||
onConfirmDelete: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
|
confirmLabelRes: Int = R.string.request_deletion,
|
||||||
) {
|
) {
|
||||||
val settled = dismissState.currentValue == Settled && dismissState.targetValue == Settled
|
val settled = dismissState.currentValue == Settled && dismissState.targetValue == Settled
|
||||||
|
|
||||||
@@ -163,7 +166,7 @@ fun ConfirmDeleteBackground(
|
|||||||
if (!settled) {
|
if (!settled) {
|
||||||
Color(0xFFFF1744)
|
Color(0xFFFF1744)
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
Color.Transparent
|
||||||
},
|
},
|
||||||
label = "ConfirmDeleteBackground",
|
label = "ConfirmDeleteBackground",
|
||||||
)
|
)
|
||||||
@@ -195,12 +198,12 @@ fun ConfirmDeleteBackground(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
MaterialSymbols.Delete,
|
MaterialSymbols.Delete,
|
||||||
contentDescription = stringRes(id = R.string.request_deletion),
|
contentDescription = stringRes(id = confirmLabelRes),
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
|
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringRes(id = R.string.request_deletion),
|
text = stringRes(id = confirmLabelRes),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip43.RelayMembersSc
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen
|
||||||
|
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.search.SearchScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
|
||||||
@@ -304,6 +305,7 @@ fun BuildNavigation(
|
|||||||
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
|
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
|
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.ScheduledPosts> { ScheduledPostsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
|
||||||
|
|||||||
+9
@@ -44,6 +44,7 @@ enum class NavBarItem {
|
|||||||
BOOKMARKS,
|
BOOKMARKS,
|
||||||
WEB_BOOKMARKS,
|
WEB_BOOKMARKS,
|
||||||
DRAFTS,
|
DRAFTS,
|
||||||
|
SCHEDULED_POSTS,
|
||||||
INTEREST_SETS,
|
INTEREST_SETS,
|
||||||
EMOJI_PACKS,
|
EMOJI_PACKS,
|
||||||
WALLET,
|
WALLET,
|
||||||
@@ -142,6 +143,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
|||||||
icon = MaterialSymbols.Drafts,
|
icon = MaterialSymbols.Drafts,
|
||||||
resolveRoute = { Route.Drafts },
|
resolveRoute = { Route.Drafts },
|
||||||
),
|
),
|
||||||
|
NavBarItem.SCHEDULED_POSTS to
|
||||||
|
NavBarItemDef(
|
||||||
|
id = NavBarItem.SCHEDULED_POSTS,
|
||||||
|
labelRes = R.string.scheduled_posts,
|
||||||
|
icon = MaterialSymbols.Schedule,
|
||||||
|
resolveRoute = { Route.ScheduledPosts },
|
||||||
|
),
|
||||||
NavBarItem.INTEREST_SETS to
|
NavBarItem.INTEREST_SETS to
|
||||||
NavBarItemDef(
|
NavBarItemDef(
|
||||||
id = NavBarItem.INTEREST_SETS,
|
id = NavBarItem.INTEREST_SETS,
|
||||||
@@ -291,6 +299,7 @@ val DrawerYouItems: List<NavBarItem> =
|
|||||||
NavBarItem.BOOKMARKS,
|
NavBarItem.BOOKMARKS,
|
||||||
NavBarItem.WEB_BOOKMARKS,
|
NavBarItem.WEB_BOOKMARKS,
|
||||||
NavBarItem.DRAFTS,
|
NavBarItem.DRAFTS,
|
||||||
|
NavBarItem.SCHEDULED_POSTS,
|
||||||
NavBarItem.INTEREST_SETS,
|
NavBarItem.INTEREST_SETS,
|
||||||
NavBarItem.EMOJI_PACKS,
|
NavBarItem.EMOJI_PACKS,
|
||||||
NavBarItem.WALLET,
|
NavBarItem.WALLET,
|
||||||
|
|||||||
+60
-1
@@ -46,11 +46,14 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.compose.collectAsStateWithLifecycle
|
||||||
import com.vitorpamplona.amethyst.AccountInfo
|
import com.vitorpamplona.amethyst.AccountInfo
|
||||||
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
import com.vitorpamplona.amethyst.LocalPreferences
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
@@ -58,9 +61,11 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
|||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||||
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
|
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
|
||||||
|
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
|
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
|
||||||
@@ -261,16 +266,70 @@ private fun LogoutButton(
|
|||||||
accountSessionManager: AccountSessionManager,
|
accountSessionManager: AccountSessionManager,
|
||||||
) {
|
) {
|
||||||
var logoutDialog by remember { mutableStateOf(false) }
|
var logoutDialog by remember { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
if (logoutDialog) {
|
if (logoutDialog) {
|
||||||
|
val accountHex = remember(acc) { decodePublicKeyAsHexOrNull(acc.npub) }
|
||||||
|
val allPosts by Amethyst.instance.scheduledPostStore.flow
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
val unpublishedCount by remember(accountHex) {
|
||||||
|
derivedStateOf {
|
||||||
|
if (accountHex == null) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
allPosts.count {
|
||||||
|
it.accountPubkey == accountHex &&
|
||||||
|
(
|
||||||
|
it.status == ScheduledPostStatus.PENDING ||
|
||||||
|
it.status == ScheduledPostStatus.PUBLISHING ||
|
||||||
|
it.status == ScheduledPostStatus.FAILED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
title = { Text(text = stringRes(R.string.log_out)) },
|
title = { Text(text = stringRes(R.string.log_out)) },
|
||||||
text = { Text(text = stringRes(R.string.are_you_sure_you_want_to_log_out)) },
|
text = {
|
||||||
|
if (unpublishedCount > 0) {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
pluralStringResource(
|
||||||
|
id = R.plurals.scheduled_posts_logout_warning,
|
||||||
|
count = unpublishedCount,
|
||||||
|
unpublishedCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(text = stringRes(R.string.are_you_sure_you_want_to_log_out))
|
||||||
|
}
|
||||||
|
},
|
||||||
onDismissRequest = { logoutDialog = false },
|
onDismissRequest = { logoutDialog = false },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
// Snapshot the count *now* so the user-facing Toast matches what
|
||||||
|
// the dialog displayed, even if the store mutates between this
|
||||||
|
// tap and the cleanup completing.
|
||||||
|
val confirmedCount = unpublishedCount
|
||||||
logoutDialog = false
|
logoutDialog = false
|
||||||
|
// Guard against a malformed npub: skip the Toast so we don't
|
||||||
|
// claim "Logged out" when logOff's coroutine bails early.
|
||||||
|
if (accountHex == null) return@TextButton
|
||||||
accountSessionManager.logOff(acc)
|
accountSessionManager.logOff(acc)
|
||||||
|
val toastMessage =
|
||||||
|
if (confirmedCount > 0) {
|
||||||
|
pluralStringRes(
|
||||||
|
context,
|
||||||
|
R.plurals.scheduled_posts_logout_toast,
|
||||||
|
confirmedCount,
|
||||||
|
confirmedCount,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stringRes(context, R.string.scheduled_posts_logout_toast_zero)
|
||||||
|
}
|
||||||
|
android.widget.Toast
|
||||||
|
.makeText(context, toastMessage, android.widget.Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(text = stringRes(R.string.log_out))
|
Text(text = stringRes(R.string.log_out))
|
||||||
|
|||||||
+77
@@ -49,6 +49,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -84,6 +85,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
import com.vitorpamplona.amethyst.BuildConfig
|
import com.vitorpamplona.amethyst.BuildConfig
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
@@ -97,6 +99,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
|||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount
|
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
|
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
|
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
|
||||||
@@ -604,11 +607,85 @@ fun CatalogSection(
|
|||||||
ids.forEach { id ->
|
ids.forEach { id ->
|
||||||
NavBarCatalog[id]?.let { def ->
|
NavBarCatalog[id]?.let { def ->
|
||||||
val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground
|
val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground
|
||||||
|
if (def.id == NavBarItem.SCHEDULED_POSTS) {
|
||||||
|
ScheduledPostsNavigationRow(def, tint, accountViewModel, nav)
|
||||||
|
} else {
|
||||||
CatalogNavigationRow(def, tint, accountViewModel, nav)
|
CatalogNavigationRow(def, tint, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScheduledPostsNavigationRow(
|
||||||
|
def: NavBarItemDef,
|
||||||
|
tint: Color,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val accountHex = accountViewModel.account.signer.pubKey
|
||||||
|
val allPosts by Amethyst.instance.scheduledPostStore.flow
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
val pendingCount by remember(accountHex) {
|
||||||
|
derivedStateOf {
|
||||||
|
allPosts.count {
|
||||||
|
it.accountPubkey == accountHex &&
|
||||||
|
(
|
||||||
|
it.status == ScheduledPostStatus.PENDING ||
|
||||||
|
it.status == ScheduledPostStatus.PUBLISHING ||
|
||||||
|
it.status == ScheduledPostStatus.FAILED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconRowWithBadge(
|
||||||
|
title = def.labelRes,
|
||||||
|
icon = def.icon,
|
||||||
|
tint = tint,
|
||||||
|
badgeCount = pendingCount,
|
||||||
|
onClick = {
|
||||||
|
nav.closeDrawer()
|
||||||
|
nav.nav { def.resolveRoute(accountViewModel) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IconRowWithBadge(
|
||||||
|
title: Int,
|
||||||
|
icon: MaterialSymbol,
|
||||||
|
tint: Color,
|
||||||
|
badgeCount: Int,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val titleStr = stringRes(title)
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onClickLabel = titleStr,
|
||||||
|
).padding(vertical = 15.dp, horizontal = 25.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
symbol = icon,
|
||||||
|
contentDescription = titleStr,
|
||||||
|
modifier = Size22ModifierWith4Padding,
|
||||||
|
tint = tint,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = IconRowTextModifier,
|
||||||
|
text = titleStr,
|
||||||
|
fontSize = Font18SP,
|
||||||
|
)
|
||||||
|
if (badgeCount > 0) {
|
||||||
|
Badge { Text(badgeCount.toString()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CatalogNavigationRow(
|
fun CatalogNavigationRow(
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object Drafts : Route()
|
@Serializable object Drafts : Route()
|
||||||
|
|
||||||
|
@Serializable object ScheduledPosts : Route()
|
||||||
|
|
||||||
@Serializable object AllSettings : Route()
|
@Serializable object AllSettings : Route()
|
||||||
|
|
||||||
@Serializable object AccountBackup : Route()
|
@Serializable object AccountBackup : Route()
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
@@ -128,6 +129,7 @@ import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
|||||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||||
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
|
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo
|
import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo
|
||||||
@@ -346,6 +348,8 @@ fun PayReaction(
|
|||||||
val authorPubkey = baseNote.author?.pubkeyHex ?: return
|
val authorPubkey = baseNote.author?.pubkeyHex ?: return
|
||||||
val address = remember(authorPubkey) { PaymentTargetsEvent.createAddress(authorPubkey) }
|
val address = remember(authorPubkey) { PaymentTargetsEvent.createAddress(authorPubkey) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val clipboardManager = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
LoadAddressableNote(address, accountViewModel) { note ->
|
LoadAddressableNote(address, accountViewModel) { note ->
|
||||||
val targets = remember(note) { (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() }
|
val targets = remember(note) { (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() }
|
||||||
@@ -395,6 +399,16 @@ fun PayReaction(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
M3ActionRow(
|
||||||
|
icon = MaterialSymbols.ContentCopy,
|
||||||
|
text = stringRes(R.string.copy_to_clipboard),
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
scope.launch {
|
||||||
|
clipboardManager.setText(target.authority)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* 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.note.creators.scheduling
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScheduleAtButton(
|
||||||
|
isActive: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onClick) {
|
||||||
|
Icon(
|
||||||
|
symbol = MaterialSymbols.Schedule,
|
||||||
|
contentDescription =
|
||||||
|
stringRes(
|
||||||
|
if (isActive) R.string.schedule_post_button_remove else R.string.schedule_post_button_add,
|
||||||
|
),
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+319
@@ -0,0 +1,319 @@
|
|||||||
|
/*
|
||||||
|
* 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.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
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
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.material3.AssistChip
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.SelectableDates
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.TimePickerDialog
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||||
|
import com.vitorpamplona.amethyst.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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-stage date + time picker for scheduling a post for future publication.
|
||||||
|
*
|
||||||
|
* The selected time is rounded up to the next quarter-hour to set realistic
|
||||||
|
* expectations: the periodic worker that fires scheduled posts runs at
|
||||||
|
* WorkManager's minimum 15-min interval, so per-minute precision is misleading.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ScheduleAtPicker(
|
||||||
|
scheduledForSec: Long,
|
||||||
|
onChanged: (Long) -> Unit,
|
||||||
|
alwaysOnEnabled: Boolean = true,
|
||||||
|
hasMultipleAccounts: Boolean = false,
|
||||||
|
) {
|
||||||
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
var showTimePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val currentTime =
|
||||||
|
Instant
|
||||||
|
.ofEpochMilli(scheduledForSec * 1000)
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toLocalDateTime()
|
||||||
|
|
||||||
|
val datePickerState =
|
||||||
|
rememberDatePickerState(
|
||||||
|
initialSelectedDateMillis = scheduledForSec * 1000,
|
||||||
|
yearRange = currentTime.year..2050,
|
||||||
|
selectableDates =
|
||||||
|
object : SelectableDates {
|
||||||
|
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= System.currentTimeMillis() - 86_400_000
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val timePickerState =
|
||||||
|
rememberTimePickerState(
|
||||||
|
initialHour = currentTime.hour,
|
||||||
|
initialMinute = currentTime.minute,
|
||||||
|
is24Hour = DateFormat.is24HourFormat(context),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 5.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
symbol = MaterialSymbols.Timer,
|
||||||
|
contentDescription = stringRes(R.string.schedule_post_time_label),
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.schedule_post),
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.W500,
|
||||||
|
modifier = Modifier.padding(start = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(thickness = DividerThickness)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.schedule_post_helper),
|
||||||
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
|
modifier = Modifier.padding(vertical = 10.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!alwaysOnEnabled) {
|
||||||
|
ReliabilityWarning(hasMultipleAccounts = hasMultipleAccounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
PresetChips(onPick = onChanged)
|
||||||
|
|
||||||
|
OutlinedCard(
|
||||||
|
onClick = { showDatePicker = true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(MaterialSymbols.Timer, contentDescription = stringRes(R.string.schedule_post_pick_time))
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
||||||
|
if (scheduledForSec < TimeUtils.oneMinuteFromNow()) {
|
||||||
|
Text(stringRes(R.string.schedule_post_pick_label), style = MaterialTheme.typography.bodyLarge)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.schedule_post_publishes_in, timeAheadNoDot(scheduledForSec, context)),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDatePicker) {
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { showDatePicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
showDatePicker = false
|
||||||
|
showTimePicker = true
|
||||||
|
}) { Text(stringRes(R.string.next)) }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
DatePicker(state = datePickerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showTimePicker) {
|
||||||
|
TimePickerDialog(
|
||||||
|
title = { Text(stringRes(R.string.schedule_post_picker_time_title)) },
|
||||||
|
onDismissRequest = { showTimePicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val datetimeLocalTimeZone =
|
||||||
|
datePickerState.selectedDateMillis?.let { localDayAtZeroHourMillis ->
|
||||||
|
(localDayAtZeroHourMillis / 1000) +
|
||||||
|
(timePickerState.hour * TimeUtils.ONE_HOUR) +
|
||||||
|
(timePickerState.minute * TimeUtils.ONE_MINUTE)
|
||||||
|
} ?: TimeUtils.oneDayAhead()
|
||||||
|
|
||||||
|
val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now())
|
||||||
|
val rawSec = datetimeLocalTimeZone - offset.totalSeconds
|
||||||
|
|
||||||
|
onChanged(roundUpToNextQuarterHour(rawSec))
|
||||||
|
showTimePicker = false
|
||||||
|
},
|
||||||
|
) { Text(stringRes(R.string.confirm)) }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
TimePicker(state = timePickerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReliabilityWarning(hasMultipleAccounts: Boolean) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
symbol = MaterialSymbols.Timer,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.schedule_post_warning_title),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringRes(
|
||||||
|
if (hasMultipleAccounts) {
|
||||||
|
R.string.schedule_post_warning_multi
|
||||||
|
} else {
|
||||||
|
R.string.schedule_post_warning_single
|
||||||
|
},
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PresetChips(onPick: (Long) -> Unit) {
|
||||||
|
val scroll = rememberScrollState()
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(scroll)
|
||||||
|
.padding(bottom = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
AssistChip(
|
||||||
|
onClick = { onPick(roundUpToNextQuarterHour(presetInOneHour())) },
|
||||||
|
label = { Text(stringRes(R.string.schedule_post_preset_in_one_hour)) },
|
||||||
|
)
|
||||||
|
AssistChip(
|
||||||
|
onClick = { onPick(roundUpToNextQuarterHour(presetTomorrowMorning())) },
|
||||||
|
label = { Text(stringRes(R.string.schedule_post_preset_tomorrow_morning)) },
|
||||||
|
)
|
||||||
|
AssistChip(
|
||||||
|
onClick = { onPick(roundUpToNextQuarterHour(presetNextMondayMorning())) },
|
||||||
|
label = { Text(stringRes(R.string.schedule_post_preset_next_monday_morning)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun presetInOneHour(): Long = (System.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 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounds [epochSec] up to the next 15-minute boundary. If already on a boundary,
|
||||||
|
* returns the boundary itself. Edge case: if rounding yields a moment in the past
|
||||||
|
* (rare — only if the user picks the exact current quarter-hour), bump forward
|
||||||
|
* one slot.
|
||||||
|
*/
|
||||||
|
internal fun roundUpToNextQuarterHour(epochSec: Long): Long {
|
||||||
|
val quarter = 15 * 60L
|
||||||
|
val rounded = ((epochSec + quarter - 1) / quarter) * quarter
|
||||||
|
val nowSec = System.currentTimeMillis() / 1000
|
||||||
|
return if (rounded <= nowSec) rounded + quarter else rounded
|
||||||
|
}
|
||||||
+16
-5
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen
|
|||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import com.vitorpamplona.amethyst.AccountInfo
|
import com.vitorpamplona.amethyst.AccountInfo
|
||||||
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
import com.vitorpamplona.amethyst.LocalPreferences
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
|
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
@@ -29,14 +30,14 @@ import com.vitorpamplona.amethyst.model.AccountSettings
|
|||||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
|
||||||
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
|
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
|
||||||
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||||
@@ -140,8 +141,11 @@ class AccountSessionManager(
|
|||||||
externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" },
|
externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" },
|
||||||
)
|
)
|
||||||
} else if (key.startsWith("nsec")) {
|
} else if (key.startsWith("nsec")) {
|
||||||
|
val privHex =
|
||||||
|
decodePrivateKeyAsHexOrNull(key)
|
||||||
|
?: throw Exception("Invalid nsec key")
|
||||||
AccountSettings(
|
AccountSettings(
|
||||||
keyPair = KeyPair(privKey = key.bechToBytes()),
|
keyPair = KeyPair(privKey = privHex.hexToByteArray()),
|
||||||
transientAccount = transientAccount,
|
transientAccount = transientAccount,
|
||||||
)
|
)
|
||||||
} else if (key.contains(" ") && Nip06().isValidMnemonic(key)) {
|
} else if (key.contains(" ") && Nip06().isValidMnemonic(key)) {
|
||||||
@@ -356,6 +360,11 @@ class AccountSessionManager(
|
|||||||
|
|
||||||
fun logOff(accountInfo: AccountInfo) {
|
fun logOff(accountInfo: AccountInfo) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val hex = decodePublicKeyAsHexOrNull(accountInfo.npub)
|
||||||
|
if (hex == null) {
|
||||||
|
Log.e("Logoff", "Cannot decode npub for account being logged off; aborting cleanup")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
if (accountInfo.npub == currentAccountNPub()) {
|
if (accountInfo.npub == currentAccountNPub()) {
|
||||||
// Drop the Nest bridge ref before tearing down the
|
// Drop the Nest bridge ref before tearing down the
|
||||||
// current account so the audio-room activity can't
|
// current account so the audio-room activity can't
|
||||||
@@ -364,12 +373,14 @@ class AccountSessionManager(
|
|||||||
.clear()
|
.clear()
|
||||||
// log off and relogin with the 0 account
|
// log off and relogin with the 0 account
|
||||||
localPreferences.deleteAccount(accountInfo)
|
localPreferences.deleteAccount(accountInfo)
|
||||||
accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey())
|
accountsCache.removeAccount(hex)
|
||||||
|
Amethyst.instance.scheduledPostStore.removeForAccount(hex)
|
||||||
loginWithDefaultAccount()
|
loginWithDefaultAccount()
|
||||||
} else {
|
} else {
|
||||||
// delete without switching logins
|
// delete without switching logins
|
||||||
localPreferences.deleteAccount(accountInfo)
|
localPreferences.deleteAccount(accountInfo)
|
||||||
accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey())
|
accountsCache.removeAccount(hex)
|
||||||
|
Amethyst.instance.scheduledPostStore.removeForAccount(hex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -1152,6 +1152,8 @@ class AccountViewModel(
|
|||||||
|
|
||||||
fun updateWarnReports(warnReports: Boolean) = launchSigner { account.updateWarnReports(warnReports) }
|
fun updateWarnReports(warnReports: Boolean) = launchSigner { account.updateWarnReports(warnReports) }
|
||||||
|
|
||||||
|
fun updateDisableClientTag(disable: Boolean) = launchSigner { account.updateDisableClientTag(disable) }
|
||||||
|
|
||||||
fun updateFilterSpam(filterSpam: Boolean) =
|
fun updateFilterSpam(filterSpam: Boolean) =
|
||||||
launchSigner {
|
launchSigner {
|
||||||
if (account.updateFilterSpam(filterSpam)) {
|
if (account.updateFilterSpam(filterSpam)) {
|
||||||
|
|||||||
+1
@@ -114,6 +114,7 @@ private fun PreloadFor(
|
|||||||
NavBarItem.BOOKMARKS,
|
NavBarItem.BOOKMARKS,
|
||||||
NavBarItem.WEB_BOOKMARKS,
|
NavBarItem.WEB_BOOKMARKS,
|
||||||
NavBarItem.DRAFTS,
|
NavBarItem.DRAFTS,
|
||||||
|
NavBarItem.SCHEDULED_POSTS,
|
||||||
NavBarItem.INTEREST_SETS,
|
NavBarItem.INTEREST_SETS,
|
||||||
NavBarItem.EMOJI_PACKS,
|
NavBarItem.EMOJI_PACKS,
|
||||||
NavBarItem.WALLET,
|
NavBarItem.WALLET,
|
||||||
|
|||||||
+86
-2
@@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -48,11 +49,16 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
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.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -61,6 +67,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.content.IntentCompat
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.core.util.Consumer
|
import androidx.core.util.Consumer
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
@@ -79,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
|
|||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
|
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
|
||||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||||
@@ -97,6 +105,9 @@ import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
|
|||||||
import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying
|
import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField
|
import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews
|
import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtButton
|
||||||
|
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.secretEmoji.AddSecretEmojiButton
|
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest
|
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
|
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
|
||||||
@@ -404,6 +415,26 @@ 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
|
||||||
|
postViewModel.scheduledForSec?.let { current ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = CenterVertically,
|
||||||
|
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
|
||||||
|
) {
|
||||||
|
ScheduleAtPicker(
|
||||||
|
scheduledForSec = current,
|
||||||
|
onChanged = { postViewModel.scheduledForSec = it },
|
||||||
|
alwaysOnEnabled = alwaysOnEnabled,
|
||||||
|
hasMultipleAccounts = hasMultipleAccounts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (postViewModel.wantsToAddGeoHash) {
|
if (postViewModel.wantsToAddGeoHash) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = CenterVertically,
|
verticalAlignment = CenterVertically,
|
||||||
@@ -574,12 +605,63 @@ private fun NewPostScreenBody(
|
|||||||
onDismiss = postViewModel::dismissAiResult,
|
onDismiss = postViewModel::dismissAiResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
BottomRowActions(postViewModel)
|
val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
var showAlwaysOnPrompt by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
BottomRowActions(
|
||||||
|
postViewModel = postViewModel,
|
||||||
|
onScheduleClicked = {
|
||||||
|
if (postViewModel.scheduledForSec != null) {
|
||||||
|
postViewModel.scheduledForSec = null
|
||||||
|
} else if (!alwaysOnEnabled) {
|
||||||
|
showAlwaysOnPrompt = true
|
||||||
|
} else {
|
||||||
|
postViewModel.scheduledForSec =
|
||||||
|
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showAlwaysOnPrompt) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showAlwaysOnPrompt = false },
|
||||||
|
title = { Text(stringRes(R.string.schedule_post_always_on_prompt_title)) },
|
||||||
|
text = { Text(stringRes(R.string.schedule_post_always_on_prompt_message)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
showAlwaysOnPrompt = false
|
||||||
|
nav.nav(Route.Settings)
|
||||||
|
}) {
|
||||||
|
Text(stringRes(R.string.schedule_post_always_on_prompt_open_settings))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
showAlwaysOnPrompt = false
|
||||||
|
postViewModel.scheduledForSec =
|
||||||
|
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
|
||||||
|
}) {
|
||||||
|
Text(stringRes(R.string.schedule_post_always_on_prompt_continue))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
|
private fun BottomRowActions(
|
||||||
|
postViewModel: ShortNotePostViewModel,
|
||||||
|
onScheduleClicked: () -> Unit = {
|
||||||
|
postViewModel.scheduledForSec =
|
||||||
|
if (postViewModel.scheduledForSec != null) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -656,6 +738,8 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
|
|||||||
postViewModel.toggleExpirationDate()
|
postViewModel.toggleExpirationDate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked)
|
||||||
|
|
||||||
AddGeoHashButton(postViewModel.wantsToAddGeoHash) {
|
AddGeoHashButton(postViewModel.wantsToAddGeoHash) {
|
||||||
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
|
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
|
||||||
}
|
}
|
||||||
|
|||||||
+39
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.service.ai.WritingAssistantStatus
|
|||||||
import com.vitorpamplona.amethyst.service.ai.WritingResult
|
import com.vitorpamplona.amethyst.service.ai.WritingResult
|
||||||
import com.vitorpamplona.amethyst.service.ai.WritingTone
|
import com.vitorpamplona.amethyst.service.ai.WritingTone
|
||||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
|
||||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||||
@@ -305,6 +306,10 @@ open class ShortNotePostViewModel :
|
|||||||
// Anonymous Reply
|
// Anonymous Reply
|
||||||
var wantsAnonymousPost by mutableStateOf(false)
|
var wantsAnonymousPost by mutableStateOf(false)
|
||||||
|
|
||||||
|
// Scheduled posting: epoch seconds (UTC) when the post should be published.
|
||||||
|
// Null = post immediately on Send (existing behavior).
|
||||||
|
var scheduledForSec by mutableStateOf<Long?>(null)
|
||||||
|
|
||||||
// AI Writing Help for testing
|
// AI Writing Help for testing
|
||||||
private val useMockAi = false
|
private val useMockAi = false
|
||||||
|
|
||||||
@@ -829,8 +834,41 @@ open class ShortNotePostViewModel :
|
|||||||
|
|
||||||
val version = draftTag.current
|
val version = draftTag.current
|
||||||
val anonymous = wantsAnonymousPost
|
val anonymous = wantsAnonymousPost
|
||||||
|
val scheduledFor = scheduledForSec
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
|
if (scheduledFor != null && !anonymous) {
|
||||||
|
// Re-stamp the template with created_at = scheduled time so the post,
|
||||||
|
// when published later, shows up at its scheduled moment in feeds
|
||||||
|
// rather than as N minutes/hours old (= compose time).
|
||||||
|
val rescheduledTemplate =
|
||||||
|
EventTemplate<Event>(
|
||||||
|
createdAt = scheduledFor,
|
||||||
|
kind = template.kind,
|
||||||
|
tags = template.tags,
|
||||||
|
content = template.content,
|
||||||
|
)
|
||||||
|
val (event, relays, extras) = accountViewModel.account.createPostEvent(rescheduledTemplate, extraNotesToBroadcast)
|
||||||
|
Amethyst.instance.scheduledPostStore.add(
|
||||||
|
ScheduledPost(
|
||||||
|
id =
|
||||||
|
java.util.UUID
|
||||||
|
.randomUUID()
|
||||||
|
.toString(),
|
||||||
|
accountPubkey = event.pubKey,
|
||||||
|
signedEventJson = event.toJson(),
|
||||||
|
relayUrls = relays.map { it.url },
|
||||||
|
extraEventsJson = extras.map { it.toJson() },
|
||||||
|
publishAtSec = scheduledFor,
|
||||||
|
createdAtSec = System.currentTimeMillis() / 1000,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
accountViewModel.launchSigner {
|
||||||
|
accountViewModel.account.deleteDraftIgnoreErrors(version)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (anonymous) {
|
if (anonymous) {
|
||||||
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
|
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
|
||||||
} else if (accountViewModel.settings.useTrackedBroadcasts()) {
|
} else if (accountViewModel.settings.useTrackedBroadcasts()) {
|
||||||
@@ -1197,6 +1235,7 @@ open class ShortNotePostViewModel :
|
|||||||
wantsExclusiveGeoPost = false
|
wantsExclusiveGeoPost = false
|
||||||
wantsSecretEmoji = false
|
wantsSecretEmoji = false
|
||||||
wantsAnonymousPost = false
|
wantsAnonymousPost = false
|
||||||
|
scheduledForSec = null
|
||||||
|
|
||||||
forwardZapTo.value = SplitBuilder()
|
forwardZapTo.value = SplitBuilder()
|
||||||
forwardZapToEditting.value = TextFieldValue("")
|
forwardZapToEditting.value = TextFieldValue("")
|
||||||
|
|||||||
+1
-1
@@ -329,7 +329,7 @@ private fun OnStageIdleControls(
|
|||||||
TalkButton(
|
TalkButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (context.hasMicPermission()) {
|
if (context.hasMicPermission()) {
|
||||||
viewModel.startBroadcast(speakerPubkeyHex, initialMuted = true)
|
viewModel.startBroadcast(speakerPubkeyHex, initialMuted = false)
|
||||||
} else {
|
} else {
|
||||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -28,8 +28,10 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
@@ -40,6 +42,7 @@ import com.vitorpamplona.amethyst.model.User
|
|||||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||||
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
|
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
|
||||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
@@ -47,6 +50,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
|||||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PaymentButton(
|
fun PaymentButton(
|
||||||
@@ -72,6 +76,8 @@ fun PaymentButton(
|
|||||||
@Composable
|
@Composable
|
||||||
fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
|
fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val clipboardManager = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
@@ -111,6 +117,16 @@ fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
M3ActionRow(
|
||||||
|
icon = MaterialSymbols.ContentCopy,
|
||||||
|
text = stringRes(R.string.copy_to_clipboard),
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
scope.launch {
|
||||||
|
clipboardManager.setText(target.authority)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@file:Suppress("ktlint:standard:filename")
|
||||||
|
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
|
private const val TAG = "ScheduledPostMedia"
|
||||||
|
|
||||||
|
sealed class MediaUrl {
|
||||||
|
abstract val url: String
|
||||||
|
|
||||||
|
data class Image(
|
||||||
|
override val url: String,
|
||||||
|
) : MediaUrl()
|
||||||
|
|
||||||
|
data class Video(
|
||||||
|
override val url: String,
|
||||||
|
) : MediaUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the signed-event JSON, run [block], and return its result. Returns null on
|
||||||
|
* any non-cancellation parse failure and logs a warning. The shared shape for
|
||||||
|
* [extractFirstMediaUrl], [extractEventId], and [extractContentPreview].
|
||||||
|
*/
|
||||||
|
private inline fun <T : Any> parseSignedEvent(
|
||||||
|
post: ScheduledPost,
|
||||||
|
caller: String,
|
||||||
|
block: (Event) -> T?,
|
||||||
|
): T? =
|
||||||
|
try {
|
||||||
|
block(Event.fromJson(post.signedEventJson))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG) { "$caller: failed to parse signed event for ${post.id}: ${e.message}" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first media URL referenced via an imeta tag in the post's signed event,
|
||||||
|
* or null when there is no imeta tag or the JSON cannot be parsed.
|
||||||
|
*
|
||||||
|
* Mime starting with `video/` -> [MediaUrl.Video]; anything else (including absent mime)
|
||||||
|
* -> [MediaUrl.Image]. Lenient on purpose: most posts attach images and don't always
|
||||||
|
* carry an `m` property.
|
||||||
|
*/
|
||||||
|
fun extractFirstMediaUrl(post: ScheduledPost): MediaUrl? =
|
||||||
|
parseSignedEvent(post, "extractFirstMediaUrl") { event ->
|
||||||
|
val firstImeta = event.imetas().firstOrNull() ?: return@parseSignedEvent null
|
||||||
|
val mime = firstImeta.properties["m"]?.firstOrNull().orEmpty()
|
||||||
|
when {
|
||||||
|
mime.startsWith("video/") -> MediaUrl.Video(firstImeta.url)
|
||||||
|
else -> MediaUrl.Image(firstImeta.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the signed event's id, or null when the JSON cannot be parsed.
|
||||||
|
*/
|
||||||
|
fun extractEventId(post: ScheduledPost): String? = parseSignedEvent(post, "extractEventId") { it.id }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first [maxLen] chars of the signed event's content (trimmed) or
|
||||||
|
* an empty string when the JSON cannot be parsed.
|
||||||
|
*/
|
||||||
|
fun extractContentPreview(
|
||||||
|
post: ScheduledPost,
|
||||||
|
maxLen: Int,
|
||||||
|
): String =
|
||||||
|
parseSignedEvent(post, "extractContentPreview") { event ->
|
||||||
|
event.content.take(maxLen).trim()
|
||||||
|
} ?: ""
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MediaThumbnail(media: MediaUrl) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = media.url,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
)
|
||||||
|
if (media is MediaUrl.Video) {
|
||||||
|
Icon(
|
||||||
|
symbol = MaterialSymbols.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+639
@@ -0,0 +1,639 @@
|
|||||||
|
/*
|
||||||
|
* 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.scheduledposts
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.compositeOver
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
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.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ScheduledPostsScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val accountPubkey = accountViewModel.account.signer.pubKey
|
||||||
|
val viewModel: ScheduledPostsViewModel =
|
||||||
|
viewModel(key = "scheduled-posts-$accountPubkey") {
|
||||||
|
ScheduledPostsViewModel.create(accountPubkey)
|
||||||
|
}
|
||||||
|
val groups by viewModel.groupedPosts.collectAsStateWithLifecycle()
|
||||||
|
val totalActive by viewModel.totalActive.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
var expandedId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// Tick once per minute so relative-time strings ("publishes in 2h 13m")
|
||||||
|
// refresh on a long-open list instead of being frozen at first composition.
|
||||||
|
val nowSec by produceState(initialValue = System.currentTimeMillis() / 1000) {
|
||||||
|
while (true) {
|
||||||
|
delay(60_000)
|
||||||
|
value = System.currentTimeMillis() / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val dueSoonCount by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
groups
|
||||||
|
.flatMap { it.posts }
|
||||||
|
.count { it.publishAtSec - nowSec in 1..URGENT_THRESHOLD_SEC }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val barHeight = if (totalActive > 0) 64.dp else TopBarSize
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
ShorterTopAppBar(
|
||||||
|
expandedHeight = barHeight,
|
||||||
|
title = {
|
||||||
|
Column(modifier = Modifier.semantics(mergeDescendants = true) {}) {
|
||||||
|
Text(stringRes(R.string.scheduled_posts))
|
||||||
|
if (totalActive > 0) {
|
||||||
|
val queuedText =
|
||||||
|
pluralStringResource(
|
||||||
|
id = R.plurals.scheduled_posts_subtitle_queued,
|
||||||
|
count = totalActive,
|
||||||
|
totalActive,
|
||||||
|
)
|
||||||
|
val dueText =
|
||||||
|
if (dueSoonCount > 0) {
|
||||||
|
pluralStringResource(
|
||||||
|
id = R.plurals.scheduled_posts_subtitle_due_suffix,
|
||||||
|
count = dueSoonCount,
|
||||||
|
dueSoonCount,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = queuedText + dueText,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (groups.isEmpty()) {
|
||||||
|
EmptyState(onCompose = { nav.nav(Route.NewShortNote()) }, modifier = Modifier.padding(padding))
|
||||||
|
} else {
|
||||||
|
val today = remember(nowSec) { LocalDate.now(ZoneId.systemDefault()) }
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
groups.forEach { group ->
|
||||||
|
stickyHeader(key = group.day) {
|
||||||
|
DayHeader(group.day, today, context)
|
||||||
|
}
|
||||||
|
items(group.posts, key = { it.id }) { post ->
|
||||||
|
val isExpanded = expandedId == post.id
|
||||||
|
val rowAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (expandedId != null && !isExpanded) 0.65f else 1f,
|
||||||
|
label = "row-alpha",
|
||||||
|
)
|
||||||
|
Box(modifier = Modifier.alpha(rowAlpha)) {
|
||||||
|
if (isExpanded) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.animateContentSize(),
|
||||||
|
) {
|
||||||
|
ScheduledPostCardCollapsed(
|
||||||
|
post = post,
|
||||||
|
nowSec = nowSec,
|
||||||
|
onClick = { expandedId = null },
|
||||||
|
)
|
||||||
|
ScheduledPostCardExpandedPanel(
|
||||||
|
post = post,
|
||||||
|
onPublishNow = {
|
||||||
|
viewModel.publishNow(post.id)
|
||||||
|
ScheduledPostWorker.scheduleCatchUp(context)
|
||||||
|
expandedId = null
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
viewModel.cancel(post.id)
|
||||||
|
expandedId = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SwipeToDeleteWithConfirmation(
|
||||||
|
modifier = Modifier.fillMaxWidth().animateContentSize(),
|
||||||
|
onDelete = { viewModel.cancel(post.id) },
|
||||||
|
confirmLabelRes = R.string.quick_action_delete,
|
||||||
|
) {
|
||||||
|
ScheduledPostCardCollapsed(
|
||||||
|
post = post,
|
||||||
|
nowSec = nowSec,
|
||||||
|
onClick = { expandedId = post.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val URGENT_THRESHOLD_SEC = 3600L
|
||||||
|
|
||||||
|
// Tailwind amber-400. Material 3 has no amber slot, but the publishing-pulse
|
||||||
|
// reads better than `tertiary` against a violet card.
|
||||||
|
private val PublishingAmber = Color(0xFFFBBF24)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Modifier.urgentEdge(enabled: Boolean): Modifier {
|
||||||
|
if (!enabled) return this
|
||||||
|
val gradientStart = MaterialTheme.colorScheme.primary
|
||||||
|
val gradientEnd = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||||
|
val brush =
|
||||||
|
remember(gradientStart, gradientEnd) {
|
||||||
|
Brush.verticalGradient(listOf(gradientStart, gradientEnd))
|
||||||
|
}
|
||||||
|
return this.drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRect(
|
||||||
|
brush = brush,
|
||||||
|
topLeft = Offset(0f, 8.dp.toPx()),
|
||||||
|
size = Size(3.dp.toPx(), size.height - 16.dp.toPx()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScheduledPostCardCollapsed(
|
||||||
|
post: ScheduledPost,
|
||||||
|
nowSec: Long,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val preview = remember(post.id) { extractContentPreview(post, 200) }
|
||||||
|
val media = remember(post.id) { extractFirstMediaUrl(post) }
|
||||||
|
val relayCountText =
|
||||||
|
pluralStringResource(
|
||||||
|
id = R.plurals.scheduled_posts_relay_count,
|
||||||
|
count = post.relayUrls.size,
|
||||||
|
post.relayUrls.size,
|
||||||
|
)
|
||||||
|
|
||||||
|
val isFailed = post.status == ScheduledPostStatus.FAILED
|
||||||
|
// Composite the tint over surface so the card is opaque — otherwise the
|
||||||
|
// SwipeToDismissBox background ("Delete" / "Cancel") bleeds through at rest.
|
||||||
|
val surface = MaterialTheme.colorScheme.surface
|
||||||
|
val containerColor =
|
||||||
|
if (isFailed) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
.copy(alpha = 0.06f)
|
||||||
|
.compositeOver(surface)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
.copy(alpha = 0.06f)
|
||||||
|
.compositeOver(surface)
|
||||||
|
}
|
||||||
|
val borderColor =
|
||||||
|
if (isFailed) {
|
||||||
|
MaterialTheme.colorScheme.error.copy(alpha = 0.22f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.urgentEdge(!isFailed && post.publishAtSec - nowSec in 1..URGENT_THRESHOLD_SEC),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||||
|
border = BorderStroke(1.dp, borderColor),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
StatusPill(post.status)
|
||||||
|
Text(
|
||||||
|
text = formatAtTime(post.publishAtSec, nowSec, context),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (media != null) {
|
||||||
|
MediaThumbnail(media)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = preview,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = relayCountText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScheduledPostCardExpandedPanel(
|
||||||
|
post: ScheduledPost,
|
||||||
|
onPublishNow: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val clipboardManager = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val eventId = remember(post.id) { extractEventId(post) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
SectionLabel(stringRes(R.string.relays))
|
||||||
|
post.relayUrls.forEach { url ->
|
||||||
|
Text(
|
||||||
|
text = url,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f),
|
||||||
|
modifier = Modifier.padding(vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventId != null) {
|
||||||
|
Column {
|
||||||
|
SectionLabel(stringRes(R.string.quick_action_copy_note_id))
|
||||||
|
Text(
|
||||||
|
text = eventId,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = {},
|
||||||
|
onLongClick = {
|
||||||
|
scope.launch { clipboardManager.setText(eventId) }
|
||||||
|
Toast
|
||||||
|
.makeText(
|
||||||
|
context,
|
||||||
|
stringRes(context, R.string.scheduled_posts_event_id_copied),
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val err = post.lastError
|
||||||
|
if (post.status == ScheduledPostStatus.FAILED && !err.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.scheduled_posts_error_prefix, err),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Button(
|
||||||
|
onClick = onPublishNow,
|
||||||
|
enabled = post.status != ScheduledPostStatus.PUBLISHING,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
val labelRes =
|
||||||
|
when (post.status) {
|
||||||
|
ScheduledPostStatus.FAILED -> R.string.retry
|
||||||
|
ScheduledPostStatus.PUBLISHING -> R.string.scheduled_posts_status_publishing
|
||||||
|
else -> R.string.scheduled_posts_action_send_now
|
||||||
|
}
|
||||||
|
Text(stringRes(labelRes))
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onDelete,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(stringRes(R.string.quick_action_delete))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionLabel(text: String) {
|
||||||
|
val locale = LocalConfiguration.current.locales[0]
|
||||||
|
Text(
|
||||||
|
text = text.uppercase(locale),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayHeader(
|
||||||
|
day: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
context: android.content.Context,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = formatDayHeader(day, today, context),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusPill(status: ScheduledPostStatus) {
|
||||||
|
val (labelRes, color, pulse) =
|
||||||
|
when (status) {
|
||||||
|
ScheduledPostStatus.PENDING -> {
|
||||||
|
Triple(R.string.scheduled_posts_status_pending, MaterialTheme.colorScheme.primary, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduledPostStatus.PUBLISHING -> {
|
||||||
|
Triple(R.string.scheduled_posts_status_publishing, PublishingAmber, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduledPostStatus.FAILED -> {
|
||||||
|
Triple(R.string.scheduled_posts_status_failed, MaterialTheme.colorScheme.error, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduledPostStatus.SENT -> {
|
||||||
|
Triple(R.string.scheduled_posts_status_sent, MaterialTheme.colorScheme.tertiary, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduledPostStatus.CANCELLED -> {
|
||||||
|
Triple(R.string.scheduled_posts_status_cancelled, MaterialTheme.colorScheme.onSurfaceVariant, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val dotAlpha =
|
||||||
|
if (pulse) {
|
||||||
|
val transition = rememberInfiniteTransition(label = "publishing-pulse")
|
||||||
|
transition
|
||||||
|
.animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = 0.35f,
|
||||||
|
animationSpec =
|
||||||
|
infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1400, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "publishing-pulse-alpha",
|
||||||
|
).value
|
||||||
|
} else {
|
||||||
|
1f
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = color.copy(alpha = 0.18f),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(6.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color.copy(alpha = dotAlpha)),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = stringRes(labelRes),
|
||||||
|
color = color,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyState(
|
||||||
|
onCompose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
symbol = MaterialSymbols.Schedule,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.scheduled_posts_empty_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.scheduled_posts_empty_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Button(onClick = onCompose) {
|
||||||
|
Text(stringRes(R.string.new_post))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val shortTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||||
|
private val fullDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
|
||||||
|
|
||||||
|
private fun formatAtTime(
|
||||||
|
publishAtSec: Long,
|
||||||
|
nowSec: Long,
|
||||||
|
context: android.content.Context,
|
||||||
|
): String {
|
||||||
|
val absolute =
|
||||||
|
Instant
|
||||||
|
.ofEpochSecond(publishAtSec)
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toLocalTime()
|
||||||
|
.format(shortTimeFormatter)
|
||||||
|
return if (publishAtSec > nowSec) {
|
||||||
|
stringRes(context, R.string.scheduled_posts_at_time, absolute, timeAheadNoDot(publishAtSec, context))
|
||||||
|
} else {
|
||||||
|
stringRes(context, R.string.scheduled_posts_at_time_past, absolute, timeAgoNoDot(publishAtSec, context).trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDayHeader(
|
||||||
|
day: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
context: android.content.Context,
|
||||||
|
): String =
|
||||||
|
when (day) {
|
||||||
|
today -> stringRes(context, R.string.today)
|
||||||
|
today.plusDays(1) -> stringRes(context, R.string.scheduled_posts_day_tomorrow)
|
||||||
|
else -> day.format(fullDateFormatter)
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
/*
|
||||||
|
* 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.scheduledposts
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/** A day-bucket of posts for the scheduled-posts list screen. */
|
||||||
|
data class ScheduledPostDayGroup(
|
||||||
|
val day: LocalDate,
|
||||||
|
val posts: List<ScheduledPost>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the "Scheduled posts" screen for a single account. Filters the global
|
||||||
|
* ScheduledPostStore down to posts owned by [accountPubkey] that are still
|
||||||
|
* "in progress" — PENDING, PUBLISHING, or FAILED. SENT and CANCELLED rows are
|
||||||
|
* hidden from the list (they're "done").
|
||||||
|
*/
|
||||||
|
class ScheduledPostsViewModel(
|
||||||
|
private val store: ScheduledPostStore,
|
||||||
|
private val accountPubkey: String,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val activeStatuses =
|
||||||
|
setOf(
|
||||||
|
ScheduledPostStatus.PENDING,
|
||||||
|
ScheduledPostStatus.PUBLISHING,
|
||||||
|
ScheduledPostStatus.FAILED,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Posts for [accountPubkey] in active statuses, grouped by local-day, sorted ascending. */
|
||||||
|
val groupedPosts: StateFlow<List<ScheduledPostDayGroup>> =
|
||||||
|
store.flow
|
||||||
|
.map { all ->
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
all
|
||||||
|
.filter { it.accountPubkey == accountPubkey && it.status in activeStatuses }
|
||||||
|
.sortedBy { it.publishAtSec }
|
||||||
|
.groupBy { Instant.ofEpochSecond(it.publishAtSec).atZone(zone).toLocalDate() }
|
||||||
|
.map { (day, list) -> ScheduledPostDayGroup(day, list) }
|
||||||
|
.sortedBy { it.day }
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val totalActive: StateFlow<Int> =
|
||||||
|
groupedPosts
|
||||||
|
.map { groups -> groups.sumOf { it.posts.size } }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun cancel(id: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
store.cancel(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publishNow(id: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
store.publishNow(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun create(accountPubkey: String): ScheduledPostsViewModel =
|
||||||
|
ScheduledPostsViewModel(
|
||||||
|
store = Amethyst.instance.scheduledPostStore,
|
||||||
|
accountPubkey = accountPubkey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -371,6 +371,21 @@ private fun HeaderOptions(accountViewModel: AccountViewModel) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsRow(
|
||||||
|
R.string.disable_client_tag_title,
|
||||||
|
R.string.disable_client_tag_explainer,
|
||||||
|
) {
|
||||||
|
var disableClientTag by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.disableClientTag.value) }
|
||||||
|
|
||||||
|
Switch(
|
||||||
|
checked = disableClientTag,
|
||||||
|
onCheckedChange = {
|
||||||
|
disableClientTag = it
|
||||||
|
accountViewModel.updateDisableClientTag(disableClientTag)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
R.string.show_sensitive_content_title,
|
R.string.show_sensitive_content_title,
|
||||||
R.string.show_sensitive_content_explainer,
|
R.string.show_sensitive_content_explainer,
|
||||||
|
|||||||
@@ -396,6 +396,75 @@
|
|||||||
<string name="migrate_bookmarks_button">Přesunout vše do nových záložek</string>
|
<string name="migrate_bookmarks_button">Přesunout vše do nových záložek</string>
|
||||||
<string name="migrate_bookmarks_success">Záložky úspěšně přesunuty</string>
|
<string name="migrate_bookmarks_success">Záložky úspěšně přesunuty</string>
|
||||||
<string name="drafts">Koncepty</string>
|
<string name="drafts">Koncepty</string>
|
||||||
|
<string name="scheduled_posts">Naplánované příspěvky</string>
|
||||||
|
<string name="schedule_post">Naplánovat</string>
|
||||||
|
<string name="schedule_post_time_label">Naplánovaný čas</string>
|
||||||
|
<string name="schedule_post_helper">Příspěvky se publikují přibližně do 15 minut od naplánovaného času.</string>
|
||||||
|
<string name="schedule_post_pick_time">Vyberte naplánovaný čas</string>
|
||||||
|
<string name="schedule_post_pick_label">Naplánovat na…</string>
|
||||||
|
<string name="schedule_post_publishes_in">Publikováno za %1$s</string>
|
||||||
|
<string name="schedule_post_was_due">Mělo být před %1$s</string>
|
||||||
|
<string name="schedule_post_picker_time_title">Čas</string>
|
||||||
|
<string name="schedule_post_button_add">Naplánovat příspěvek</string>
|
||||||
|
<string name="schedule_post_button_remove">Zrušit plánování</string>
|
||||||
|
<string name="schedule_post_warning_title">Trvalá oznámení vypnuta</string>
|
||||||
|
<string name="schedule_post_warning_single">Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI.</string>
|
||||||
|
<string name="schedule_post_warning_multi">Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Naplánované příspěvky jiných účtů se nespustí, dokud je aktivní tento účet. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI.</string>
|
||||||
|
<string name="schedule_post_preset_in_one_hour">Za 1 hodinu</string>
|
||||||
|
<string name="schedule_post_preset_tomorrow_morning">Zítra v 9:00</string>
|
||||||
|
<string name="schedule_post_preset_next_monday_morning">Příští pondělí v 9:00</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_title">Povolit trvalá oznámení?</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_message">Naplánované příspěvky se spolehlivě publikují pouze tehdy, když jsou povolena trvalá oznámení. Jinak se nemusí spustit, dokud aplikaci znovu neotevřete.</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_open_settings">Otevřít nastavení</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_continue">Přesto pokračovat</string>
|
||||||
|
<string name="scheduled_posts_at_time">%1$s · za %2$s</string>
|
||||||
|
<string name="scheduled_posts_at_time_past">%1$s · před %2$s</string>
|
||||||
|
<string name="scheduled_posts_day_tomorrow">Zítra</string>
|
||||||
|
<string name="scheduled_posts_logout_toast_zero">Odhlášeno</string>
|
||||||
|
<plurals name="scheduled_posts_logout_toast">
|
||||||
|
<item quantity="one">Odhlášeno · smazán %d naplánovaný příspěvek</item>
|
||||||
|
<item quantity="few">Odhlášeno · smazány %d naplánované příspěvky</item>
|
||||||
|
<item quantity="many">Odhlášeno · smazáno %d naplánovaných příspěvků</item>
|
||||||
|
<item quantity="other">Odhlášeno · smazáno %d naplánovaných příspěvků</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="scheduled_posts_notification_sent_title">Naplánovaný příspěvek publikován</string>
|
||||||
|
<string name="scheduled_posts_notification_failed_title">Naplánovaný příspěvek selhal</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_name">Naplánované příspěvky</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_description">Oznámení, když je naplánovaný příspěvek publikován nebo selže při publikaci.</string>
|
||||||
|
<string name="scheduled_posts_action_send_now">Odeslat hned</string>
|
||||||
|
<string name="scheduled_posts_empty_title">Žádné naplánované příspěvky</string>
|
||||||
|
<string name="scheduled_posts_empty_hint">Napište poznámku a klepněte na ikonu hodin pro naplánování na později.</string>
|
||||||
|
<string name="scheduled_posts_error_prefix">Chyba: %1$s</string>
|
||||||
|
<string name="scheduled_posts_status_pending">Naplánováno</string>
|
||||||
|
<string name="scheduled_posts_status_publishing">Odesílá se…</string>
|
||||||
|
<string name="scheduled_posts_status_failed">Selhalo</string>
|
||||||
|
<string name="scheduled_posts_status_sent">Odesláno</string>
|
||||||
|
<string name="scheduled_posts_status_cancelled">Zrušeno</string>
|
||||||
|
<plurals name="scheduled_posts_logout_warning">
|
||||||
|
<item quantity="one">Máte %d naplánovaný příspěvek, který ještě nebyl publikován. Odhlášením bude trvale smazán.</item>
|
||||||
|
<item quantity="few">Máte %d naplánované příspěvky, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</item>
|
||||||
|
<item quantity="many">Máte %d naplánovaných příspěvků, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</item>
|
||||||
|
<item quantity="other">Máte %d naplánovaných příspěvků, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_subtitle_queued">
|
||||||
|
<item quantity="one">%d ve frontě</item>
|
||||||
|
<item quantity="few">%d ve frontě</item>
|
||||||
|
<item quantity="many">%d ve frontě</item>
|
||||||
|
<item quantity="other">%d ve frontě</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||||
|
<item quantity="one"> · %d do 1 h</item>
|
||||||
|
<item quantity="few"> · %d do 1 h</item>
|
||||||
|
<item quantity="many"> · %d do 1 h</item>
|
||||||
|
<item quantity="other"> · %d do 1 h</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_relay_count">
|
||||||
|
<item quantity="one">na %d relay</item>
|
||||||
|
<item quantity="few">na %d relaye</item>
|
||||||
|
<item quantity="many">na %d relayů</item>
|
||||||
|
<item quantity="other">na %d relayů</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="scheduled_posts_event_id_copied">ID příspěvku zkopírováno</string>
|
||||||
<string name="polls">Ankety</string>
|
<string name="polls">Ankety</string>
|
||||||
<string name="open_polls">Otevřené</string>
|
<string name="open_polls">Otevřené</string>
|
||||||
<string name="closed_polls">Uzavřené</string>
|
<string name="closed_polls">Uzavřené</string>
|
||||||
|
|||||||
@@ -402,6 +402,64 @@ anz der Bedingungen ist erforderlich</string>
|
|||||||
<string name="migrate_bookmarks_button">Alle in neue Lesezeichen verschieben</string>
|
<string name="migrate_bookmarks_button">Alle in neue Lesezeichen verschieben</string>
|
||||||
<string name="migrate_bookmarks_success">Lesezeichen erfolgreich migriert</string>
|
<string name="migrate_bookmarks_success">Lesezeichen erfolgreich migriert</string>
|
||||||
<string name="drafts">Entwürfe</string>
|
<string name="drafts">Entwürfe</string>
|
||||||
|
<string name="scheduled_posts">Geplante Beiträge</string>
|
||||||
|
<string name="schedule_post">Planen</string>
|
||||||
|
<string name="schedule_post_time_label">Geplante Zeit</string>
|
||||||
|
<string name="schedule_post_helper">Beiträge werden innerhalb von ~15 Minuten nach der geplanten Zeit veröffentlicht.</string>
|
||||||
|
<string name="schedule_post_pick_time">Geplante Zeit auswählen</string>
|
||||||
|
<string name="schedule_post_pick_label">Planen für…</string>
|
||||||
|
<string name="schedule_post_publishes_in">Veröffentlicht in %1$s</string>
|
||||||
|
<string name="schedule_post_was_due">Fällig vor %1$s</string>
|
||||||
|
<string name="schedule_post_picker_time_title">Zeit</string>
|
||||||
|
<string name="schedule_post_button_add">Beitrag planen</string>
|
||||||
|
<string name="schedule_post_button_remove">Planung abbrechen</string>
|
||||||
|
<string name="schedule_post_warning_title">Dauerbenachrichtigungen deaktiviert</string>
|
||||||
|
<string name="schedule_post_warning_single">Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App das nächste Mal öffnest. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung.</string>
|
||||||
|
<string name="schedule_post_warning_multi">Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App wieder öffnest. Geplante Beiträge anderer Konten werden nicht ausgelöst, solange dieses Konto aktiv ist. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung.</string>
|
||||||
|
<string name="schedule_post_preset_in_one_hour">In 1 Stunde</string>
|
||||||
|
<string name="schedule_post_preset_tomorrow_morning">Morgen 9 Uhr</string>
|
||||||
|
<string name="schedule_post_preset_next_monday_morning">Nächsten Montag 9 Uhr</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_title">Dauerbenachrichtigungen aktivieren?</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_message">Geplante Beiträge werden zuverlässig nur veröffentlicht, wenn Dauerbenachrichtigungen aktiviert sind. Andernfalls werden sie möglicherweise erst beim nächsten Öffnen der App ausgelöst.</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_open_settings">Einstellungen öffnen</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_continue">Trotzdem fortfahren</string>
|
||||||
|
<string name="scheduled_posts_at_time_past">%1$s · vor %2$s</string>
|
||||||
|
<string name="scheduled_posts_day_tomorrow">Morgen</string>
|
||||||
|
<string name="scheduled_posts_logout_toast_zero">Abgemeldet</string>
|
||||||
|
<plurals name="scheduled_posts_logout_toast">
|
||||||
|
<item quantity="one">Abgemeldet · %d geplanter Beitrag gelöscht</item>
|
||||||
|
<item quantity="other">Abgemeldet · %d geplante Beiträge gelöscht</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="scheduled_posts_notification_sent_title">Geplanter Beitrag veröffentlicht</string>
|
||||||
|
<string name="scheduled_posts_notification_failed_title">Geplanter Beitrag fehlgeschlagen</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_name">Geplante Beiträge</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_description">Benachrichtigungen, wenn ein geplanter Beitrag veröffentlicht wird oder die Veröffentlichung fehlschlägt.</string>
|
||||||
|
<string name="scheduled_posts_action_send_now">Jetzt senden</string>
|
||||||
|
<string name="scheduled_posts_empty_title">Keine geplanten Beiträge</string>
|
||||||
|
<string name="scheduled_posts_empty_hint">Verfasse eine Notiz und tippe auf das Uhr-Symbol, um sie für später zu planen.</string>
|
||||||
|
<string name="scheduled_posts_error_prefix">Fehler: %1$s</string>
|
||||||
|
<string name="scheduled_posts_status_pending">Geplant</string>
|
||||||
|
<string name="scheduled_posts_status_publishing">Wird gesendet…</string>
|
||||||
|
<string name="scheduled_posts_status_failed">Fehlgeschlagen</string>
|
||||||
|
<string name="scheduled_posts_status_sent">Gesendet</string>
|
||||||
|
<string name="scheduled_posts_status_cancelled">Abgebrochen</string>
|
||||||
|
<plurals name="scheduled_posts_logout_warning">
|
||||||
|
<item quantity="one">Du hast %d geplanten Beitrag, der noch nicht veröffentlicht wurde. Beim Abmelden wird er dauerhaft gelöscht.</item>
|
||||||
|
<item quantity="other">Du hast %d geplante Beiträge, die noch nicht veröffentlicht wurden. Beim Abmelden werden sie dauerhaft gelöscht.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_subtitle_queued">
|
||||||
|
<item quantity="one">%d in Warteschlange</item>
|
||||||
|
<item quantity="other">%d in Warteschlange</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||||
|
<item quantity="one"> · %d fällig in 1 Std.</item>
|
||||||
|
<item quantity="other"> · %d fällig in 1 Std.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_relay_count">
|
||||||
|
<item quantity="one">an %d Relay</item>
|
||||||
|
<item quantity="other">an %d Relays</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="scheduled_posts_event_id_copied">Beitrags-ID kopiert</string>
|
||||||
<string name="polls">Umfragen</string>
|
<string name="polls">Umfragen</string>
|
||||||
<string name="open_polls">Offen</string>
|
<string name="open_polls">Offen</string>
|
||||||
<string name="closed_polls">Geschlossen</string>
|
<string name="closed_polls">Geschlossen</string>
|
||||||
|
|||||||
@@ -398,6 +398,59 @@
|
|||||||
<string name="migrate_bookmarks_button">Összes átköltöztetése az új könyvjelzőkbe</string>
|
<string name="migrate_bookmarks_button">Összes átköltöztetése az új könyvjelzőkbe</string>
|
||||||
<string name="migrate_bookmarks_success">A könyvjelzők átköltöztetése sikeresen befejeződött</string>
|
<string name="migrate_bookmarks_success">A könyvjelzők átköltöztetése sikeresen befejeződött</string>
|
||||||
<string name="drafts">Piszkozatok</string>
|
<string name="drafts">Piszkozatok</string>
|
||||||
|
<string name="scheduled_posts">Ütemezett bejegyzések</string>
|
||||||
|
<string name="schedule_post">Ütemezés</string>
|
||||||
|
<string name="schedule_post_time_label">Ütemezés ideje</string>
|
||||||
|
<string name="schedule_post_helper">A bejegyzések a tervezett időponttól számított körülbelül 15 percen belül jelennek meg.</string>
|
||||||
|
<string name="schedule_post_pick_time">Válasszon ki egy időpontot</string>
|
||||||
|
<string name="schedule_post_pick_label">Ütemezés…</string>
|
||||||
|
<string name="schedule_post_publishes_in">Közzététel: %1$s</string>
|
||||||
|
<string name="schedule_post_was_due">Már %1$s ezelőtt esedékes volt</string>
|
||||||
|
<string name="schedule_post_picker_time_title">Időpont</string>
|
||||||
|
<string name="schedule_post_button_add">Bejegyzés ütemezése</string>
|
||||||
|
<string name="schedule_post_button_remove">Ütemezés visszavonása</string>
|
||||||
|
<string name="schedule_post_warning_title">Folyamatos értesítési szolgáltatás letiltva</string>
|
||||||
|
<string name="schedule_post_warning_single">Az ütemezett bejegyzések csak akkor jelennek meg, ha legközelebb újra megnyitja az alkalmazást. A megbízható háttérbeli ütemezés érdekében engedélyezze a „Folyamatos értesítési szolgáltatás” beállítását a Beállítások → Felhasználói felület beállítása menüpontban.</string>
|
||||||
|
<string name="schedule_post_warning_multi">Az ütemezett bejegyzések csak akkor jelennek meg, ha újra megnyitja az alkalmazást. Más fiókok ütemezett bejegyzései nem jelennek meg, amíg ez a fiók aktív. A megbízható háttérbeli ütemezés érdekében engedélyezze a „Folyamatos értesítési szolgáltatás” beállítását a Beállítások → Felhasználói felület beállítása menüpontban.</string>
|
||||||
|
<string name="schedule_post_preset_in_one_hour">1 órán belül</string>
|
||||||
|
<string name="schedule_post_preset_tomorrow_morning">Holnap délelőtt 9 órakor</string>
|
||||||
|
<string name="schedule_post_preset_next_monday_morning">Következő hétfő délelőtt 9 órakor</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_title">Bekapcsolja a folyamatos értesítési szolgáltatást?</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_message">Az ütemezett bejegyzések csak akkor jelennek meg biztosan, ha a „Folyamatos értesítési szolgáltatás” engedélyezve van. Ellenkező esetben előfordulhat, hogy csak az alkalmazás következő megnyitásakor jelennek meg.</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_open_settings">Beállítások menyitása</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_continue">Folytatás mindenképpen</string>
|
||||||
|
<string name="scheduled_posts_at_time">%1$s → %2$s</string>
|
||||||
|
<string name="scheduled_posts_at_time_past">%1$s · %2$s ezelőtt</string>
|
||||||
|
<string name="scheduled_posts_day_tomorrow">Holnap</string>
|
||||||
|
<string name="scheduled_posts_logout_toast_zero">Kijelentkezve</string>
|
||||||
|
<string name="scheduled_posts_logout_toast">Ön kijelentkezett · %1$d ütemezett bejegyzés törölve</string>
|
||||||
|
<string name="scheduled_posts_notification_sent_title">Ütemezett bejegyzés közzétéve</string>
|
||||||
|
<string name="scheduled_posts_notification_failed_title">Nem sikerült közzétenni egy ütemezett bejegyzést</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_name">Ütemezett bejegyzések</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_description">Értesítések, amikor egy ütemezett bejegyzés megjelenik vagy nem sikerül közzététenni.</string>
|
||||||
|
<string name="scheduled_posts_action_send_now">Küldés most</string>
|
||||||
|
<string name="scheduled_posts_empty_title">Nincsenek ütemezett bejegyzések</string>
|
||||||
|
<string name="scheduled_posts_empty_hint">Írja meg az üzenetet, majd koppintson az óraikonra, hogy a közzétételét későbbre ütemezhesse.</string>
|
||||||
|
<string name="scheduled_posts_error_prefix">Hiba: %1$s</string>
|
||||||
|
<string name="scheduled_posts_status_pending">Ütemezve</string>
|
||||||
|
<string name="scheduled_posts_status_publishing">Küldés…</string>
|
||||||
|
<string name="scheduled_posts_status_failed">Sikertelen</string>
|
||||||
|
<string name="scheduled_posts_status_sent">Elküldve</string>
|
||||||
|
<string name="scheduled_posts_status_cancelled">Megszakitva</string>
|
||||||
|
<string name="scheduled_posts_logout_warning">Önnek %1$d ütemezett bejegyzése van, amelyeket még nem tett közzé. Kijelentkezés esetén ezek véglegesen törlődnek.</string>
|
||||||
|
<plurals name="scheduled_posts_subtitle_queued">
|
||||||
|
<item quantity="one">%d sorbaállítva</item>
|
||||||
|
<item quantity="other">%d sorbaállítva</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||||
|
<item quantity="one"> · 1 bejegyzés 1 órán belül</item>
|
||||||
|
<item quantity="other"> · %d bejegyzés 1 órán belül</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_relay_count">
|
||||||
|
<item quantity="one">1 átjátszóhoz</item>
|
||||||
|
<item quantity="other">%d átjátszóhoz</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="scheduled_posts_event_id_copied">Bejegyzés azonosítója másolva</string>
|
||||||
<string name="polls">Szavazások</string>
|
<string name="polls">Szavazások</string>
|
||||||
<string name="open_polls">Megnyitás</string>
|
<string name="open_polls">Megnyitás</string>
|
||||||
<string name="closed_polls">Lezárva</string>
|
<string name="closed_polls">Lezárva</string>
|
||||||
|
|||||||
@@ -396,6 +396,67 @@
|
|||||||
<string name="migrate_bookmarks_button">Mover Tudo para Novos Favoritos</string>
|
<string name="migrate_bookmarks_button">Mover Tudo para Novos Favoritos</string>
|
||||||
<string name="migrate_bookmarks_success">Favoritos migrados com sucesso</string>
|
<string name="migrate_bookmarks_success">Favoritos migrados com sucesso</string>
|
||||||
<string name="drafts">Rascunhos</string>
|
<string name="drafts">Rascunhos</string>
|
||||||
|
<string name="scheduled_posts">Posts agendados</string>
|
||||||
|
<string name="schedule_post">Agendar</string>
|
||||||
|
<string name="schedule_post_time_label">Hora agendada</string>
|
||||||
|
<string name="schedule_post_helper">Posts são publicados em até ~15 minutos após o horário agendado.</string>
|
||||||
|
<string name="schedule_post_pick_time">Escolher horário agendado</string>
|
||||||
|
<string name="schedule_post_pick_label">Agendar para…</string>
|
||||||
|
<string name="schedule_post_publishes_in">Publica em %1$s</string>
|
||||||
|
<string name="schedule_post_was_due">Devia ter sido publicado há %1$s</string>
|
||||||
|
<string name="schedule_post_picker_time_title">Hora</string>
|
||||||
|
<string name="schedule_post_button_add">Agendar post</string>
|
||||||
|
<string name="schedule_post_button_remove">Cancelar agendamento</string>
|
||||||
|
<string name="schedule_post_warning_title">Notificações sempre ativas desativadas</string>
|
||||||
|
<string name="schedule_post_warning_single">Posts agendados podem não ser publicados até você reabrir o app. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano.</string>
|
||||||
|
<string name="schedule_post_warning_multi">Posts agendados podem não ser publicados até você reabrir o app. Posts agendados de outras contas não serão disparados enquanto esta conta estiver ativa. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano.</string>
|
||||||
|
<string name="schedule_post_preset_in_one_hour">Em 1 hora</string>
|
||||||
|
<string name="schedule_post_preset_tomorrow_morning">Amanhã às 9h</string>
|
||||||
|
<string name="schedule_post_preset_next_monday_morning">Próxima segunda às 9h</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_title">Ativar notificações sempre ativas?</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_message">Posts agendados publicam de forma confiável apenas quando notificações sempre ativas estão ativadas. Caso contrário, podem não disparar até você reabrir o app.</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_open_settings">Abrir configurações</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_continue">Continuar mesmo assim</string>
|
||||||
|
<string name="scheduled_posts_at_time">%1$s · em %2$s</string>
|
||||||
|
<string name="scheduled_posts_at_time_past">%1$s · há %2$s</string>
|
||||||
|
<string name="scheduled_posts_day_tomorrow">Amanhã</string>
|
||||||
|
<string name="scheduled_posts_logout_toast_zero">Desconectado</string>
|
||||||
|
<plurals name="scheduled_posts_logout_toast">
|
||||||
|
<item quantity="one">Desconectado · %d post agendado excluído</item>
|
||||||
|
<item quantity="many">Desconectado · %d posts agendados excluídos</item>
|
||||||
|
<item quantity="other">Desconectado · %d posts agendados excluídos</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="scheduled_posts_notification_sent_title">Post agendado publicado</string>
|
||||||
|
<string name="scheduled_posts_notification_failed_title">Post agendado falhou</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_name">Posts agendados</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_description">Notificações quando um post agendado é publicado ou falha ao publicar.</string>
|
||||||
|
<string name="scheduled_posts_action_send_now">Enviar agora</string>
|
||||||
|
<string name="scheduled_posts_empty_title">Sem posts agendados</string>
|
||||||
|
<string name="scheduled_posts_empty_hint">Componha uma nota e toque no ícone do relógio para agendá-la.</string>
|
||||||
|
<string name="scheduled_posts_error_prefix">Erro: %1$s</string>
|
||||||
|
<string name="scheduled_posts_status_pending">Agendado</string>
|
||||||
|
<string name="scheduled_posts_status_publishing">Enviando…</string>
|
||||||
|
<string name="scheduled_posts_status_failed">Falhou</string>
|
||||||
|
<string name="scheduled_posts_status_sent">Enviado</string>
|
||||||
|
<string name="scheduled_posts_status_cancelled">Cancelado</string>
|
||||||
|
<plurals name="scheduled_posts_logout_warning">
|
||||||
|
<item quantity="one">Você tem %d post agendado que ainda não foi publicado. Sair excluirá esse post permanentemente.</item>
|
||||||
|
<item quantity="many">Você tem %d posts agendados que ainda não foram publicados. Sair excluirá esses posts permanentemente.</item>
|
||||||
|
<item quantity="other">Você tem %d posts agendados que ainda não foram publicados. Sair excluirá esses posts permanentemente.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_subtitle_queued">
|
||||||
|
<item quantity="one">%d na fila</item>
|
||||||
|
<item quantity="other">%d na fila</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||||
|
<item quantity="one"> · %d em 1h</item>
|
||||||
|
<item quantity="other"> · %d em 1h</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_relay_count">
|
||||||
|
<item quantity="one">para %d relay</item>
|
||||||
|
<item quantity="other">para %d relays</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="scheduled_posts_event_id_copied">ID do post copiado</string>
|
||||||
<string name="polls">Enquetes</string>
|
<string name="polls">Enquetes</string>
|
||||||
<string name="open_polls">Abertas</string>
|
<string name="open_polls">Abertas</string>
|
||||||
<string name="closed_polls">Encerradas</string>
|
<string name="closed_polls">Encerradas</string>
|
||||||
|
|||||||
@@ -396,6 +396,65 @@
|
|||||||
<string name="migrate_bookmarks_button">Flytta allt till nya bokmärken</string>
|
<string name="migrate_bookmarks_button">Flytta allt till nya bokmärken</string>
|
||||||
<string name="migrate_bookmarks_success">Bokmärken migrerade</string>
|
<string name="migrate_bookmarks_success">Bokmärken migrerade</string>
|
||||||
<string name="drafts">Utkast</string>
|
<string name="drafts">Utkast</string>
|
||||||
|
<string name="scheduled_posts">Schemalagda inlägg</string>
|
||||||
|
<string name="schedule_post">Schemalägg</string>
|
||||||
|
<string name="schedule_post_time_label">Schemalagd tid</string>
|
||||||
|
<string name="schedule_post_helper">Inlägg publiceras inom ~15 minuter från den schemalagda tiden.</string>
|
||||||
|
<string name="schedule_post_pick_time">Välj schemalagd tid</string>
|
||||||
|
<string name="schedule_post_pick_label">Schemalägg för…</string>
|
||||||
|
<string name="schedule_post_publishes_in">Publiceras om %1$s</string>
|
||||||
|
<string name="schedule_post_was_due">Skulle ha publicerats för %1$s sedan</string>
|
||||||
|
<string name="schedule_post_picker_time_title">Tid</string>
|
||||||
|
<string name="schedule_post_button_add">Schemalägg inlägg</string>
|
||||||
|
<string name="schedule_post_button_remove">Avbryt schemaläggning</string>
|
||||||
|
<string name="schedule_post_warning_title">Alltid-på-aviseringar avstängda</string>
|
||||||
|
<string name="schedule_post_warning_single">Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden.</string>
|
||||||
|
<string name="schedule_post_warning_multi">Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Andra kontons schemalagda inlägg utlöses inte medan detta konto är aktivt. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden.</string>
|
||||||
|
<string name="schedule_post_preset_in_one_hour">Om 1 timme</string>
|
||||||
|
<string name="schedule_post_preset_tomorrow_morning">Imorgon kl. 09:00</string>
|
||||||
|
<string name="schedule_post_preset_next_monday_morning">Nästa måndag kl. 09:00</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_title">Aktivera alltid-på-aviseringar?</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_message">Schemalagda inlägg publiceras pålitligt endast när alltid-på-aviseringar är aktiverade. Annars kanske de inte utlöses förrän du öppnar appen igen.</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_open_settings">Öppna inställningar</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_continue">Fortsätt ändå</string>
|
||||||
|
<string name="scheduled_posts_at_time">%1$s · om %2$s</string>
|
||||||
|
<string name="scheduled_posts_at_time_past">%1$s · för %2$s sedan</string>
|
||||||
|
<string name="scheduled_posts_day_tomorrow">Imorgon</string>
|
||||||
|
<string name="scheduled_posts_logout_toast_zero">Utloggad</string>
|
||||||
|
<plurals name="scheduled_posts_logout_toast">
|
||||||
|
<item quantity="one">Utloggad · %d schemalagt inlägg raderat</item>
|
||||||
|
<item quantity="other">Utloggad · %d schemalagda inlägg raderade</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="scheduled_posts_notification_sent_title">Schemalagt inlägg publicerat</string>
|
||||||
|
<string name="scheduled_posts_notification_failed_title">Schemalagt inlägg misslyckades</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_name">Schemalagda inlägg</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_description">Aviseringar när ett schemalagt inlägg publiceras eller misslyckas att publicera.</string>
|
||||||
|
<string name="scheduled_posts_action_send_now">Skicka nu</string>
|
||||||
|
<string name="scheduled_posts_empty_title">Inga schemalagda inlägg</string>
|
||||||
|
<string name="scheduled_posts_empty_hint">Skriv en anteckning och tryck på klockikonen för att schemalägga den.</string>
|
||||||
|
<string name="scheduled_posts_error_prefix">Fel: %1$s</string>
|
||||||
|
<string name="scheduled_posts_status_pending">Schemalagd</string>
|
||||||
|
<string name="scheduled_posts_status_publishing">Skickar…</string>
|
||||||
|
<string name="scheduled_posts_status_failed">Misslyckades</string>
|
||||||
|
<string name="scheduled_posts_status_sent">Skickat</string>
|
||||||
|
<string name="scheduled_posts_status_cancelled">Avbrutet</string>
|
||||||
|
<plurals name="scheduled_posts_logout_warning">
|
||||||
|
<item quantity="one">Du har %d schemalagt inlägg som inte har publicerats än. Att logga ut raderar det permanent.</item>
|
||||||
|
<item quantity="other">Du har %d schemalagda inlägg som inte har publicerats än. Att logga ut raderar dem permanent.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_subtitle_queued">
|
||||||
|
<item quantity="one">%d i kö</item>
|
||||||
|
<item quantity="other">%d i kö</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||||
|
<item quantity="one"> · %d inom 1 h</item>
|
||||||
|
<item quantity="other"> · %d inom 1 h</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_relay_count">
|
||||||
|
<item quantity="one">till %d relä</item>
|
||||||
|
<item quantity="other">till %d reläer</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="scheduled_posts_event_id_copied">Inläggs-ID kopierat</string>
|
||||||
<string name="polls">Omröstningar</string>
|
<string name="polls">Omröstningar</string>
|
||||||
<string name="open_polls">Öppna</string>
|
<string name="open_polls">Öppna</string>
|
||||||
<string name="closed_polls">Stängda</string>
|
<string name="closed_polls">Stängda</string>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<string name="referenced_event_not_found">未找到相关事件</string>
|
<string name="referenced_event_not_found">未找到相关事件</string>
|
||||||
<string name="could_not_decrypt_the_message">无法解密消息</string>
|
<string name="could_not_decrypt_the_message">无法解密消息</string>
|
||||||
<string name="group_picture">群聊图片</string>
|
<string name="group_picture">群聊图片</string>
|
||||||
<string name="explicit_content">明确内容</string>
|
<string name="explicit_content">露骨内容</string>
|
||||||
<string name="relay_notice">中继通知</string>
|
<string name="relay_notice">中继通知</string>
|
||||||
<string name="duplicated_post">重复的贴文</string>
|
<string name="duplicated_post">重复的贴文</string>
|
||||||
<string name="spam">垃圾信息</string>
|
<string name="spam">垃圾信息</string>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<string name="block_hide_user"><![CDATA[阻止并隐藏用户]]></string>
|
<string name="block_hide_user"><![CDATA[阻止并隐藏用户]]></string>
|
||||||
<string name="report_spam_scam">举报垃圾邮件/诈骗</string>
|
<string name="report_spam_scam">举报垃圾邮件/诈骗</string>
|
||||||
<string name="report_impersonation">举报冒充</string>
|
<string name="report_impersonation">举报冒充</string>
|
||||||
<string name="report_explicit_content">举报明确内容</string>
|
<string name="report_explicit_content">举报露骨内容</string>
|
||||||
<string name="report_illegal_behaviour">举报非法行为</string>
|
<string name="report_illegal_behaviour">举报非法行为</string>
|
||||||
<string name="report_malware">举报恶意软件</string>
|
<string name="report_malware">举报恶意软件</string>
|
||||||
<string name="report_mod">举报 Mod</string>
|
<string name="report_mod">举报 Mod</string>
|
||||||
@@ -398,6 +398,56 @@
|
|||||||
<string name="migrate_bookmarks_button">移动全部到新书签</string>
|
<string name="migrate_bookmarks_button">移动全部到新书签</string>
|
||||||
<string name="migrate_bookmarks_success">书签迁移成功</string>
|
<string name="migrate_bookmarks_success">书签迁移成功</string>
|
||||||
<string name="drafts">草稿</string>
|
<string name="drafts">草稿</string>
|
||||||
|
<string name="scheduled_posts">定时帖子</string>
|
||||||
|
<string name="schedule_post">计划</string>
|
||||||
|
<string name="schedule_post_time_label">计划的时间</string>
|
||||||
|
<string name="schedule_post_helper">帖子发布时间计划时间的 ~15 分钟内。</string>
|
||||||
|
<string name="schedule_post_pick_time">选择计划时间</string>
|
||||||
|
<string name="schedule_post_pick_label">选择计划项目…</string>
|
||||||
|
<string name="schedule_post_publishes_in">在 %1$s 内发布</string>
|
||||||
|
<string name="schedule_post_was_due">%1$s 前到期</string>
|
||||||
|
<string name="schedule_post_picker_time_title">时间</string>
|
||||||
|
<string name="schedule_post_button_add">定时帖</string>
|
||||||
|
<string name="schedule_post_button_remove">取消时间安排</string>
|
||||||
|
<string name="schedule_post_warning_title">禁用了始终显示的通知</string>
|
||||||
|
<string name="schedule_post_warning_single">下次重新打开应用前可能不会发布定时贴。要获得稳定的后台定时功能在“设置” → “用户界面偏好” 中启用“始终显示”。</string>
|
||||||
|
<string name="schedule_post_warning_multi">在重新打开应用前可能不会发布定时帖。此账户活跃时,其他账户的定时帖不会发布。要获得稳定的后台定时功能,在“设置” →\"用户界面首选项“ 中开启”始终显示“。</string>
|
||||||
|
<string name="schedule_post_preset_in_one_hour">1小时内</string>
|
||||||
|
<string name="schedule_post_preset_tomorrow_morning">明天上午9点</string>
|
||||||
|
<string name="schedule_post_preset_next_monday_morning">下周一上午9点</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_title">开启”始终显示的通知“?</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_message">只有在启用了”始终显示的通知“时才能可靠地发布定时帖。否则,在下次重新打开应用前定时帖可能不会发布。</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_open_settings">打开设置</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_continue">仍然继续</string>
|
||||||
|
<string name="scheduled_posts_at_time">%1$s ·在 %2$s</string>
|
||||||
|
<string name="scheduled_posts_at_time_past">%1$s · %2$s 前</string>
|
||||||
|
<string name="scheduled_posts_day_tomorrow">明天</string>
|
||||||
|
<string name="scheduled_posts_logout_toast_zero">已退出登录</string>
|
||||||
|
<string name="scheduled_posts_logout_toast">已退出登录 删除了 %1$d 个定时帖</string>
|
||||||
|
<string name="scheduled_posts_notification_sent_title">发布了定时帖</string>
|
||||||
|
<string name="scheduled_posts_notification_failed_title">未能发布定时帖</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_name">定时帖</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_description">当成功发布了定时帖或未能发布定时帖时发出通知。</string>
|
||||||
|
<string name="scheduled_posts_action_send_now">立即发送</string>
|
||||||
|
<string name="scheduled_posts_empty_title">无定时帖</string>
|
||||||
|
<string name="scheduled_posts_empty_hint">撰写笔记并轻触时钟图标稍后安排时间。</string>
|
||||||
|
<string name="scheduled_posts_error_prefix">错误:%1$s</string>
|
||||||
|
<string name="scheduled_posts_status_pending">已安排时间</string>
|
||||||
|
<string name="scheduled_posts_status_publishing">正在发送…</string>
|
||||||
|
<string name="scheduled_posts_status_failed">失败</string>
|
||||||
|
<string name="scheduled_posts_status_sent">已发送</string>
|
||||||
|
<string name="scheduled_posts_status_cancelled">已取消</string>
|
||||||
|
<string name="scheduled_posts_logout_warning">您有尚未发布的 %1$d 个定时帖。退出登录将永久删除它们。</string>
|
||||||
|
<plurals name="scheduled_posts_subtitle_queued">
|
||||||
|
<item quantity="other">%d 个已加入队列</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||||
|
<item quantity="other"> • %d 个于1小时内到期</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_relay_count">
|
||||||
|
<item quantity="other">到 %d 个中继</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="scheduled_posts_event_id_copied">已复制笔记 ID</string>
|
||||||
<string name="polls">投票</string>
|
<string name="polls">投票</string>
|
||||||
<string name="open_polls">开启</string>
|
<string name="open_polls">开启</string>
|
||||||
<string name="closed_polls">已关闭</string>
|
<string name="closed_polls">已关闭</string>
|
||||||
@@ -739,7 +789,7 @@
|
|||||||
<string name="zap_type_nonzap">非打闪</string>
|
<string name="zap_type_nonzap">非打闪</string>
|
||||||
<string name="zap_type_nonzap_explainer">Nostr 上没有痕迹,仅在闪电上</string>
|
<string name="zap_type_nonzap_explainer">Nostr 上没有痕迹,仅在闪电上</string>
|
||||||
<string name="post_anonymously">匿名</string>
|
<string name="post_anonymously">匿名</string>
|
||||||
<string name="post_anonymously_explainer">使用新的一次性身份发布。您的帐户将不会被链接到这个回复。</string>
|
<string name="post_anonymously_explainer">使用新的一次性身份发帖。您的帐户将不会被链接到这个回复。</string>
|
||||||
<string name="anonymous_reply_warning">此回复将从新的匿名身份发布</string>
|
<string name="anonymous_reply_warning">此回复将从新的匿名身份发布</string>
|
||||||
<string name="file_server">文件服务器</string>
|
<string name="file_server">文件服务器</string>
|
||||||
<string name="file_server_description">选择上传文件时使用的服务器</string>
|
<string name="file_server_description">选择上传文件时使用的服务器</string>
|
||||||
|
|||||||
@@ -428,6 +428,66 @@
|
|||||||
<string name="migrate_bookmarks_button">Move All to New Bookmarks</string>
|
<string name="migrate_bookmarks_button">Move All to New Bookmarks</string>
|
||||||
<string name="migrate_bookmarks_success">Bookmarks migrated successfully</string>
|
<string name="migrate_bookmarks_success">Bookmarks migrated successfully</string>
|
||||||
<string name="drafts">Drafts</string>
|
<string name="drafts">Drafts</string>
|
||||||
|
<string name="scheduled_posts">Scheduled posts</string>
|
||||||
|
<string name="schedule_post">Schedule</string>
|
||||||
|
<string name="schedule_post_time_label">Scheduled time</string>
|
||||||
|
<string name="schedule_post_helper">Posts publish within ~15 minutes of the scheduled time.</string>
|
||||||
|
<string name="schedule_post_pick_time">Pick scheduled time</string>
|
||||||
|
<string name="schedule_post_pick_label">Schedule for…</string>
|
||||||
|
<string name="schedule_post_publishes_in">Publishes in %1$s</string>
|
||||||
|
<string name="schedule_post_was_due">Was due %1$s ago</string>
|
||||||
|
<string name="schedule_post_picker_time_title">Time</string>
|
||||||
|
<string name="schedule_post_button_add">Schedule post</string>
|
||||||
|
<string name="schedule_post_button_remove">Cancel scheduling</string>
|
||||||
|
<string name="schedule_post_warning_title">Always-on notifications disabled</string>
|
||||||
|
<string name="schedule_post_warning_single">Scheduled posts may not publish until you next reopen the app. Enable always-on in Settings → UI Preferences for reliable background scheduling.</string>
|
||||||
|
<string name="schedule_post_warning_multi">Scheduled posts may not publish until you reopen the app. Other accounts\' scheduled posts won\'t fire while this account is active. Enable always-on in Settings → UI Preferences for reliable background scheduling.</string>
|
||||||
|
<string name="schedule_post_preset_in_one_hour">In 1 hour</string>
|
||||||
|
<string name="schedule_post_preset_tomorrow_morning">Tomorrow 9 AM</string>
|
||||||
|
<string name="schedule_post_preset_next_monday_morning">Next Monday 9 AM</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_title">Enable always-on notifications?</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_message">Scheduled posts publish reliably only when always-on notifications are enabled. Otherwise, they may not fire until you next reopen the app.</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_open_settings">Open settings</string>
|
||||||
|
<string name="schedule_post_always_on_prompt_continue">Continue anyway</string>
|
||||||
|
<string name="scheduled_posts_at_time">%1$s · in %2$s</string>
|
||||||
|
<string name="scheduled_posts_at_time_past">%1$s · %2$s ago</string>
|
||||||
|
<string name="scheduled_posts_day_tomorrow">Tomorrow</string>
|
||||||
|
<string name="scheduled_posts_logout_toast_zero">Logged out</string>
|
||||||
|
<plurals name="scheduled_posts_logout_toast">
|
||||||
|
<item quantity="one">Logged out · %d scheduled post deleted</item>
|
||||||
|
<item quantity="other">Logged out · %d scheduled posts deleted</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="scheduled_posts_notification_sent_title">Scheduled post published</string>
|
||||||
|
<string name="scheduled_posts_notification_failed_title">Scheduled post failed</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_id" translatable="false">ScheduledPostsID</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_name">Scheduled posts</string>
|
||||||
|
<string name="app_notification_scheduled_posts_channel_description">Notifications when a scheduled post is published or fails to publish.</string>
|
||||||
|
<string name="scheduled_posts_action_send_now">Send now</string>
|
||||||
|
<string name="scheduled_posts_empty_title">No scheduled posts</string>
|
||||||
|
<string name="scheduled_posts_empty_hint">Compose a note and tap the clock icon to schedule it for later.</string>
|
||||||
|
<string name="scheduled_posts_error_prefix">Error: %1$s</string>
|
||||||
|
<string name="scheduled_posts_status_pending">Scheduled</string>
|
||||||
|
<string name="scheduled_posts_status_publishing">Sending…</string>
|
||||||
|
<string name="scheduled_posts_status_failed">Failed</string>
|
||||||
|
<string name="scheduled_posts_status_sent">Sent</string>
|
||||||
|
<string name="scheduled_posts_status_cancelled">Cancelled</string>
|
||||||
|
<plurals name="scheduled_posts_logout_warning">
|
||||||
|
<item quantity="one">You have %d scheduled post that hasn\'t been published yet. Logging out will permanently delete it.</item>
|
||||||
|
<item quantity="other">You have %d scheduled posts that haven\'t been published yet. Logging out will permanently delete them.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_subtitle_queued">
|
||||||
|
<item quantity="one">%d queued</item>
|
||||||
|
<item quantity="other">%d queued</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||||
|
<item quantity="one"> · %d due in 1h</item>
|
||||||
|
<item quantity="other"> · %d due in 1h</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scheduled_posts_relay_count">
|
||||||
|
<item quantity="one">to %d relay</item>
|
||||||
|
<item quantity="other">to %d relays</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="scheduled_posts_event_id_copied">Note ID copied</string>
|
||||||
<string name="polls">Polls</string>
|
<string name="polls">Polls</string>
|
||||||
<string name="open_polls">Open</string>
|
<string name="open_polls">Open</string>
|
||||||
<string name="closed_polls">Closed</string>
|
<string name="closed_polls">Closed</string>
|
||||||
@@ -1125,6 +1185,8 @@
|
|||||||
|
|
||||||
<string name="filter_spam_from_strangers_title">Filter spam</string>
|
<string name="filter_spam_from_strangers_title">Filter spam</string>
|
||||||
<string name="filter_spam_from_strangers_explainer">Hides posts from strangers that were exactly the same for 5 or more times</string>
|
<string name="filter_spam_from_strangers_explainer">Hides posts from strangers that were exactly the same for 5 or more times</string>
|
||||||
|
<string name="disable_client_tag_title">Don\'t add client tag to my events</string>
|
||||||
|
<string name="disable_client_tag_explainer">When enabled, Amethyst will not append a NIP-89 client tag to events you publish.</string>
|
||||||
<string name="warn_when_posts_have_reports_from_your_follows_title">Warn on reports</string>
|
<string name="warn_when_posts_have_reports_from_your_follows_title">Warn on reports</string>
|
||||||
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Shows a warning message when posts have 5 or more reports from your follows</string>
|
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Shows a warning message when posts have 5 or more reports from your follows</string>
|
||||||
<string name="show_sensitive_content_title">Show sensitive content</string>
|
<string name="show_sensitive_content_title">Show sensitive content</string>
|
||||||
|
|||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
/*
|
||||||
|
* 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.nests
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for [AppForegroundCounter] — the pure-state core of
|
||||||
|
* [AppForegroundRecycleHook]. Drives the lifecycle transitions
|
||||||
|
* directly with a controllable clock so the threshold logic doesn't
|
||||||
|
* need a real wall-clock wait.
|
||||||
|
*/
|
||||||
|
class AppForegroundRecycleHookTest {
|
||||||
|
@Test
|
||||||
|
fun firstForegroundAfterProcessStartDoesNotPublish() {
|
||||||
|
// The very first onActivityStarted has no prior background to
|
||||||
|
// recycle from — recycling here would fire a redundant
|
||||||
|
// re-handshake on every cold start, which is wasteful.
|
||||||
|
var fakeNow = 0L
|
||||||
|
var publishCount = 0
|
||||||
|
val counter =
|
||||||
|
AppForegroundCounter(
|
||||||
|
publishEvent = { publishCount++ },
|
||||||
|
nowMillis = { fakeNow },
|
||||||
|
)
|
||||||
|
|
||||||
|
fakeNow = 1_000L
|
||||||
|
counter.onActivityStarted()
|
||||||
|
|
||||||
|
assertEquals("first onActivityStarted must not publish — no prior background", 0, publishCount)
|
||||||
|
assertEquals(0, counter.recyclesFired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shortBackgroundDoesNotTriggerRecycle() {
|
||||||
|
// 1 s background (notification pull, biometric prompt) is
|
||||||
|
// typical UI noise and the QUIC socket is still healthy.
|
||||||
|
var fakeNow = 0L
|
||||||
|
var publishCount = 0
|
||||||
|
val counter =
|
||||||
|
AppForegroundCounter(
|
||||||
|
backgroundThresholdMs = 5_000L,
|
||||||
|
publishEvent = { publishCount++ },
|
||||||
|
nowMillis = { fakeNow },
|
||||||
|
)
|
||||||
|
|
||||||
|
fakeNow = 1_000L
|
||||||
|
counter.onActivityStarted()
|
||||||
|
fakeNow = 2_000L
|
||||||
|
counter.onActivityStopped()
|
||||||
|
fakeNow = 3_000L // backgrounded for 1 s only
|
||||||
|
counter.onActivityStarted()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"background < threshold must not publish — short transitions don't reclaim sockets",
|
||||||
|
0,
|
||||||
|
publishCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun backgroundLongerThanThresholdTriggersRecycle() {
|
||||||
|
// 6 s background crosses the 5 s default threshold — Android
|
||||||
|
// may have reclaimed the socket FD by now, so recycle the
|
||||||
|
// QUIC session on resume.
|
||||||
|
var fakeNow = 0L
|
||||||
|
var publishCount = 0
|
||||||
|
val counter =
|
||||||
|
AppForegroundCounter(
|
||||||
|
backgroundThresholdMs = 5_000L,
|
||||||
|
publishEvent = { publishCount++ },
|
||||||
|
nowMillis = { fakeNow },
|
||||||
|
)
|
||||||
|
|
||||||
|
fakeNow = 1_000L
|
||||||
|
counter.onActivityStarted()
|
||||||
|
fakeNow = 2_000L
|
||||||
|
counter.onActivityStopped()
|
||||||
|
fakeNow = 8_000L // backgrounded for 6 s
|
||||||
|
counter.onActivityStarted()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"background ≥ threshold must publish exactly once on resume",
|
||||||
|
1,
|
||||||
|
publishCount,
|
||||||
|
)
|
||||||
|
assertEquals(1, counter.recyclesFired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipleActivitiesTrackTransitionCorrectly() {
|
||||||
|
// Picture-in-picture mode is implemented as a second activity
|
||||||
|
// overlaid on the main activity. While both are started the
|
||||||
|
// app is in foreground; only when both stop does the app
|
||||||
|
// truly background.
|
||||||
|
var fakeNow = 0L
|
||||||
|
var publishCount = 0
|
||||||
|
val counter =
|
||||||
|
AppForegroundCounter(
|
||||||
|
backgroundThresholdMs = 5_000L,
|
||||||
|
publishEvent = { publishCount++ },
|
||||||
|
nowMillis = { fakeNow },
|
||||||
|
)
|
||||||
|
|
||||||
|
// First activity starts (cold start), no publish.
|
||||||
|
fakeNow = 1_000L
|
||||||
|
counter.onActivityStarted()
|
||||||
|
// Second activity (e.g. PIP / dialog) starts on top. Still
|
||||||
|
// foreground; no publish — `wasBackgrounded` was false.
|
||||||
|
fakeNow = 2_000L
|
||||||
|
counter.onActivityStarted()
|
||||||
|
// First activity stops (e.g. user backs out of main).
|
||||||
|
// counter = 1, still foreground.
|
||||||
|
fakeNow = 3_000L
|
||||||
|
counter.onActivityStopped()
|
||||||
|
assertEquals(
|
||||||
|
"intermediate stop with another activity still started must not background",
|
||||||
|
0,
|
||||||
|
publishCount,
|
||||||
|
)
|
||||||
|
// Second stops → app truly backgrounds.
|
||||||
|
fakeNow = 4_000L
|
||||||
|
counter.onActivityStopped()
|
||||||
|
// 6 s later, an activity restarts → recycle.
|
||||||
|
fakeNow = 10_000L
|
||||||
|
counter.onActivityStarted()
|
||||||
|
assertEquals(1, publishCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun consecutiveLongBackgroundsEachPublishOnce() {
|
||||||
|
// Two separate back-and-forth cycles must each fire exactly
|
||||||
|
// one publish. A regression that misses to refresh the
|
||||||
|
// last-backgrounded timestamp on second-stop would either
|
||||||
|
// double-fire on the second resume or skip it.
|
||||||
|
var fakeNow = 0L
|
||||||
|
var publishCount = 0
|
||||||
|
val counter =
|
||||||
|
AppForegroundCounter(
|
||||||
|
backgroundThresholdMs = 5_000L,
|
||||||
|
publishEvent = { publishCount++ },
|
||||||
|
nowMillis = { fakeNow },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cycle 1: cold start → 6 s background → resume (publish #1)
|
||||||
|
fakeNow = 1_000L
|
||||||
|
counter.onActivityStarted()
|
||||||
|
fakeNow = 2_000L
|
||||||
|
counter.onActivityStopped()
|
||||||
|
fakeNow = 8_000L
|
||||||
|
counter.onActivityStarted()
|
||||||
|
assertEquals("first resume after long background must publish", 1, publishCount)
|
||||||
|
|
||||||
|
// Cycle 2: 8 s background again → resume (publish #2)
|
||||||
|
fakeNow = 10_000L
|
||||||
|
counter.onActivityStopped()
|
||||||
|
fakeNow = 18_000L
|
||||||
|
counter.onActivityStarted()
|
||||||
|
assertEquals("second resume after long background must also publish", 2, publishCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
+542
@@ -0,0 +1,542 @@
|
|||||||
|
/*
|
||||||
|
* 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.scheduledposts
|
||||||
|
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class ScheduledPostStoreTest {
|
||||||
|
@get:Rule
|
||||||
|
val temp = TemporaryFolder()
|
||||||
|
|
||||||
|
private lateinit var file: File
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
file = File(temp.root, "scheduled_posts.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file, now)
|
||||||
|
|
||||||
|
private fun samplePost(
|
||||||
|
id: String = "id-1",
|
||||||
|
publishAtSec: Long = 1_000,
|
||||||
|
accountPubkey: String = "pk1",
|
||||||
|
) = ScheduledPost(
|
||||||
|
id = id,
|
||||||
|
accountPubkey = accountPubkey,
|
||||||
|
signedEventJson = "{}",
|
||||||
|
relayUrls = listOf("wss://relay.example/"),
|
||||||
|
extraEventsJson = emptyList(),
|
||||||
|
publishAtSec = publishAtSec,
|
||||||
|
createdAtSec = 500,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun add_persists_to_disk() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost())
|
||||||
|
assertTrue("storage file should exist after add", file.exists())
|
||||||
|
|
||||||
|
val reloaded = newStore().list()
|
||||||
|
assertEquals(1, reloaded.size)
|
||||||
|
assertEquals("id-1", reloaded[0].id)
|
||||||
|
assertEquals(ScheduledPostStatus.PENDING, reloaded[0].status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun claimDuePosts_returns_only_due_pending_posts() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(id = "due", publishAtSec = 1000))
|
||||||
|
store.add(samplePost(id = "future", publishAtSec = 5000))
|
||||||
|
store.add(samplePost(id = "also-due", publishAtSec = 999))
|
||||||
|
|
||||||
|
val claimed = store.claimDuePosts(nowSec = 1000)
|
||||||
|
val ids = claimed.map { it.id }.toSet()
|
||||||
|
assertEquals(setOf("due", "also-due"), ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun claimDuePosts_flips_status_to_publishing() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(publishAtSec = 1000))
|
||||||
|
|
||||||
|
store.claimDuePosts(nowSec = 1000)
|
||||||
|
|
||||||
|
val all = store.list()
|
||||||
|
assertEquals(ScheduledPostStatus.PUBLISHING, all[0].status)
|
||||||
|
assertEquals(1, all[0].attemptCount)
|
||||||
|
assertEquals(1000L, all[0].lastAttemptAtSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun claimDuePosts_second_call_returns_empty() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(publishAtSec = 1000))
|
||||||
|
|
||||||
|
val first = store.claimDuePosts(nowSec = 1000)
|
||||||
|
val second = store.claimDuePosts(nowSec = 1000)
|
||||||
|
|
||||||
|
assertEquals(1, first.size)
|
||||||
|
assertEquals(0, second.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun concurrent_claimDuePosts_only_one_wins() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(id = "race", publishAtSec = 1000))
|
||||||
|
|
||||||
|
val results =
|
||||||
|
(1..10)
|
||||||
|
.map { async { store.claimDuePosts(nowSec = 1000) } }
|
||||||
|
.awaitAll()
|
||||||
|
|
||||||
|
val totalClaimed = results.sumOf { it.size }
|
||||||
|
assertEquals("exactly one concurrent caller should claim the post", 1, totalClaimed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun markSent_updates_status_and_clears_error() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost())
|
||||||
|
store.markFailed("id-1", "earlier error")
|
||||||
|
store.markSent("id-1")
|
||||||
|
|
||||||
|
val all = store.list()
|
||||||
|
assertEquals(ScheduledPostStatus.SENT, all[0].status)
|
||||||
|
assertNull(all[0].lastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun markFailed_records_error() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost())
|
||||||
|
store.markFailed("id-1", "boom")
|
||||||
|
|
||||||
|
val all = store.list()
|
||||||
|
assertEquals(ScheduledPostStatus.FAILED, all[0].status)
|
||||||
|
assertEquals("boom", all[0].lastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun releaseClaim_reverts_publishing_to_pending() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(publishAtSec = 1000))
|
||||||
|
store.claimDuePosts(nowSec = 1000)
|
||||||
|
|
||||||
|
store.releaseClaim("id-1")
|
||||||
|
|
||||||
|
val all = store.list()
|
||||||
|
assertEquals(ScheduledPostStatus.PENDING, all[0].status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun releaseClaim_does_not_touch_non_publishing() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost())
|
||||||
|
store.releaseClaim("id-1")
|
||||||
|
|
||||||
|
assertEquals(ScheduledPostStatus.PENDING, store.list()[0].status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancel_sets_cancelled_status_and_returns_true() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost())
|
||||||
|
|
||||||
|
val ok = store.cancel("id-1")
|
||||||
|
|
||||||
|
assertTrue(ok)
|
||||||
|
assertEquals(ScheduledPostStatus.CANCELLED, store.list()[0].status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancel_unknown_id_returns_false() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
assertEquals(false, store.cancel("nope"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listFor_filters_by_account() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(id = "a", accountPubkey = "pk-a"))
|
||||||
|
store.add(samplePost(id = "b", accountPubkey = "pk-b"))
|
||||||
|
store.add(samplePost(id = "c", accountPubkey = "pk-a"))
|
||||||
|
|
||||||
|
val filtered = store.listFor("pk-a").map { it.id }.toSet()
|
||||||
|
assertEquals(setOf("a", "c"), filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancelled_posts_are_not_claimed() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(publishAtSec = 1000))
|
||||||
|
store.cancel("id-1")
|
||||||
|
|
||||||
|
val claimed = store.claimDuePosts(nowSec = 5000)
|
||||||
|
|
||||||
|
assertEquals(0, claimed.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun missing_file_loads_as_empty() =
|
||||||
|
runTest {
|
||||||
|
assertTrue("file should not exist before first read", !file.exists())
|
||||||
|
val store = newStore()
|
||||||
|
assertEquals(0, store.list().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun corrupt_file_loads_as_empty() =
|
||||||
|
runTest {
|
||||||
|
file.writeText("not valid json {{{")
|
||||||
|
val store = newStore()
|
||||||
|
assertEquals(0, store.list().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun publishNow_sets_publishAtSec_to_now_and_status_pending() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(publishAtSec = 9_999_999))
|
||||||
|
|
||||||
|
val ok = store.publishNow("id-1", nowSec = 1234)
|
||||||
|
|
||||||
|
assertTrue(ok)
|
||||||
|
val updated = store.list().single()
|
||||||
|
assertEquals(ScheduledPostStatus.PENDING, updated.status)
|
||||||
|
assertEquals(1234L, updated.publishAtSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun publishNow_clears_failed_state_for_retry() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost())
|
||||||
|
store.markFailed("id-1", "earlier failure")
|
||||||
|
|
||||||
|
store.publishNow("id-1", nowSec = 5000)
|
||||||
|
|
||||||
|
val updated = store.list().single()
|
||||||
|
assertEquals(ScheduledPostStatus.PENDING, updated.status)
|
||||||
|
assertNull(updated.lastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun publishNow_unknown_id_returns_false() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
assertEquals(false, store.publishNow("nope"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun publishNow_makes_post_immediately_claimable() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(publishAtSec = 9_999_999))
|
||||||
|
assertEquals(0, store.claimDuePosts(nowSec = 1000).size)
|
||||||
|
|
||||||
|
store.publishNow("id-1", nowSec = 1000)
|
||||||
|
|
||||||
|
val claimed = store.claimDuePosts(nowSec = 1000)
|
||||||
|
assertEquals(1, claimed.size)
|
||||||
|
assertEquals("id-1", claimed[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun flow_emits_initial_empty_then_post_after_add() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
assertEquals(0, store.flow.value.size)
|
||||||
|
|
||||||
|
store.add(samplePost())
|
||||||
|
|
||||||
|
assertEquals(1, store.flow.value.size)
|
||||||
|
assertEquals("id-1", store.flow.value[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun flow_reflects_status_transitions() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(publishAtSec = 1000))
|
||||||
|
assertEquals(ScheduledPostStatus.PENDING, store.flow.value[0].status)
|
||||||
|
|
||||||
|
store.claimDuePosts(nowSec = 1000)
|
||||||
|
assertEquals(ScheduledPostStatus.PUBLISHING, store.flow.value[0].status)
|
||||||
|
|
||||||
|
store.markSent("id-1")
|
||||||
|
assertEquals(ScheduledPostStatus.SENT, store.flow.value[0].status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun flow_seeded_from_disk_on_first_access() =
|
||||||
|
runTest {
|
||||||
|
// Pre-populate the file via a first store instance
|
||||||
|
newStore().add(samplePost())
|
||||||
|
|
||||||
|
// Second store starts with empty in-memory flow until first access
|
||||||
|
val store = newStore()
|
||||||
|
assertEquals(0, store.flow.value.size)
|
||||||
|
|
||||||
|
// Triggering any read method causes ensureLoaded() to seed the flow
|
||||||
|
store.list()
|
||||||
|
assertEquals(1, store.flow.value.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removeForAccount_removes_all_matching_rows_and_returns_count() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(id = "a1", accountPubkey = "pk-a"))
|
||||||
|
store.add(samplePost(id = "a2", accountPubkey = "pk-a"))
|
||||||
|
store.add(samplePost(id = "b1", accountPubkey = "pk-b"))
|
||||||
|
|
||||||
|
val removed = store.removeForAccount("pk-a")
|
||||||
|
|
||||||
|
assertEquals(2, removed)
|
||||||
|
val remaining = store.list()
|
||||||
|
assertEquals(1, remaining.size)
|
||||||
|
assertEquals("b1", remaining[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removeForAccount_no_match_returns_zero_and_does_not_persist() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(accountPubkey = "pk-a"))
|
||||||
|
val bytesBefore = file.readBytes()
|
||||||
|
|
||||||
|
val removed = store.removeForAccount("pk-other")
|
||||||
|
|
||||||
|
assertEquals(0, removed)
|
||||||
|
assertEquals(1, store.list().size)
|
||||||
|
assertTrue("file should not be rewritten on no-op", bytesBefore.contentEquals(file.readBytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removeForAccount_persists_to_disk() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(id = "a1", accountPubkey = "pk-a"))
|
||||||
|
store.add(samplePost(id = "b1", accountPubkey = "pk-b"))
|
||||||
|
|
||||||
|
store.removeForAccount("pk-a")
|
||||||
|
|
||||||
|
val reloaded = newStore().list()
|
||||||
|
assertEquals(1, reloaded.size)
|
||||||
|
assertEquals("b1", reloaded[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removeForAccount_purges_terminal_states_too() =
|
||||||
|
runTest {
|
||||||
|
val store = newStore()
|
||||||
|
store.add(samplePost(id = "p1", accountPubkey = "pk-a"))
|
||||||
|
store.add(samplePost(id = "p2", accountPubkey = "pk-a"))
|
||||||
|
store.markSent("p1")
|
||||||
|
store.cancel("p2")
|
||||||
|
|
||||||
|
val removed = store.removeForAccount("pk-a")
|
||||||
|
|
||||||
|
assertEquals(2, removed)
|
||||||
|
assertEquals(0, store.list().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancel_stamps_terminatedAtSec() =
|
||||||
|
runTest {
|
||||||
|
val clock = 1_700_000_000L
|
||||||
|
val store = newStore { clock }
|
||||||
|
store.add(samplePost(id = "x"))
|
||||||
|
|
||||||
|
store.cancel("x")
|
||||||
|
|
||||||
|
assertEquals(clock, store.list().single().terminatedAtSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun markSent_stamps_terminatedAtSec() =
|
||||||
|
runTest {
|
||||||
|
val clock = 1_700_000_000L
|
||||||
|
val store = newStore { clock }
|
||||||
|
store.add(samplePost(id = "x", publishAtSec = clock))
|
||||||
|
store.claimDuePosts(clock)
|
||||||
|
|
||||||
|
store.markSent("x")
|
||||||
|
|
||||||
|
assertEquals(clock, store.list().single().terminatedAtSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun publishNow_clears_terminatedAtSec() =
|
||||||
|
runTest {
|
||||||
|
val clock = 1_700_000_000L
|
||||||
|
val store = newStore { clock }
|
||||||
|
store.add(samplePost(id = "x"))
|
||||||
|
store.cancel("x") // stamps terminatedAtSec
|
||||||
|
|
||||||
|
store.publishNow("x", nowSec = clock + 5)
|
||||||
|
|
||||||
|
assertNull(store.list().single().terminatedAtSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ensureLoaded_purges_sent_older_than_seven_days() =
|
||||||
|
runTest {
|
||||||
|
val createTime = 1_700_000_000L
|
||||||
|
newStore { createTime }.also { it.add(samplePost(id = "old-sent", publishAtSec = createTime)) }
|
||||||
|
newStore { createTime }.also {
|
||||||
|
it.claimDuePosts(createTime)
|
||||||
|
it.markSent("old-sent")
|
||||||
|
}
|
||||||
|
|
||||||
|
val eightDaysLater = createTime + 8L * 24 * 3600
|
||||||
|
val reloaded = newStore { eightDaysLater }
|
||||||
|
assertEquals(0, reloaded.list().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ensureLoaded_keeps_recent_sent() =
|
||||||
|
runTest {
|
||||||
|
val createTime = 1_700_000_000L
|
||||||
|
newStore { createTime }.also { it.add(samplePost(id = "fresh", publishAtSec = createTime)) }
|
||||||
|
newStore { createTime }.also {
|
||||||
|
it.claimDuePosts(createTime)
|
||||||
|
it.markSent("fresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
val sixDaysLater = createTime + 6L * 24 * 3600
|
||||||
|
val reloaded = newStore { sixDaysLater }
|
||||||
|
assertEquals(1, reloaded.list().size)
|
||||||
|
assertEquals(ScheduledPostStatus.SENT, reloaded.list().single().status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ensureLoaded_purges_cancelled_older_than_thirty_days() =
|
||||||
|
runTest {
|
||||||
|
val createTime = 1_700_000_000L
|
||||||
|
newStore { createTime }.also {
|
||||||
|
it.add(samplePost(id = "old-cancel"))
|
||||||
|
it.cancel("old-cancel")
|
||||||
|
}
|
||||||
|
|
||||||
|
val thirtyOneDaysLater = createTime + 31L * 24 * 3600
|
||||||
|
val reloaded = newStore { thirtyOneDaysLater }
|
||||||
|
assertEquals(0, reloaded.list().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ensureLoaded_keeps_recent_cancelled() =
|
||||||
|
runTest {
|
||||||
|
val createTime = 1_700_000_000L
|
||||||
|
newStore { createTime }.also {
|
||||||
|
it.add(samplePost(id = "recent-cancel"))
|
||||||
|
it.cancel("recent-cancel")
|
||||||
|
}
|
||||||
|
|
||||||
|
val twentyDaysLater = createTime + 20L * 24 * 3600
|
||||||
|
val reloaded = newStore { twentyDaysLater }
|
||||||
|
assertEquals(1, reloaded.list().size)
|
||||||
|
assertEquals(ScheduledPostStatus.CANCELLED, reloaded.list().single().status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ensureLoaded_keeps_failed_indefinitely() =
|
||||||
|
runTest {
|
||||||
|
val createTime = 1_700_000_000L
|
||||||
|
newStore { createTime }.also { it.add(samplePost(id = "fail", publishAtSec = createTime)) }
|
||||||
|
newStore { createTime }.also {
|
||||||
|
it.claimDuePosts(createTime)
|
||||||
|
it.markFailed("fail", "boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
val ninetyDaysLater = createTime + 90L * 24 * 3600
|
||||||
|
val reloaded = newStore { ninetyDaysLater }
|
||||||
|
assertEquals(1, reloaded.list().size)
|
||||||
|
assertEquals(ScheduledPostStatus.FAILED, reloaded.list().single().status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ensureLoaded_persists_purge_to_disk() =
|
||||||
|
runTest {
|
||||||
|
val createTime = 1_700_000_000L
|
||||||
|
newStore { createTime }.also { it.add(samplePost(id = "old", publishAtSec = createTime)) }
|
||||||
|
newStore { createTime }.also {
|
||||||
|
it.claimDuePosts(createTime)
|
||||||
|
it.markSent("old")
|
||||||
|
}
|
||||||
|
val sizeBefore = file.length()
|
||||||
|
|
||||||
|
val eightDaysLater = createTime + 8L * 24 * 3600
|
||||||
|
newStore { eightDaysLater }.list() // triggers ensureLoaded + purge + persist
|
||||||
|
|
||||||
|
assertTrue("file should shrink after purge", file.length() < sizeBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundtrip_preserves_all_fields() =
|
||||||
|
runTest {
|
||||||
|
val original =
|
||||||
|
ScheduledPost(
|
||||||
|
id = "roundtrip",
|
||||||
|
accountPubkey = "pk-x",
|
||||||
|
signedEventJson = """{"kind":1,"content":"hi"}""",
|
||||||
|
relayUrls = listOf("wss://a/", "wss://b/"),
|
||||||
|
extraEventsJson = listOf("{}", "{}"),
|
||||||
|
publishAtSec = 1_700_000_000,
|
||||||
|
createdAtSec = 1_699_900_000,
|
||||||
|
status = ScheduledPostStatus.PENDING,
|
||||||
|
lastAttemptAtSec = null,
|
||||||
|
attemptCount = 0,
|
||||||
|
lastError = null,
|
||||||
|
)
|
||||||
|
newStore().add(original)
|
||||||
|
|
||||||
|
val reloaded = newStore().list().single()
|
||||||
|
assertEquals(original, reloaded)
|
||||||
|
assertNotNull(reloaded.relayUrls)
|
||||||
|
assertEquals(2, reloaded.relayUrls.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* 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.scheduledposts
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ScheduledPostMediaTest {
|
||||||
|
private fun postWithJson(json: String) =
|
||||||
|
ScheduledPost(
|
||||||
|
id = "id",
|
||||||
|
accountPubkey = "pk",
|
||||||
|
signedEventJson = json,
|
||||||
|
relayUrls = emptyList(),
|
||||||
|
extraEventsJson = emptyList(),
|
||||||
|
publishAtSec = 0,
|
||||||
|
createdAtSec = 0,
|
||||||
|
status = ScheduledPostStatus.PENDING,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractFirstMediaUrl_returns_null_when_no_imeta() {
|
||||||
|
val json = """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[],"content":"hi","sig":"x"}"""
|
||||||
|
assertNull(extractFirstMediaUrl(postWithJson(json)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractFirstMediaUrl_returns_image_when_mime_starts_with_image() {
|
||||||
|
val json =
|
||||||
|
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg","m image/jpeg"]],"content":"hi","sig":"x"}"""
|
||||||
|
val result = extractFirstMediaUrl(postWithJson(json))
|
||||||
|
assertTrue(result is MediaUrl.Image)
|
||||||
|
assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractFirstMediaUrl_returns_video_when_mime_starts_with_video() {
|
||||||
|
val json =
|
||||||
|
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/v.mp4","m video/mp4"]],"content":"hi","sig":"x"}"""
|
||||||
|
val result = extractFirstMediaUrl(postWithJson(json))
|
||||||
|
assertTrue(result is MediaUrl.Video)
|
||||||
|
assertEquals("https://x/v.mp4", (result as MediaUrl.Video).url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractFirstMediaUrl_defaults_to_image_when_no_mime() {
|
||||||
|
val json =
|
||||||
|
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg"]],"content":"hi","sig":"x"}"""
|
||||||
|
val result = extractFirstMediaUrl(postWithJson(json))
|
||||||
|
assertTrue(result is MediaUrl.Image)
|
||||||
|
assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractFirstMediaUrl_picks_first_when_multiple_imeta() {
|
||||||
|
val json =
|
||||||
|
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg","m image/jpeg"],["imeta","url https://y/v.mp4","m video/mp4"]],"content":"hi","sig":"x"}"""
|
||||||
|
val result = extractFirstMediaUrl(postWithJson(json))
|
||||||
|
assertTrue(result is MediaUrl.Image)
|
||||||
|
assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractFirstMediaUrl_returns_null_when_json_malformed() {
|
||||||
|
assertNull(extractFirstMediaUrl(postWithJson("{not json}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractFirstMediaUrl_returns_null_when_imeta_has_no_url() {
|
||||||
|
val json =
|
||||||
|
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","m image/jpeg"]],"content":"hi","sig":"x"}"""
|
||||||
|
assertNull(extractFirstMediaUrl(postWithJson(json)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractFirstMediaUrl_picks_first_when_video_precedes_image() {
|
||||||
|
val json =
|
||||||
|
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/v.mp4","m video/mp4"],["imeta","url https://y/a.jpg","m image/jpeg"]],"content":"hi","sig":"x"}"""
|
||||||
|
val result = extractFirstMediaUrl(postWithJson(json))
|
||||||
|
assertTrue(result is MediaUrl.Video)
|
||||||
|
assertEquals("https://x/v.mp4", (result as MediaUrl.Video).url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractEventId_returns_id_for_well_formed_json() {
|
||||||
|
val json =
|
||||||
|
"""{"id":"abc123","pubkey":"p","kind":1,"created_at":0,"tags":[],"content":"hi","sig":"x"}"""
|
||||||
|
assertEquals("abc123", extractEventId(postWithJson(json)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractEventId_returns_null_when_json_malformed() {
|
||||||
|
assertNull(extractEventId(postWithJson("{not json}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
# Custom Feeds for Amethyst Desktop
|
||||||
|
|
||||||
|
**Date:** 2026-05-04
|
||||||
|
**Branch:** `feat/desktop-custom-feeds`
|
||||||
|
**Status:** Planning
|
||||||
|
**Deepened:** 2026-05-04
|
||||||
|
|
||||||
|
## Enhancement Summary
|
||||||
|
|
||||||
|
**Research agents used:** feed-patterns, compose-expert, desktop-expert, relay-client, kotlin-expert, nostr-expert, account-state, compose-dnd-research, nip90-research
|
||||||
|
|
||||||
|
### Key Improvements from Research
|
||||||
|
1. Feeds must be **account-scoped** (not java.util.prefs) — supports multi-account + cross-device sync via kind 10090
|
||||||
|
2. Added **Phase 1.5** (FeedFilter mapping + relay subscriptions) — critical missing layer between data model and UI
|
||||||
|
3. Use `ImmutableList` from kotlinx.collections.immutable for all FeedSource collections — Compose stability
|
||||||
|
4. Sync pinned feeds with existing **kind 10090 `FavoriteAlgoFeedsListEvent`** for cross-device persistence
|
||||||
|
5. Use **Calvin-LL/Reorderable** library for drag-reorder (KMP, proven)
|
||||||
|
6. Changed chord shortcut from `Cmd+F, 1/2/3` to `Cmd+1/2/3` (standard tab-switch, no conflict)
|
||||||
|
7. Added EOSE-aware loading states and subscription lifecycle management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add custom feed creation, discovery, and management to Amethyst Desktop. Users can create feeds from hashtags, authors, relays, keywords; browse DVM algorithmic feeds; pin top 3 to the sidebar; manage all feeds via the app drawer's new Feeds tab; and optionally publish/import feeds via kind 31890 events.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Intuitive feed lookup and creation (from search or builder)
|
||||||
|
- Customizable feed navbar (top 3 pinned in sidebar, expandable via drawer)
|
||||||
|
- DVM marketplace for algorithmic feeds
|
||||||
|
- Local-first with optional protocol-level sharing (kind 31890 + naddr)
|
||||||
|
|
||||||
|
## Non-Goals (for now)
|
||||||
|
|
||||||
|
- Set operations (union/intersection/difference)
|
||||||
|
- WoT-based filtering
|
||||||
|
- Auto-zapping DVMs
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|----------|
|
||||||
|
| 1 | Top 3 feeds pinned in left sidebar, hard cap |
|
||||||
|
| 2 | "More" opens app drawer with FEEDS tab |
|
||||||
|
| 3 | Following + Global pre-created as FeedDefinitions |
|
||||||
|
| 4 | Local + private first; optional publish to relays |
|
||||||
|
| 5 | Live stream refresh; periodic poll fallback for DVMs |
|
||||||
|
| 6 | DVM zap requires confirm popup |
|
||||||
|
| 7 | Emoji-only feed icons |
|
||||||
|
| 8 | Drag-reorder in both sidebar and drawer |
|
||||||
|
| 9 | Reuse existing HomeFeed column rendering (swap filter) |
|
||||||
|
| 10 | Feed sharing via kind 31890 naddr (publish, copy, paste) |
|
||||||
|
| 11 | Account-scoped persistence (not machine-global java.util.prefs) |
|
||||||
|
| 12 | Sync pinned feeds with kind 10090 FavoriteAlgoFeedsListEvent |
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| `Cmd+Shift+F` | Open drawer on Feeds tab |
|
||||||
|
| `Cmd+K` | Open drawer (last tab) |
|
||||||
|
| `Cmd+1/2/3` | Switch to pinned feed 1/2/3 |
|
||||||
|
| `Cmd+N` (in feeds tab) | Create new feed |
|
||||||
|
|
||||||
|
> **Note:** `Cmd+1/2/3` is standard tab-switching (like browsers, Slack). Avoids `Cmd+F` conflict with search. Implemented via `MenuBar` items in a "Feeds" menu — no chord state machine needed.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// commons/src/commonMain/.../feeds/custom/FeedDefinition.kt
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class FeedDefinition(
|
||||||
|
val id: String, // UUID
|
||||||
|
val name: String,
|
||||||
|
val emoji: String, // single emoji as icon
|
||||||
|
val pinned: Boolean,
|
||||||
|
val pinOrder: Int, // 0, 1, 2
|
||||||
|
val source: FeedSource,
|
||||||
|
val refreshMode: RefreshMode,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
sealed interface FeedSource {
|
||||||
|
@Immutable
|
||||||
|
data class Filter(
|
||||||
|
val hashtags: ImmutableList<String>,
|
||||||
|
val authors: ImmutableList<HexKey>,
|
||||||
|
val relays: ImmutableList<String>,
|
||||||
|
val excludeAuthors: ImmutableList<HexKey>,
|
||||||
|
val excludeKeywords: ImmutableList<String>,
|
||||||
|
val kinds: ImmutableList<Int>,
|
||||||
|
) : FeedSource
|
||||||
|
|
||||||
|
@Immutable data class PeopleList(val address: ATag) : FeedSource
|
||||||
|
@Immutable data class InterestSet(val address: ATag) : FeedSource
|
||||||
|
@Immutable data class DVM(val address: ATag) : FeedSource
|
||||||
|
@Immutable data class SingleRelay(val url: String) : FeedSource
|
||||||
|
@Immutable data object Global : FeedSource
|
||||||
|
@Immutable data object Following : FeedSource
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class RefreshMode {
|
||||||
|
LIVE_STREAM,
|
||||||
|
POLL_5MIN,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Research Insights: Data Model
|
||||||
|
|
||||||
|
- **ImmutableList** (from `kotlinx.collections.immutable`, already in project) required for Compose stability — bare `List` is treated as unstable
|
||||||
|
- **@Immutable** on sealed interface + all subtypes ensures skip-safe recomposition in FeedCard/sidebar
|
||||||
|
- **feedKey identity**: use `"custom-${definition.id}"` in generated filters to avoid cache collision between feeds with same parameters but different names
|
||||||
|
- **DSL builder** for test/programmatic construction:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
inline fun feedDefinition(init: FeedDefinitionBuilder.() -> Unit): FeedDefinition =
|
||||||
|
FeedDefinitionBuilder().apply(init).build()
|
||||||
|
|
||||||
|
// Usage in tests:
|
||||||
|
val feed = feedDefinition {
|
||||||
|
name = "Bitcoin"
|
||||||
|
emoji = "₿"
|
||||||
|
filter {
|
||||||
|
hashtags += "bitcoin"
|
||||||
|
kinds += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository & State Management
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// commons/src/commonMain/.../feeds/custom/FeedDefinitionRepository.kt
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class FeedDefinitionRepository(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val serializer: FeedDefinitionSerializer,
|
||||||
|
) {
|
||||||
|
private val _feeds = MutableStateFlow<ImmutableList<FeedDefinition>>(persistentListOf())
|
||||||
|
val feeds: StateFlow<ImmutableList<FeedDefinition>> = _feeds.asStateFlow()
|
||||||
|
|
||||||
|
// Pre-computed grouped view for drawer UI
|
||||||
|
val groupedFeeds: StateFlow<GroupedFeeds> = _feeds.mapLatest { all ->
|
||||||
|
GroupedFeeds(
|
||||||
|
pinned = all.filter { it.pinned }.sortedBy { it.pinOrder }.toImmutableList(),
|
||||||
|
myFeeds = all.filter { !it.pinned && it.source !is FeedSource.DVM }.toImmutableList(),
|
||||||
|
algoFeeds = all.filter { it.source is FeedSource.DVM }.toImmutableList(),
|
||||||
|
)
|
||||||
|
}.distinctUntilChanged().stateIn(scope, SharingStarted.Eagerly, GroupedFeeds.EMPTY)
|
||||||
|
|
||||||
|
// Derived for sidebar (only recomposes when pinned change)
|
||||||
|
val pinnedFeeds: StateFlow<ImmutableList<FeedDefinition>> = groupedFeeds.map { it.pinned }
|
||||||
|
.distinctUntilChanged().stateIn(scope, SharingStarted.Eagerly, persistentListOf())
|
||||||
|
|
||||||
|
// Transient UI events
|
||||||
|
private val _events = MutableSharedFlow<FeedEvent>(replay = 0)
|
||||||
|
val events: SharedFlow<FeedEvent> = _events.asSharedFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface FeedEvent {
|
||||||
|
data class Created(val feed: FeedDefinition) : FeedEvent
|
||||||
|
data class PinLimitReached(val max: Int) : FeedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class GroupedFeeds(
|
||||||
|
val pinned: ImmutableList<FeedDefinition>,
|
||||||
|
val myFeeds: ImmutableList<FeedDefinition>,
|
||||||
|
val algoFeeds: ImmutableList<FeedDefinition>,
|
||||||
|
) {
|
||||||
|
companion object { val EMPTY = GroupedFeeds(persistentListOf(), persistentListOf(), persistentListOf()) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Account-Scoped Persistence
|
||||||
|
|
||||||
|
Feeds are per-account, NOT machine-global:
|
||||||
|
- Serialize alongside `AccountSettings` (same mechanism as `defaultHomeFollowList`, `favoriteAlgoFeeds`)
|
||||||
|
- On account switch, feed list swaps automatically
|
||||||
|
- On login, also subscribe to own kind 10090 events to restore pinned feeds from relay
|
||||||
|
|
||||||
|
## Navigation Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
+--------+------------------------------------+
|
||||||
|
| A | |
|
||||||
|
| | |
|
||||||
|
| [em1] | <- Pinned feed 1 (active) |
|
||||||
|
| [em2] | |
|
||||||
|
| [em3] | Feed Content (reuses HomeFeed) |
|
||||||
|
| | |
|
||||||
|
| ... | <- "More feeds" (opens drawer) |
|
||||||
|
| | |
|
||||||
|
| | |
|
||||||
|
| gear | |
|
||||||
|
+--------+------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Research Insights: Sidebar
|
||||||
|
|
||||||
|
- Extend existing `DeckSidebar` params: `pinnedFeeds`, `activeFeedId`, `onSwitchFeed`, `onOpenFeedsDrawer`
|
||||||
|
- Insert emoji buttons between "Add Column" button and `Spacer(weight=1)`
|
||||||
|
- Active feed: `primaryContainer` background with `CircleShape`
|
||||||
|
- Tooltip: `"${feed.name} (Cmd+${index+1})"`
|
||||||
|
- For 3 items, use simple `Column` + `pointerInput` with `detectDragGestures` (no library needed)
|
||||||
|
- Drag state: track `draggedIndex` + `dragOffsetY`, swap on threshold cross
|
||||||
|
|
||||||
|
## App Drawer Feeds Tab
|
||||||
|
|
||||||
|
```
|
||||||
|
+--- App Drawer (Cmd+K / Cmd+Shift+F) ------+
|
||||||
|
| Search: [________________________] |
|
||||||
|
| |
|
||||||
|
| [Screens] [Workspaces] [Feeds <-active] |
|
||||||
|
| |
|
||||||
|
| Pinned (3/3) |
|
||||||
|
| em Following [unpin] [menu] |
|
||||||
|
| em Bitcoin [unpin] [edit] [menu]|
|
||||||
|
| em Trending (DVM) [unpin] [menu] |
|
||||||
|
| |
|
||||||
|
| My Feeds |
|
||||||
|
| em Dev Talk [pin] [edit] [menu] |
|
||||||
|
| em Memes [pin] [edit] [menu] |
|
||||||
|
| |
|
||||||
|
| Algo Feeds |
|
||||||
|
| em Primal Popular [pin] [menu] |
|
||||||
|
| |
|
||||||
|
| [+ Create Feed] [Browse DVMs] |
|
||||||
|
+---------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Research Insights: Drawer
|
||||||
|
|
||||||
|
- Extend `AppDrawerTab` enum + `AppDrawerState` with filtered feeds
|
||||||
|
- `Cmd+Shift+F` → add `MenuBar` item with `KeyShortcut(Key.F, meta=true, shift=true)` that sets `showAppDrawer=true` + `appDrawerInitialTab=FEEDS`
|
||||||
|
- Use `derivedStateOf` for search filtering (avoids recomposition on every keystroke when filtered result unchanged)
|
||||||
|
- LazyColumn with `key = { it.id }` + `Modifier.animateItem()` for smooth reorder
|
||||||
|
- Right-click: `onPointerEvent(PointerEventType.Press)` + `isSecondaryPressed` → `DropdownMenu` (existing pattern in AppDrawer)
|
||||||
|
- For drawer reorder: use **Calvin-LL/Reorderable** (v3.1.0, full KMP support)
|
||||||
|
|
||||||
|
## Feed Creation Paths
|
||||||
|
|
||||||
|
| Path | Entry | Result |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Search -> Feed | Search results -> "Save as Feed" | SearchQuery -> FeedSource.Filter |
|
||||||
|
| Builder | Drawer -> "+ Create Feed" | Feed Builder dialog |
|
||||||
|
| DVM Browse | Drawer -> "Browse DVMs" -> pick | FeedSource.DVM |
|
||||||
|
| Import | Paste naddr in search/drawer | Fetch kind 31890 -> add |
|
||||||
|
|
||||||
|
## Feed Sharing
|
||||||
|
|
||||||
|
| Action | Mechanism |
|
||||||
|
|--------|-----------|
|
||||||
|
| Publish | Menu -> "Publish to Relays" -> signs kind 31890 |
|
||||||
|
| Share | After publish -> "Copy naddr" via `NAddress.create(31890, pubkey, dTag, relays)` |
|
||||||
|
| Import | Paste naddr -> client decodes with `Nip19Parser` -> if kind==31890 render feed card -> "Add to My Feeds" |
|
||||||
|
|
||||||
|
## Kind 31890 Event Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
kind: 31890 (addressable replaceable)
|
||||||
|
content: JSON-serialized FeedSource (see schema below)
|
||||||
|
tags:
|
||||||
|
["d", "<feed-id>"]
|
||||||
|
["title", "<feed name>"]
|
||||||
|
["emoji", "<single emoji>"]
|
||||||
|
["alt", "Feed definition: <name>"]
|
||||||
|
// Discoverability tags (duplicated from content for relay filtering):
|
||||||
|
["t", "<hashtag>"] // for each hashtag in filter
|
||||||
|
["p", "<author-hex>"] // for each author in filter
|
||||||
|
["relay", "<relay-url>"] // for relay-based feeds
|
||||||
|
["a", "31990:<dvm-pubkey>:<d>"] // DVM reference
|
||||||
|
["a", "30000:<pubkey>:<d>"] // PeopleList reference
|
||||||
|
["a", "30015:<pubkey>:<d>"] // InterestSet reference
|
||||||
|
```
|
||||||
|
|
||||||
|
**Content JSON schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "filter|people_list|interest_set|dvm|relay|global|following",
|
||||||
|
"hashtags": ["bitcoin"],
|
||||||
|
"authors": ["hex..."],
|
||||||
|
"relays": ["wss://..."],
|
||||||
|
"exclude_authors": ["hex..."],
|
||||||
|
"exclude_keywords": ["spam"],
|
||||||
|
"kinds": [1, 6, 30023],
|
||||||
|
"refresh": "live|poll_5min",
|
||||||
|
"source_address": "30000:hex:dtag"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Data Model + Persistence
|
||||||
|
|
||||||
|
**Location:** `commons/src/commonMain/kotlin/.../feeds/custom/`
|
||||||
|
|
||||||
|
- `FeedDefinition` data class with `@Immutable`, `ImmutableList` collections
|
||||||
|
- `FeedSource` sealed interface with all variants
|
||||||
|
- `RefreshMode` enum
|
||||||
|
- `FeedDefinitionRepository` with `StateFlow<ImmutableList<FeedDefinition>>` + `groupedFeeds` + `pinnedFeeds`
|
||||||
|
- `FeedDefinitionSerializer` (JSON via Jackson, exhaustive `when` on FeedSource for compile safety)
|
||||||
|
- `FeedDefinitionBuilder` DSL for tests and programmatic creation
|
||||||
|
- Account-scoped persistence (serialize alongside AccountSettings)
|
||||||
|
- Pre-create Following + Global as defaults on first launch
|
||||||
|
- Unit tests for serialization round-trip + builder DSL
|
||||||
|
|
||||||
|
### Phase 1.5: FeedFilter Mapping + Relay Subscriptions
|
||||||
|
|
||||||
|
**Location:** `commons/src/commonMain/kotlin/.../feeds/custom/`
|
||||||
|
|
||||||
|
This is the critical bridge between data model and UI rendering.
|
||||||
|
|
||||||
|
**FeedFilter per FeedSource variant:**
|
||||||
|
|
||||||
|
| FeedSource | Filter Type | Base Class |
|
||||||
|
|------------|-------------|------------|
|
||||||
|
| Filter | `CustomFilterFeedFilter` | `AdditiveComplexFeedFilter` |
|
||||||
|
| Following | Reuse existing `HomeFeedFilter` | — |
|
||||||
|
| Global | Reuse existing `GlobalFeedFilter` | — |
|
||||||
|
| PeopleList | Resolve ATag -> extract pubkeys -> author filter | `AdditiveComplexFeedFilter` |
|
||||||
|
| InterestSet | Resolve ATag -> extract hashtags -> tag filter | `AdditiveComplexFeedFilter` |
|
||||||
|
| DVM | `DvmFeedFilter` (non-additive, results from external) | `FeedFilter` |
|
||||||
|
| SingleRelay | `CustomFilterFeedFilter` (targeted to one relay) | `AdditiveComplexFeedFilter` |
|
||||||
|
|
||||||
|
**FeedFilterFactory:**
|
||||||
|
```kotlin
|
||||||
|
class FeedFilterFactory {
|
||||||
|
fun createFilter(definition: FeedDefinition): IFeedFilter<Note> = when (definition.source) {
|
||||||
|
is FeedSource.Filter -> CustomFilterFeedFilter(definition)
|
||||||
|
is FeedSource.Following -> HomeFeedFilter(account)
|
||||||
|
is FeedSource.Global -> GlobalFeedFilter()
|
||||||
|
// ... etc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key rules:**
|
||||||
|
- `feedKey() = "custom-${definition.id}"` — unique per feed, avoids cache collision
|
||||||
|
- `excludeAuthors`/`excludeKeywords` applied client-side in `applyFilter()`, not at relay level
|
||||||
|
- Unit test: `applyFilter(event)` must match what `feed()` would include/exclude
|
||||||
|
|
||||||
|
**Relay subscription assembler:**
|
||||||
|
```kotlin
|
||||||
|
class CustomFeedFilterAssembler(private val source: FeedSource.Filter) {
|
||||||
|
fun toFilter(): Filter = filter {
|
||||||
|
if (source.kinds.isNotEmpty()) kinds(source.kinds)
|
||||||
|
if (source.authors.isNotEmpty()) authors(source.authors.toSet())
|
||||||
|
if (source.hashtags.isNotEmpty()) tags("t", source.hashtags.toSet())
|
||||||
|
limit(200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ViewModel selection:**
|
||||||
|
- Standard feeds (hashtags, authors, relays) -> `FeedViewModel`
|
||||||
|
- DVM feeds -> `FeedViewModel` with poll-based invalidation
|
||||||
|
- PeopleList feeds -> `ListChangeFeedViewModel` (membership changes)
|
||||||
|
|
||||||
|
**EOSE-aware loading state:**
|
||||||
|
```kotlin
|
||||||
|
class CustomFeedSubscriptionState(
|
||||||
|
val events: StateFlow<List<Note>>,
|
||||||
|
val eoseReceived: StateFlow<Boolean>,
|
||||||
|
val lastRefreshed: StateFlow<Instant?>,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Subscription lifecycle:**
|
||||||
|
- Only the ACTIVE feed has a live subscription
|
||||||
|
- Pinned feeds not currently displayed = NO open subscription
|
||||||
|
- On switch: old feed `unsubscribe()`, new feed `subscribe()`
|
||||||
|
- For POLL_5MIN: subscribe -> wait EOSE -> unsubscribe -> timer -> repeat
|
||||||
|
|
||||||
|
**Invalidation signals per FeedSource:**
|
||||||
|
- `Filter` with authors -> invalidate when those authors' notes arrive in LocalCache
|
||||||
|
- `Following` -> invalidate on `Account.followListFlow` change
|
||||||
|
- `PeopleList` -> invalidate when referenced list event updates
|
||||||
|
|
||||||
|
### Phase 2: Sidebar Pinned Feeds
|
||||||
|
|
||||||
|
**Location:** `desktopApp/src/jvmMain/.../deck/DeckSidebar.kt`
|
||||||
|
|
||||||
|
- Add params to `DeckSidebar`: `pinnedFeeds`, `activeFeedId`, `onSwitchFeed`, `onOpenFeedsDrawer`
|
||||||
|
- Insert pinned feed emoji buttons between "Add Column" and spacer
|
||||||
|
- Active state: `primaryContainer` background + `CircleShape`
|
||||||
|
- Click switches active feed (triggers subscription swap)
|
||||||
|
- Drag-to-reorder: `detectDragGestures` on each item (3 items, Column, no library needed)
|
||||||
|
- Tooltip with shortcut hint: `"${feed.name} (Cmd+${index+1})"`
|
||||||
|
- "More" button (MaterialSymbols.MoreHoriz) opens drawer on Feeds tab
|
||||||
|
- Wire `Cmd+1/2/3` via `MenuBar` items in "Feeds" menu (OS-aware: meta on macOS, ctrl on others)
|
||||||
|
|
||||||
|
### Phase 3: App Drawer Feeds Tab
|
||||||
|
|
||||||
|
**Location:** `desktopApp/src/jvmMain/.../deck/AppDrawer.kt`
|
||||||
|
|
||||||
|
- Add `FEEDS` to `AppDrawerTab` enum
|
||||||
|
- Extend `AppDrawerState` with `filteredFeeds()` method + keyboard nav for 3rd tab
|
||||||
|
- `Cmd+Shift+F` → MenuBar item that opens drawer on Feeds tab (pass `appDrawerInitialTab`)
|
||||||
|
- Feed list grouped via `groupedFeeds` StateFlow (pre-computed in repository)
|
||||||
|
- Search: `derivedStateOf` filtering by name/emoji
|
||||||
|
- Pin/unpin buttons (grayed out at 3 cap, emit `FeedEvent.PinLimitReached`)
|
||||||
|
- Right-click: `onPointerEvent` + `isSecondaryPressed` -> DropdownMenu (Edit, Duplicate, Delete, Publish)
|
||||||
|
- Drag-reorder in pinned section: Calvin-LL/Reorderable v3.1.0 with `LazyColumn` + `key = { it.id }`
|
||||||
|
- `animateItem()` for smooth movement on reorder
|
||||||
|
|
||||||
|
### Phase 4: Feed Builder Dialog
|
||||||
|
|
||||||
|
**Location:** `commons/src/commonMain/.../feeds/custom/ui/` (composable) + `desktopApp` (host)
|
||||||
|
|
||||||
|
**State hoisting pattern:**
|
||||||
|
```kotlin
|
||||||
|
@Stable
|
||||||
|
class FeedBuilderState(initial: FeedDefinition?) {
|
||||||
|
var name by mutableStateOf(initial?.name ?: "")
|
||||||
|
var emoji by mutableStateOf(initial?.emoji ?: "")
|
||||||
|
val hashtags = mutableStateListOf<String>()
|
||||||
|
val authors = mutableStateListOf<HexKey>()
|
||||||
|
val relays = mutableStateListOf<String>()
|
||||||
|
val excludeAuthors = mutableStateListOf<HexKey>()
|
||||||
|
val excludeKeywords = mutableStateListOf<String>()
|
||||||
|
// ...
|
||||||
|
fun toDefinition(): FeedDefinition = ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Stateless composable: `FeedBuilderDialog(initialDefinition, onSave, onDismiss)`
|
||||||
|
- Internal state via `rememberFeedBuilderState(initial)`
|
||||||
|
- Emoji picker: simple grid of common emojis in `FlowRow` (use Emoji.kt data library for full Unicode set)
|
||||||
|
- Author autocomplete via ViewModel (never query LocalCache directly from composable)
|
||||||
|
- `dismissOnBackPress = true, dismissOnClickOutside = false` (prevent accidental data loss)
|
||||||
|
- Material3: `AlertDialog` or `Dialog` with `surface` bg
|
||||||
|
|
||||||
|
### Phase 5: Search -> Feed Bridge
|
||||||
|
|
||||||
|
**Location:** `commons/src/commonMain/.../feeds/custom/`
|
||||||
|
|
||||||
|
- `SearchQuery.toFeedDefinition()` extension
|
||||||
|
- Maps hashtag operators -> `FeedSource.Filter.hashtags`
|
||||||
|
- Maps from: operators -> `FeedSource.Filter.authors`
|
||||||
|
- Maps relay: operators -> `FeedSource.Filter.relays`
|
||||||
|
- Maps exclude operators -> excludeAuthors/excludeKeywords
|
||||||
|
- Maps kind: operators -> `FeedSource.Filter.kinds`
|
||||||
|
- "Save as Feed" button in search results UI
|
||||||
|
- Uses `FeedDefinitionBuilder` DSL internally
|
||||||
|
|
||||||
|
### Phase 6: DVM Marketplace
|
||||||
|
|
||||||
|
**Location:** `desktopApp/src/jvmMain/.../feeds/`
|
||||||
|
|
||||||
|
- Browse kind 31990 `AppDefinitionEvent` filtered by `isTaggedKind(5300)` (existing Quartz class)
|
||||||
|
- List with name, description, author, cost indicator
|
||||||
|
- Preview: send kind 5300 request, show results in preview panel
|
||||||
|
- "Add to My Feeds" creates `FeedSource.DVM(address)` entry
|
||||||
|
- Zap confirm popup when kind 7000 status = "payment-required" with `firstAmount()` + invoice
|
||||||
|
- DVM request goes to DVM's advertised relays (from kind 31990 `relay` tags)
|
||||||
|
- Response subscription listens on both user's relays AND DVM's relays
|
||||||
|
- Use `MetadataPreloader` for bulk-fetching author metadata of returned notes
|
||||||
|
- Reuse existing `NIP90ContentDiscoveryRequestEvent.build()` pattern from Quartz
|
||||||
|
|
||||||
|
### Phase 7: Publish/Import (kind 31890)
|
||||||
|
|
||||||
|
**Location:** `quartz/src/commonMain/.../feedDefinition/` (event type) + `commons` (UI)
|
||||||
|
|
||||||
|
- `FeedDefinitionEvent` extends `BaseAddressableEvent` (kind 31890)
|
||||||
|
- d-tag = feed UUID, content = JSON FeedSource, tags for discoverability
|
||||||
|
- Serialize `FeedDefinition` -> event via `FeedDefinitionEvent.build(signer, definition)`
|
||||||
|
- Parse kind 31890 events -> `FeedDefinition` via content JSON deserialization
|
||||||
|
- "Publish to Relays" action in feed context menu (signs + publishes)
|
||||||
|
- "Copy naddr" via `NAddress.create(31890, pubkey, dTag, relays)`
|
||||||
|
- Import: `Nip19Parser` detects naddr with kind 31890 -> fetch event -> render feed card preview
|
||||||
|
- "Add to My Feeds" clones with new UUID (marks as not-published-by-me)
|
||||||
|
- On login: subscribe to own kind 31890 + kind 10090 to restore from relay
|
||||||
|
|
||||||
|
### Cross-Device Sync (kind 10090)
|
||||||
|
|
||||||
|
- Sync pinned feed addresses with existing `FavoriteAlgoFeedsListEvent` (kind 10090)
|
||||||
|
- On pin/unpin, update kind 10090 event with current pinned feed addresses
|
||||||
|
- On login/restore, fetch own kind 10090, resolve `AddressBookmark` entries, populate sidebar
|
||||||
|
- This reuses the existing protocol — no new event kind needed for pin sync
|
||||||
|
|
||||||
|
## File Map (expected new files)
|
||||||
|
|
||||||
|
```
|
||||||
|
commons/src/commonMain/kotlin/.../feeds/custom/
|
||||||
|
FeedDefinition.kt # @Immutable data class + FeedSource + RefreshMode
|
||||||
|
FeedDefinitionRepository.kt # StateFlow-based, account-scoped
|
||||||
|
FeedDefinitionSerializer.kt # JSON serialization (exhaustive when)
|
||||||
|
FeedDefinitionBuilder.kt # DSL for tests + SearchQuery bridge
|
||||||
|
GroupedFeeds.kt # @Immutable pre-computed grouping
|
||||||
|
FeedEvent.kt # SharedFlow events (PinLimitReached, etc.)
|
||||||
|
SearchQueryToFeed.kt # SearchQuery.toFeedDefinition() extension
|
||||||
|
filters/
|
||||||
|
CustomFilterFeedFilter.kt # AdditiveComplexFeedFilter for FeedSource.Filter
|
||||||
|
FeedFilterFactory.kt # FeedSource -> IFeedFilter<Note> mapping
|
||||||
|
assemblers/
|
||||||
|
CustomFeedFilterAssembler.kt # FeedSource.Filter -> relay Filter
|
||||||
|
PeopleListFilterAssembler.kt # Resolve ATag -> author set -> Filter
|
||||||
|
DvmFeedSubscribable.kt # NIP-90 request/response lifecycle
|
||||||
|
ui/
|
||||||
|
FeedBuilderDialog.kt # Shared composable (stateless)
|
||||||
|
FeedBuilderState.kt # @Stable state holder
|
||||||
|
FeedCard.kt # Feed preview card
|
||||||
|
EmojiPicker.kt # Simple emoji grid (FlowRow)
|
||||||
|
|
||||||
|
desktopApp/src/jvmMain/kotlin/.../deck/
|
||||||
|
FeedSidebarSection.kt # Pinned feeds in sidebar
|
||||||
|
FeedDrawerTab.kt # FEEDS tab content
|
||||||
|
DvmMarketplace.kt # DVM browse UI
|
||||||
|
|
||||||
|
quartz/src/commonMain/kotlin/.../feedDefinition/
|
||||||
|
FeedDefinitionEvent.kt # kind 31890 (BaseAddressableEvent)
|
||||||
|
|
||||||
|
commons/src/commonTest/kotlin/.../feeds/custom/
|
||||||
|
FeedDefinitionSerializerTest.kt
|
||||||
|
FeedDefinitionBuilderTest.kt
|
||||||
|
SearchQueryToFeedTest.kt
|
||||||
|
CustomFeedFilterAssemblerTest.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies on Existing Code
|
||||||
|
|
||||||
|
| Component | Location | Usage |
|
||||||
|
|-----------|----------|-------|
|
||||||
|
| `TopFilter` | `amethyst/.../AccountSettings.kt` | Reference; `FeedSource.toTopFilter()` for bridge |
|
||||||
|
| `FavoriteAlgoFeedsOrchestrator` | `amethyst/.../algoFeeds/` | Extract to commons for DVM reuse |
|
||||||
|
| `FavoriteAlgoFeedsListEvent` (kind 10090) | `quartz/.../nip51Lists/` | Pinned feed sync |
|
||||||
|
| `NIP90ContentDiscoveryRequestEvent` | `quartz/.../nip90Dvms/` | DVM request building |
|
||||||
|
| `AppDefinitionEvent` (kind 31990) | `quartz/.../nip89AppHandlers/` | DVM marketplace discovery |
|
||||||
|
| `NAddress` | `quartz/.../nip19Bech32/entities/` | naddr encode/decode |
|
||||||
|
| `Nip19Parser` | `quartz/.../nip19Bech32/` | Detect pasted naddr |
|
||||||
|
| `SearchQuery` / `QueryParser` | `commons/.../search/` | Phase 5 bridge |
|
||||||
|
| `AppDrawer` / `AppDrawerTab` | `desktopApp/.../deck/AppDrawer.kt` | Phase 3 integration |
|
||||||
|
| `DeckSidebar` | `desktopApp/.../deck/DeckSidebar.kt` | Phase 2 integration |
|
||||||
|
| `PinnedNavBarState` | `desktopApp/.../deck/PinnedNavBarState.kt` | Reference pattern for pin state |
|
||||||
|
| `HomeFeed` rendering | `desktopApp/.../home/` | Phase 2 content reuse |
|
||||||
|
| `BaseAddressableEvent` | `quartz/.../nip01Core/core/` | Base for kind 31890 |
|
||||||
|
| `PeopleListEvent` / `InterestSetEvent` | `quartz/.../nip51Lists/` | Resolve ATag -> members |
|
||||||
|
| `MetadataPreloader` | `commons/.../relayClient/` | Bulk metadata fetch for feed results |
|
||||||
|
| `FeedMetadataCoordinator` | `commons/.../relayClient/` | Coordinate metadata for visible notes |
|
||||||
|
| `ComposeSubscriptionManager` | `commons/.../relayClient/` | Subscription lifecycle |
|
||||||
|
|
||||||
|
## External Dependencies
|
||||||
|
|
||||||
|
| Library | Version | Usage |
|
||||||
|
|---------|---------|-------|
|
||||||
|
| `sh.calvin.reorderable:reorderable` | 3.1.0 | Drag-reorder in drawer LazyColumn |
|
||||||
|
| `org.kodein.emoji:emoji-compose` (Emoji.kt) | latest | Emoji data for picker grid |
|
||||||
|
| `kotlinx.collections.immutable` | (already in project) | ImmutableList for stability |
|
||||||
|
|
||||||
|
## Risk & Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| DVM latency makes feeds feel broken | Show loading skeleton + "last refreshed" timestamp + EOSE state |
|
||||||
|
| Kind 31890 NIP still in draft | Keep publish optional; local-first always works |
|
||||||
|
| Sidebar drag-reorder complexity | Only 3 items — simple `detectDragGestures`, no library |
|
||||||
|
| Feed builder autocomplete for authors | Reuse existing user search via ViewModel (never direct LocalCache query) |
|
||||||
|
| Account switching breaks feed state | Account-scoped repository auto-swaps with account |
|
||||||
|
| Kind 10090 sync conflicts | Last-write-wins (same as other replaceable events) |
|
||||||
|
| Preferences 8KB limit (if used for temp storage) | JSON chunking pattern or switch to account serialization |
|
||||||
|
| DVM payment format inconsistency | Support bolt11 from amount tag + NIP-57 zap; show raw amount if format unclear |
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Custom Feeds — Testing Sheet
|
||||||
|
|
||||||
|
**Branch:** `feat/desktop-custom-feeds`
|
||||||
|
**Run:** `./gradlew :desktopApp:run`
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Wiring is complete. `FeedDefinitionRepository` is provided via `CompositionLocalProvider` in `Main.kt`.
|
||||||
|
Default feeds (Following + Global) are loaded on startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Data Model + Serialization (Unit Tests)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :commons:jvmTest --tests "com.vitorpamplona.amethyst.commons.feeds.custom.*"
|
||||||
|
```
|
||||||
|
|
||||||
|
| # | Test | Expected |
|
||||||
|
|---|------|----------|
|
||||||
|
| 1 | `FeedDefinitionSerializerTest.roundTripFilterSource` | Serialize/deserialize Filter with all fields |
|
||||||
|
| 2 | `FeedDefinitionSerializerTest.roundTripGlobalSource` | Global source round-trip |
|
||||||
|
| 3 | `FeedDefinitionSerializerTest.roundTripFollowingSource` | Following source round-trip |
|
||||||
|
| 4 | `FeedDefinitionSerializerTest.roundTripDvmSource` | DVM source round-trip |
|
||||||
|
| 5 | `FeedDefinitionSerializerTest.roundTripPeopleListSource` | PeopleList source round-trip |
|
||||||
|
| 6 | `FeedDefinitionSerializerTest.roundTripInterestSetSource` | InterestSet source round-trip |
|
||||||
|
| 7 | `FeedDefinitionSerializerTest.roundTripSingleRelaySource` | SingleRelay source round-trip |
|
||||||
|
| 8 | `FeedDefinitionSerializerTest.multipleFeeds` | Multiple feeds in single JSON |
|
||||||
|
| 9 | `FeedDefinitionSerializerTest.emptyJsonReturnsEmptyList` | Empty/blank input |
|
||||||
|
| 10 | `FeedDefinitionSerializerTest.defaultFeedsAreValid` | Default feeds (Following+Global) pinned |
|
||||||
|
| 11 | `SearchQueryToFeedTest.convertsHashtagsToFeedFilter` | SearchQuery hashtags -> FeedSource.Filter |
|
||||||
|
| 12 | `SearchQueryToFeedTest.convertsAuthorsToFeedFilter` | SearchQuery authors -> FeedSource.Filter |
|
||||||
|
| 13 | `SearchQueryToFeedTest.convertsExcludeTerms` | Exclude terms mapping |
|
||||||
|
| 14 | `SearchQueryToFeedTest.convertsKinds` | Kind mapping |
|
||||||
|
| 15 | `SearchQueryToFeedTest.canBecomeFeedWithHashtags` | canBecomeFeed = true |
|
||||||
|
| 16 | `SearchQueryToFeedTest.canBecomeFeedWithAuthors` | canBecomeFeed = true |
|
||||||
|
| 17 | `SearchQueryToFeedTest.canNotBecomeFeedEmpty` | canBecomeFeed = false |
|
||||||
|
| 18 | `SearchQueryToFeedTest.canNotBecomeFeedTextOnly` | canBecomeFeed = false |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2/3: Sidebar + App Drawer Feeds Tab (Manual)
|
||||||
|
|
||||||
|
### App Drawer Integration
|
||||||
|
|
||||||
|
| # | Action | Expected |
|
||||||
|
|---|--------|----------|
|
||||||
|
| 1 | Open app drawer (Cmd+K) | Drawer opens, shows 3 tabs: Screens, Workspaces, Feeds |
|
||||||
|
| 2 | Click "Feeds" tab | Feed list appears (empty initially + defaults after wiring) |
|
||||||
|
| 3 | Cmd+Shift+F | Drawer opens directly on Feeds tab |
|
||||||
|
| 4 | Feeds tab shows sections | "Pinned", "My Feeds", "Algo Feeds" sections visible |
|
||||||
|
| 5 | Click a feed row | Drawer closes, main content switches to that feed |
|
||||||
|
|
||||||
|
### Feed CRUD (in Feeds tab)
|
||||||
|
|
||||||
|
| # | Action | Expected |
|
||||||
|
|---|--------|----------|
|
||||||
|
| 6 | Click "+ Create Feed" | Feed Builder dialog opens |
|
||||||
|
| 7 | Enter name + emoji + hashtags | Form validates (Save enabled when name + at least 1 source) |
|
||||||
|
| 8 | Click "Save" | Dialog closes, new feed appears in "My Feeds" section |
|
||||||
|
| 9 | Click "Pin" on a feed | Feed moves to "Pinned" section (max 3) |
|
||||||
|
| 10 | Try pinning a 4th feed | Pin fails (PinLimitReached event, button stays) |
|
||||||
|
| 11 | Click "Unpin" on a pinned feed | Feed moves back to "My Feeds" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Feed Builder Dialog (Manual)
|
||||||
|
|
||||||
|
| # | Action | Expected |
|
||||||
|
|---|--------|----------|
|
||||||
|
| 1 | Open builder, leave empty | "Save" button disabled |
|
||||||
|
| 2 | Enter name only | Still disabled (needs source) |
|
||||||
|
| 3 | Enter name + add hashtag "bitcoin" | "Save" enabled |
|
||||||
|
| 4 | Add multiple hashtags | All show as chips, removable by click |
|
||||||
|
| 5 | Add author pubkey | Shows as chip |
|
||||||
|
| 6 | Add relay URL | Shows as chip |
|
||||||
|
| 7 | Add exclude keyword | Shows as chip in exclude section |
|
||||||
|
| 8 | Toggle refresh mode (Live / Every 5 min) | Selection changes |
|
||||||
|
| 9 | Click "Cancel" | Dialog dismissed, no feed created |
|
||||||
|
| 10 | Click "Save" | Feed created with all specified params |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Search -> Feed Bridge (Manual)
|
||||||
|
|
||||||
|
| # | Action | Expected |
|
||||||
|
|---|--------|----------|
|
||||||
|
| 1 | Search "#bitcoin" in search screen | Results appear |
|
||||||
|
| 2 | Programmatically: `SearchQuery(hashtags=listOf("bitcoin")).canBecomeFeed()` | Returns true |
|
||||||
|
| 3 | `query.toFeedDefinition("BTC", "₿")` | Creates FeedDefinition with FeedSource.Filter(hashtags=["bitcoin"]) |
|
||||||
|
|
||||||
|
*(Note: "Save as Feed" button in search UI is not yet wired — the bridge logic exists but UI button pending)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1.5: Custom Feed Content (Manual)
|
||||||
|
|
||||||
|
| # | Action | Expected |
|
||||||
|
|---|--------|----------|
|
||||||
|
| 1 | Create feed with hashtag "nostr" | Feed created |
|
||||||
|
| 2 | Select that feed from drawer | Content area shows FeedScreen |
|
||||||
|
| 3 | Wait for events | Notes containing #nostr appear (from relay subscription) |
|
||||||
|
| 4 | Create feed with author pubkey | Only notes from that author appear |
|
||||||
|
| 5 | Create feed with excludeKeyword "spam" | Notes containing "spam" filtered out |
|
||||||
|
| 6 | Switch between feeds | Content updates, old subscription closed |
|
||||||
|
| 7 | Feed with specific relay URL | Subscription targets only that relay |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Kind 31890 Event (Compile-level)
|
||||||
|
|
||||||
|
| # | Check | Expected |
|
||||||
|
|---|-------|----------|
|
||||||
|
| 1 | `FeedDefinitionEvent.KIND == 31890` | Correct |
|
||||||
|
| 2 | `FeedDefinitionEvent` extends `BaseAddressableEvent` | Has `dTag()`, `address()`, `addressTag()` |
|
||||||
|
| 3 | `title()` extracts from tags | Parses `["title", "..."]` tag |
|
||||||
|
| 4 | `emoji()` extracts from tags | Parses `["emoji", "..."]` tag |
|
||||||
|
| 5 | `feedConfigJson()` returns content | JSON-serialized FeedSource |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compilation Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full compile (all modules)
|
||||||
|
./gradlew :quartz:compileKotlinJvm :commons:compileKotlinJvm :desktopApp:compileKotlin
|
||||||
|
|
||||||
|
# Unit tests
|
||||||
|
./gradlew :commons:jvmTest --tests "com.vitorpamplona.amethyst.commons.feeds.custom.*"
|
||||||
|
|
||||||
|
# Code formatting
|
||||||
|
./gradlew spotlessApply
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wiring TODO (Required for Manual Testing)
|
||||||
|
|
||||||
|
The `FeedDefinitionRepository` needs to be instantiated and provided via `CompositionLocalProvider` in `Main.kt`. Steps:
|
||||||
|
|
||||||
|
1. In `Main.kt` `App()` function (around line 710), create:
|
||||||
|
```kotlin
|
||||||
|
val feedRepository = remember { FeedDefinitionRepository(appScope) }
|
||||||
|
LaunchedEffect(Unit) { feedRepository.load(defaultFeeds()) }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. In the `CompositionLocalProvider` block (around line 1166), add:
|
||||||
|
```kotlin
|
||||||
|
LocalFeedRepository provides feedRepository,
|
||||||
|
LocalFeedScope provides appScope,
|
||||||
|
```
|
||||||
|
|
||||||
|
3. This enables:
|
||||||
|
- `FeedsDrawerTab` to read/write feeds
|
||||||
|
- `CustomFeedScreen` to resolve feed definitions by ID
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Limitations (not bugs)
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| DVM marketplace (Phase 6) | Button wired, content shows "coming soon" |
|
||||||
|
| PeopleList/InterestSet resolution | Shows "coming soon" — needs ATag resolution |
|
||||||
|
| Kind 10090 cross-device sync | Not wired yet — local-only |
|
||||||
|
| Feed publish/import (naddr) | Event class exists, publish UI not wired |
|
||||||
|
| Sidebar pinned feed emojis | Needs `PinnedNavBarState` integration for feed-specific items |
|
||||||
|
| Account-scoped persistence | Currently in-memory only (needs serialize to account settings) |
|
||||||
|
| Cmd+1/2/3 shortcuts | Not wired yet (needs MenuBar items in Main.kt) |
|
||||||
|
| Drag-to-reorder | Repository supports it, UI gesture handlers pending |
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Feed Builder Enhancements
|
||||||
|
|
||||||
|
**Date:** 2026-05-05
|
||||||
|
**Status:** Implementation
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Enhance `FeedBuilderDialog` with 6 items:
|
||||||
|
1. npub auto-decode (paste npub -> hex + display name)
|
||||||
|
2. Author search (type name -> search LocalCache -> pick)
|
||||||
|
3. Kind filter checkboxes (Notes, Reposts, Articles)
|
||||||
|
4. Edit + Delete feeds (CRUD completeness)
|
||||||
|
5. "Save as Feed" button in search results
|
||||||
|
6. Exclude authors field in dialog
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### 1. npub Auto-Decode
|
||||||
|
|
||||||
|
**Where:** `FeedBuilderDialog.kt` ChipInputField for Authors
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
- On add: if input starts with `npub1`, decode via `decodePublicKeyAsHexOrNull()`
|
||||||
|
- If decode succeeds, add hex to authors list
|
||||||
|
- Show display name in chip (resolve from LocalCache)
|
||||||
|
|
||||||
|
### 2. Author Search
|
||||||
|
|
||||||
|
**Where:** New `AuthorSearchField` composable in `FeedBuilderDialog.kt`
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
- Replace plain text field for authors with search field
|
||||||
|
- Type >= 2 chars -> filter `DesktopLocalCache.users` by displayName/nip05
|
||||||
|
- Show dropdown with matching profiles (avatar placeholder + name + npub short)
|
||||||
|
- Click adds hex to authors list
|
||||||
|
- Still allow raw npub/hex paste (falls through to auto-decode)
|
||||||
|
|
||||||
|
**Data source:** `DesktopLocalCache.users` (already populated from metadata subscriptions)
|
||||||
|
|
||||||
|
### 3. Kind Filter Checkboxes
|
||||||
|
|
||||||
|
**Where:** `FeedBuilderDialog.kt`, new section between relays and exclude
|
||||||
|
|
||||||
|
**UI:** FlowRow of FilterChips:
|
||||||
|
- [x] Notes (kind 1)
|
||||||
|
- [x] Reposts (kind 6, 16)
|
||||||
|
- [ ] Articles (kind 30023)
|
||||||
|
- [ ] Highlights (kind 9802)
|
||||||
|
- [ ] Reactions (kind 7)
|
||||||
|
|
||||||
|
Default: Notes + Reposts checked. Maps to `FeedBuilderState.kinds`.
|
||||||
|
|
||||||
|
### 4. Edit + Delete in Drawer
|
||||||
|
|
||||||
|
**Where:** `FeedsDrawerTab.kt`
|
||||||
|
|
||||||
|
**Edit:** Add edit button on each FeedRow -> opens `FeedBuilderDialog(initial = feed)`
|
||||||
|
- On save: calls `feedRepository.update(feed)`
|
||||||
|
|
||||||
|
**Delete:** Add delete in context or as swipe action
|
||||||
|
- Confirm dialog: "Delete feed X?"
|
||||||
|
- Calls `feedRepository.delete(feed.id)`
|
||||||
|
|
||||||
|
### 5. "Save as Feed" from Search
|
||||||
|
|
||||||
|
**Where:** `SearchScreen.kt` or `SearchResultsList.kt`
|
||||||
|
|
||||||
|
**UI:** Show "Save as Feed" button when `query.canBecomeFeed()` is true
|
||||||
|
- Button appears in the header area, next to the search bar actions
|
||||||
|
- Click opens `FeedBuilderDialog` pre-filled from `query.toFeedDefinition()`
|
||||||
|
|
||||||
|
### 6. Exclude Authors in Dialog
|
||||||
|
|
||||||
|
**Where:** `FeedBuilderDialog.kt`
|
||||||
|
|
||||||
|
**UI:** Same chip input pattern as regular authors but for excludes
|
||||||
|
- Uses same author search/npub-decode logic
|
||||||
|
- Maps to `FeedBuilderState.excludeAuthors`
|
||||||
|
|
||||||
|
## File Changes
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `FeedBuilderDialog.kt` | Author search field, npub decode, kind checkboxes, exclude authors |
|
||||||
|
| `FeedsDrawerTab.kt` | Edit/delete buttons on feed rows |
|
||||||
|
| `SearchScreen.kt` | "Save as Feed" button |
|
||||||
|
| `FeedBuilderState.kt` | No changes needed (already has all fields) |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `decodePublicKeyAsHexOrNull` from `quartz/nip19Bech32`
|
||||||
|
- `DesktopLocalCache.users` for profile search
|
||||||
|
- `SearchQuery.canBecomeFeed()` + `toFeedDefinition()` already exist
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* 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.feeds.custom
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class FeedBuilderState(
|
||||||
|
initial: FeedDefinition? = null,
|
||||||
|
) {
|
||||||
|
var name by mutableStateOf(initial?.name ?: "")
|
||||||
|
var emoji by mutableStateOf(initial?.emoji ?: "")
|
||||||
|
var refreshMode by mutableStateOf(initial?.refreshMode ?: RefreshMode.LIVE_STREAM)
|
||||||
|
|
||||||
|
val hashtags =
|
||||||
|
mutableStateListOf<String>().apply {
|
||||||
|
(initial?.source as? FeedSource.Filter)?.hashtags?.let { addAll(it) }
|
||||||
|
}
|
||||||
|
val authors =
|
||||||
|
mutableStateListOf<HexKey>().apply {
|
||||||
|
(initial?.source as? FeedSource.Filter)?.authors?.let { addAll(it) }
|
||||||
|
}
|
||||||
|
val relays =
|
||||||
|
mutableStateListOf<String>().apply {
|
||||||
|
(initial?.source as? FeedSource.Filter)?.relays?.let { addAll(it) }
|
||||||
|
}
|
||||||
|
val excludeAuthors =
|
||||||
|
mutableStateListOf<HexKey>().apply {
|
||||||
|
(initial?.source as? FeedSource.Filter)?.excludeAuthors?.let { addAll(it) }
|
||||||
|
}
|
||||||
|
val excludeKeywords =
|
||||||
|
mutableStateListOf<String>().apply {
|
||||||
|
(initial?.source as? FeedSource.Filter)?.excludeKeywords?.let { addAll(it) }
|
||||||
|
}
|
||||||
|
val kinds =
|
||||||
|
mutableStateListOf<Int>().apply {
|
||||||
|
(initial?.source as? FeedSource.Filter)?.kinds?.let { addAll(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val isValid: Boolean
|
||||||
|
get() = name.isNotBlank() && (hashtags.isNotEmpty() || authors.isNotEmpty() || relays.isNotEmpty())
|
||||||
|
|
||||||
|
private val editId: String? = initial?.id
|
||||||
|
|
||||||
|
fun toDefinition(): FeedDefinition {
|
||||||
|
val source =
|
||||||
|
FeedSource.Filter(
|
||||||
|
hashtags = hashtags.toImmutableList(),
|
||||||
|
authors = authors.toImmutableList(),
|
||||||
|
relays = relays.toImmutableList(),
|
||||||
|
excludeAuthors = excludeAuthors.toImmutableList(),
|
||||||
|
excludeKeywords = excludeKeywords.toImmutableList(),
|
||||||
|
kinds = kinds.toImmutableList(),
|
||||||
|
)
|
||||||
|
return FeedDefinition(
|
||||||
|
id =
|
||||||
|
editId ?: java.util.UUID
|
||||||
|
.randomUUID()
|
||||||
|
.toString(),
|
||||||
|
name = name,
|
||||||
|
emoji = emoji,
|
||||||
|
pinned = false,
|
||||||
|
pinOrder = Int.MAX_VALUE,
|
||||||
|
source = source,
|
||||||
|
refreshMode = refreshMode,
|
||||||
|
createdAt = System.currentTimeMillis() / 1000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -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.amethyst.commons.feeds.custom
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class FeedDefinition(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val emoji: String,
|
||||||
|
val pinned: Boolean,
|
||||||
|
val pinOrder: Int,
|
||||||
|
val source: FeedSource,
|
||||||
|
val refreshMode: RefreshMode,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
sealed interface FeedSource {
|
||||||
|
@Immutable
|
||||||
|
data class Filter(
|
||||||
|
val hashtags: ImmutableList<String> = persistentListOf(),
|
||||||
|
val authors: ImmutableList<HexKey> = persistentListOf(),
|
||||||
|
val relays: ImmutableList<String> = persistentListOf(),
|
||||||
|
val excludeAuthors: ImmutableList<HexKey> = persistentListOf(),
|
||||||
|
val excludeKeywords: ImmutableList<String> = persistentListOf(),
|
||||||
|
val kinds: ImmutableList<Int> = persistentListOf(),
|
||||||
|
) : FeedSource
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class PeopleList(
|
||||||
|
val kind: Int,
|
||||||
|
val pubkey: HexKey,
|
||||||
|
val dTag: String,
|
||||||
|
) : FeedSource
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class InterestSet(
|
||||||
|
val kind: Int,
|
||||||
|
val pubkey: HexKey,
|
||||||
|
val dTag: String,
|
||||||
|
) : FeedSource
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class DVM(
|
||||||
|
val kind: Int,
|
||||||
|
val pubkey: HexKey,
|
||||||
|
val dTag: String,
|
||||||
|
) : FeedSource
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class SingleRelay(
|
||||||
|
val url: String,
|
||||||
|
) : FeedSource
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data object Global : FeedSource
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data object Following : FeedSource
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class RefreshMode {
|
||||||
|
LIVE_STREAM,
|
||||||
|
POLL_5MIN,
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
/*
|
||||||
|
* 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.feeds.custom
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
|
class FeedDefinitionBuilder {
|
||||||
|
var name: String = ""
|
||||||
|
var emoji: String = ""
|
||||||
|
var refreshMode: RefreshMode = RefreshMode.LIVE_STREAM
|
||||||
|
private var source: FeedSource? = null
|
||||||
|
|
||||||
|
fun filter(init: FilterBuilder.() -> Unit) {
|
||||||
|
source = FilterBuilder().apply(init).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromPeopleList(
|
||||||
|
kind: Int = 30000,
|
||||||
|
pubkey: HexKey,
|
||||||
|
dTag: String,
|
||||||
|
) {
|
||||||
|
source = FeedSource.PeopleList(kind, pubkey, dTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromDvm(
|
||||||
|
kind: Int = 31990,
|
||||||
|
pubkey: HexKey,
|
||||||
|
dTag: String,
|
||||||
|
) {
|
||||||
|
source = FeedSource.DVM(kind, pubkey, dTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromRelay(url: String) {
|
||||||
|
source = FeedSource.SingleRelay(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun global() {
|
||||||
|
source = FeedSource.Global
|
||||||
|
}
|
||||||
|
|
||||||
|
fun following() {
|
||||||
|
source = FeedSource.Following
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(): FeedDefinition =
|
||||||
|
FeedDefinition(
|
||||||
|
id = generateId(),
|
||||||
|
name = name,
|
||||||
|
emoji = emoji,
|
||||||
|
pinned = false,
|
||||||
|
pinOrder = Int.MAX_VALUE,
|
||||||
|
source = source ?: error("FeedDefinition requires a source"),
|
||||||
|
refreshMode = refreshMode,
|
||||||
|
createdAt = System.currentTimeMillis() / 1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun generateId(): String =
|
||||||
|
java.util.UUID
|
||||||
|
.randomUUID()
|
||||||
|
.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
class FilterBuilder {
|
||||||
|
val hashtags = mutableListOf<String>()
|
||||||
|
val authors = mutableListOf<HexKey>()
|
||||||
|
val relays = mutableListOf<String>()
|
||||||
|
val excludeAuthors = mutableListOf<HexKey>()
|
||||||
|
val excludeKeywords = mutableListOf<String>()
|
||||||
|
val kinds = mutableListOf<Int>()
|
||||||
|
|
||||||
|
fun build(): FeedSource.Filter =
|
||||||
|
FeedSource.Filter(
|
||||||
|
hashtags = hashtags.toImmutableList(),
|
||||||
|
authors = authors.toImmutableList(),
|
||||||
|
relays = relays.toImmutableList(),
|
||||||
|
excludeAuthors = excludeAuthors.toImmutableList(),
|
||||||
|
excludeKeywords = excludeKeywords.toImmutableList(),
|
||||||
|
kinds = kinds.toImmutableList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun feedDefinition(init: FeedDefinitionBuilder.() -> Unit): FeedDefinition = FeedDefinitionBuilder().apply(init).build()
|
||||||
|
|
||||||
|
fun defaultFeeds(): List<FeedDefinition> =
|
||||||
|
listOf(
|
||||||
|
FeedDefinition(
|
||||||
|
id = "default-following",
|
||||||
|
name = "Following",
|
||||||
|
emoji = "\uD83C\uDFE0",
|
||||||
|
pinned = true,
|
||||||
|
pinOrder = 0,
|
||||||
|
source = FeedSource.Following,
|
||||||
|
refreshMode = RefreshMode.LIVE_STREAM,
|
||||||
|
createdAt = 0L,
|
||||||
|
),
|
||||||
|
FeedDefinition(
|
||||||
|
id = "default-global",
|
||||||
|
name = "Global",
|
||||||
|
emoji = "\uD83C\uDF10",
|
||||||
|
pinned = true,
|
||||||
|
pinOrder = 1,
|
||||||
|
source = FeedSource.Global,
|
||||||
|
refreshMode = RefreshMode.LIVE_STREAM,
|
||||||
|
createdAt = 0L,
|
||||||
|
),
|
||||||
|
)
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
/*
|
||||||
|
* 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.feeds.custom
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
const val MAX_PINNED_FEEDS = 3
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class FeedDefinitionRepository(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private val _feeds = MutableStateFlow<ImmutableList<FeedDefinition>>(persistentListOf())
|
||||||
|
val feeds: StateFlow<ImmutableList<FeedDefinition>> = _feeds.asStateFlow()
|
||||||
|
|
||||||
|
val groupedFeeds: StateFlow<GroupedFeeds> =
|
||||||
|
_feeds
|
||||||
|
.map { all ->
|
||||||
|
GroupedFeeds(
|
||||||
|
pinned = all.filter { it.pinned }.sortedBy { it.pinOrder }.toImmutableList(),
|
||||||
|
myFeeds = all.filter { !it.pinned && it.source !is FeedSource.DVM }.toImmutableList(),
|
||||||
|
algoFeeds = all.filter { it.source is FeedSource.DVM }.toImmutableList(),
|
||||||
|
)
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, GroupedFeeds.EMPTY)
|
||||||
|
|
||||||
|
val pinnedFeeds: StateFlow<ImmutableList<FeedDefinition>> =
|
||||||
|
groupedFeeds
|
||||||
|
.map { it.pinned }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<FeedEvent>(replay = 0)
|
||||||
|
val events: SharedFlow<FeedEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
fun load(feeds: List<FeedDefinition>) {
|
||||||
|
_feeds.value = feeds.toImmutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshot(): List<FeedDefinition> = _feeds.value
|
||||||
|
|
||||||
|
suspend fun add(feed: FeedDefinition) {
|
||||||
|
_feeds.value = (_feeds.value + feed).toImmutableList()
|
||||||
|
_events.emit(FeedEvent.Created(feed))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun update(feed: FeedDefinition) {
|
||||||
|
_feeds.value =
|
||||||
|
_feeds.value
|
||||||
|
.map { if (it.id == feed.id) feed else it }
|
||||||
|
.toImmutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun delete(id: String) {
|
||||||
|
_feeds.value = _feeds.value.filter { it.id != id }.toImmutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun pin(id: String): Boolean {
|
||||||
|
val currentPinned = _feeds.value.count { it.pinned }
|
||||||
|
if (currentPinned >= MAX_PINNED_FEEDS) {
|
||||||
|
_events.emit(FeedEvent.PinLimitReached(MAX_PINNED_FEEDS))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_feeds.value =
|
||||||
|
_feeds.value
|
||||||
|
.map {
|
||||||
|
if (it.id == id) it.copy(pinned = true, pinOrder = currentPinned) else it
|
||||||
|
}.toImmutableList()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun unpin(id: String) {
|
||||||
|
_feeds.value =
|
||||||
|
_feeds.value
|
||||||
|
.map { if (it.id == id) it.copy(pinned = false, pinOrder = Int.MAX_VALUE) else it }
|
||||||
|
.toImmutableList()
|
||||||
|
// Reindex remaining pinned
|
||||||
|
reindexPinned()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun reorderPinned(
|
||||||
|
fromIndex: Int,
|
||||||
|
toIndex: Int,
|
||||||
|
) {
|
||||||
|
val pinned =
|
||||||
|
_feeds.value
|
||||||
|
.filter { it.pinned }
|
||||||
|
.sortedBy { it.pinOrder }
|
||||||
|
.toMutableList()
|
||||||
|
if (fromIndex !in pinned.indices || toIndex !in pinned.indices) return
|
||||||
|
val item = pinned.removeAt(fromIndex)
|
||||||
|
pinned.add(toIndex, item)
|
||||||
|
val reindexed = pinned.mapIndexed { i, feed -> feed.copy(pinOrder = i) }.associateBy { it.id }
|
||||||
|
_feeds.value =
|
||||||
|
_feeds.value
|
||||||
|
.map { reindexed[it.id] ?: it }
|
||||||
|
.toImmutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reindexPinned() {
|
||||||
|
val pinned = _feeds.value.filter { it.pinned }.sortedBy { it.pinOrder }
|
||||||
|
val reindexed = pinned.mapIndexed { i, feed -> feed.copy(pinOrder = i) }.associateBy { it.id }
|
||||||
|
_feeds.value =
|
||||||
|
_feeds.value
|
||||||
|
.map { reindexed[it.id] ?: it }
|
||||||
|
.toImmutableList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface FeedEvent {
|
||||||
|
data class Created(
|
||||||
|
val feed: FeedDefinition,
|
||||||
|
) : FeedEvent
|
||||||
|
|
||||||
|
data class PinLimitReached(
|
||||||
|
val max: Int,
|
||||||
|
) : FeedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class GroupedFeeds(
|
||||||
|
val pinned: ImmutableList<FeedDefinition>,
|
||||||
|
val myFeeds: ImmutableList<FeedDefinition>,
|
||||||
|
val algoFeeds: ImmutableList<FeedDefinition>,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY = GroupedFeeds(persistentListOf(), persistentListOf(), persistentListOf())
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
@@ -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.amethyst.commons.feeds.custom
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
|
object FeedDefinitionSerializer {
|
||||||
|
private val mapper = ObjectMapper()
|
||||||
|
|
||||||
|
fun serializeList(feeds: List<FeedDefinition>): String {
|
||||||
|
val array = mapper.createArrayNode()
|
||||||
|
feeds.forEach { feed -> array.add(serializeFeed(feed)) }
|
||||||
|
return mapper.writeValueAsString(array)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deserializeList(json: String): List<FeedDefinition> {
|
||||||
|
if (json.isBlank()) return emptyList()
|
||||||
|
val array = mapper.readTree(json) as? ArrayNode ?: return emptyList()
|
||||||
|
return array.mapNotNull { node -> deserializeFeed(node) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeFeed(feed: FeedDefinition): ObjectNode =
|
||||||
|
mapper.createObjectNode().apply {
|
||||||
|
put("id", feed.id)
|
||||||
|
put("name", feed.name)
|
||||||
|
put("emoji", feed.emoji)
|
||||||
|
put("pinned", feed.pinned)
|
||||||
|
put("pinOrder", feed.pinOrder)
|
||||||
|
put("refreshMode", feed.refreshMode.name)
|
||||||
|
put("createdAt", feed.createdAt)
|
||||||
|
set<ObjectNode>("source", serializeSource(feed.source))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deserializeFeed(node: JsonNode): FeedDefinition? {
|
||||||
|
val id = node.get("id")?.asText() ?: return null
|
||||||
|
val name = node.get("name")?.asText() ?: return null
|
||||||
|
val emoji = node.get("emoji")?.asText() ?: ""
|
||||||
|
val pinned = node.get("pinned")?.asBoolean() ?: false
|
||||||
|
val pinOrder = node.get("pinOrder")?.asInt() ?: Int.MAX_VALUE
|
||||||
|
val refreshMode =
|
||||||
|
node.get("refreshMode")?.asText()?.let {
|
||||||
|
try {
|
||||||
|
RefreshMode.valueOf(it)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
RefreshMode.LIVE_STREAM
|
||||||
|
}
|
||||||
|
} ?: RefreshMode.LIVE_STREAM
|
||||||
|
val createdAt = node.get("createdAt")?.asLong() ?: 0L
|
||||||
|
val source = node.get("source")?.let { deserializeSource(it) } ?: return null
|
||||||
|
|
||||||
|
return FeedDefinition(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
emoji = emoji,
|
||||||
|
pinned = pinned,
|
||||||
|
pinOrder = pinOrder,
|
||||||
|
source = source,
|
||||||
|
refreshMode = refreshMode,
|
||||||
|
createdAt = createdAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeSource(source: FeedSource): ObjectNode =
|
||||||
|
mapper.createObjectNode().apply {
|
||||||
|
when (source) {
|
||||||
|
is FeedSource.Filter -> {
|
||||||
|
put("type", "filter")
|
||||||
|
set<ArrayNode>("hashtags", mapper.valueToTree(source.hashtags.toList()))
|
||||||
|
set<ArrayNode>("authors", mapper.valueToTree(source.authors.toList()))
|
||||||
|
set<ArrayNode>("relays", mapper.valueToTree(source.relays.toList()))
|
||||||
|
set<ArrayNode>("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList()))
|
||||||
|
set<ArrayNode>("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList()))
|
||||||
|
set<ArrayNode>("kinds", mapper.valueToTree(source.kinds.toList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedSource.PeopleList -> {
|
||||||
|
put("type", "people_list")
|
||||||
|
put("kind", source.kind)
|
||||||
|
put("pubkey", source.pubkey)
|
||||||
|
put("dTag", source.dTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedSource.InterestSet -> {
|
||||||
|
put("type", "interest_set")
|
||||||
|
put("kind", source.kind)
|
||||||
|
put("pubkey", source.pubkey)
|
||||||
|
put("dTag", source.dTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedSource.DVM -> {
|
||||||
|
put("type", "dvm")
|
||||||
|
put("kind", source.kind)
|
||||||
|
put("pubkey", source.pubkey)
|
||||||
|
put("dTag", source.dTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedSource.SingleRelay -> {
|
||||||
|
put("type", "single_relay")
|
||||||
|
put("url", source.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
FeedSource.Global -> {
|
||||||
|
put("type", "global")
|
||||||
|
}
|
||||||
|
|
||||||
|
FeedSource.Following -> {
|
||||||
|
put("type", "following")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deserializeSource(node: JsonNode): FeedSource? {
|
||||||
|
val type = node.get("type")?.asText() ?: return null
|
||||||
|
return when (type) {
|
||||||
|
"filter" -> {
|
||||||
|
FeedSource.Filter(
|
||||||
|
hashtags = node.get("hashtags")?.map { it.asText() }?.toImmutableList() ?: return null,
|
||||||
|
authors = node.get("authors")?.map { it.asText() }?.toImmutableList() ?: return null,
|
||||||
|
relays = node.get("relays")?.map { it.asText() }?.toImmutableList() ?: return null,
|
||||||
|
excludeAuthors = node.get("excludeAuthors")?.map { it.asText() }?.toImmutableList() ?: return null,
|
||||||
|
excludeKeywords = node.get("excludeKeywords")?.map { it.asText() }?.toImmutableList() ?: return null,
|
||||||
|
kinds = node.get("kinds")?.map { it.asInt() }?.toImmutableList() ?: return null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"people_list" -> {
|
||||||
|
FeedSource.PeopleList(
|
||||||
|
kind = node.get("kind")?.asInt() ?: 30000,
|
||||||
|
pubkey = node.get("pubkey")?.asText() ?: return null,
|
||||||
|
dTag = node.get("dTag")?.asText() ?: return null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"interest_set" -> {
|
||||||
|
FeedSource.InterestSet(
|
||||||
|
kind = node.get("kind")?.asInt() ?: 30015,
|
||||||
|
pubkey = node.get("pubkey")?.asText() ?: return null,
|
||||||
|
dTag = node.get("dTag")?.asText() ?: return null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"dvm" -> {
|
||||||
|
FeedSource.DVM(
|
||||||
|
kind = node.get("kind")?.asInt() ?: 31990,
|
||||||
|
pubkey = node.get("pubkey")?.asText() ?: return null,
|
||||||
|
dTag = node.get("dTag")?.asText() ?: return null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"single_relay" -> {
|
||||||
|
FeedSource.SingleRelay(
|
||||||
|
url = node.get("url")?.asText() ?: return null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"global" -> {
|
||||||
|
FeedSource.Global
|
||||||
|
}
|
||||||
|
|
||||||
|
"following" -> {
|
||||||
|
FeedSource.Following
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -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.feeds.custom
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.SearchQuery
|
||||||
|
|
||||||
|
fun SearchQuery.toFeedDefinition(
|
||||||
|
name: String,
|
||||||
|
emoji: String = "",
|
||||||
|
): FeedDefinition =
|
||||||
|
feedDefinition {
|
||||||
|
this.name = name
|
||||||
|
this.emoji = emoji
|
||||||
|
filter {
|
||||||
|
hashtags += this@toFeedDefinition.hashtags
|
||||||
|
authors += this@toFeedDefinition.authors
|
||||||
|
excludeKeywords += this@toFeedDefinition.excludeTerms
|
||||||
|
kinds += this@toFeedDefinition.kinds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SearchQuery.canBecomeFeed(): Boolean = hashtags.isNotEmpty() || authors.isNotEmpty() || kinds.isNotEmpty()
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
* 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.search
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||||
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegate for searching users via relays (NIP-50 or other mechanism).
|
||||||
|
* Platform-specific: desktop uses relay manager, Android could use its own.
|
||||||
|
*/
|
||||||
|
interface RelayUserSearchDelegate {
|
||||||
|
fun searchPeople(
|
||||||
|
query: String,
|
||||||
|
limit: Int,
|
||||||
|
onResult: (User) -> Unit,
|
||||||
|
onComplete: () -> Unit,
|
||||||
|
): Job
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable user search engine that combines local cache search with optional relay search.
|
||||||
|
* Platform-agnostic — lives in commons, relay interaction via delegate.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```
|
||||||
|
* val engine = UserSearchEngine(cache, scope)
|
||||||
|
* engine.relayDelegate = myRelayDelegate // optional
|
||||||
|
* engine.search("fiatjaf")
|
||||||
|
* // collect engine.results, engine.isSearching
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class UserSearchEngine(
|
||||||
|
private val cache: ICacheProvider,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val debounceMs: Long = 300L,
|
||||||
|
private val localLimit: Int = 10,
|
||||||
|
private val relayLimit: Int = 10,
|
||||||
|
) {
|
||||||
|
private val _query = MutableStateFlow("")
|
||||||
|
val query: StateFlow<String> = _query.asStateFlow()
|
||||||
|
|
||||||
|
private val _localResults = MutableStateFlow<List<User>>(emptyList())
|
||||||
|
val localResults: StateFlow<List<User>> = _localResults.asStateFlow()
|
||||||
|
|
||||||
|
private val _relayResults = MutableStateFlow<List<User>>(emptyList())
|
||||||
|
val relayResults: StateFlow<List<User>> = _relayResults.asStateFlow()
|
||||||
|
|
||||||
|
private val _isSearching = MutableStateFlow(false)
|
||||||
|
val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow()
|
||||||
|
|
||||||
|
var relayDelegate: RelayUserSearchDelegate? = null
|
||||||
|
|
||||||
|
private var relaySearchJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
setupDebouncedSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(text: String) {
|
||||||
|
_query.value = text
|
||||||
|
_relayResults.value = emptyList()
|
||||||
|
relaySearchJob?.cancel()
|
||||||
|
|
||||||
|
if (text.length < 2) {
|
||||||
|
_localResults.value = emptyList()
|
||||||
|
_isSearching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() = search("")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an input string to a hex pubkey.
|
||||||
|
* Handles npub, nprofile, and raw hex.
|
||||||
|
*/
|
||||||
|
fun resolveToHex(input: String): String = decodePublicKeyAsHexOrNull(input.trim()) ?: input.trim()
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
private fun setupDebouncedSearch() {
|
||||||
|
_query
|
||||||
|
.debounce(debounceMs)
|
||||||
|
.onEach { text ->
|
||||||
|
if (text.length >= 2) {
|
||||||
|
// Local cache search (instant)
|
||||||
|
_localResults.value = cache.findUsersStartingWith(text, localLimit)
|
||||||
|
|
||||||
|
// Relay search (async, via delegate)
|
||||||
|
startRelaySearch(text)
|
||||||
|
} else {
|
||||||
|
_localResults.value = emptyList()
|
||||||
|
_isSearching.value = false
|
||||||
|
}
|
||||||
|
}.launchIn(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRelaySearch(text: String) {
|
||||||
|
val delegate = relayDelegate ?: return
|
||||||
|
relaySearchJob?.cancel()
|
||||||
|
_isSearching.value = true
|
||||||
|
_relayResults.value = emptyList()
|
||||||
|
|
||||||
|
relaySearchJob =
|
||||||
|
delegate.searchPeople(
|
||||||
|
query = text,
|
||||||
|
limit = relayLimit,
|
||||||
|
onResult = { user ->
|
||||||
|
// Deduplicate against local results and existing relay results
|
||||||
|
val isDuplicate =
|
||||||
|
_localResults.value.any { it.pubkeyHex == user.pubkeyHex } ||
|
||||||
|
_relayResults.value.any { it.pubkeyHex == user.pubkeyHex }
|
||||||
|
if (!isDuplicate) {
|
||||||
|
_relayResults.value = _relayResults.value + user
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onComplete = {
|
||||||
|
_isSearching.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -66,6 +67,11 @@ fun LoadingState(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A centered empty state with title, optional description, and optional refresh action.
|
* A centered empty state with title, optional description, and optional refresh action.
|
||||||
|
*
|
||||||
|
* The optional `description` is wrapped in a [SelectionContainer] so users can
|
||||||
|
* select and copy it. `EmptyState` is reused as the in-feed error renderer
|
||||||
|
* (e.g. "Error loading feed" with the underlying error in `description`), so
|
||||||
|
* making the description selectable lets users copy error text for reporting.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun EmptyState(
|
fun EmptyState(
|
||||||
@@ -89,12 +95,14 @@ fun EmptyState(
|
|||||||
)
|
)
|
||||||
if (description != null) {
|
if (description != null) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
description,
|
description,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (onRefresh != null) {
|
if (onRefresh != null) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
OutlinedButton(onClick = onRefresh) {
|
OutlinedButton(onClick = onRefresh) {
|
||||||
@@ -106,6 +114,9 @@ fun EmptyState(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A centered error state with message and optional retry action.
|
* A centered error state with message and optional retry action.
|
||||||
|
*
|
||||||
|
* The error `message` is wrapped in a [SelectionContainer] so users can select
|
||||||
|
* and copy it — useful for reporting bugs or pasting error text into a search.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ErrorState(
|
fun ErrorState(
|
||||||
@@ -121,11 +132,13 @@ fun ErrorState(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
if (onRetry != null) {
|
if (onRetry != null) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Button(onClick = onRetry) {
|
Button(onClick = onRetry) {
|
||||||
|
|||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
/*
|
||||||
|
* 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.feeds.custom
|
||||||
|
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class FeedDefinitionSerializerTest {
|
||||||
|
@Test
|
||||||
|
fun roundTripFilterSource() {
|
||||||
|
val feed =
|
||||||
|
FeedDefinition(
|
||||||
|
id = "test-1",
|
||||||
|
name = "Bitcoin",
|
||||||
|
emoji = "\u20BF",
|
||||||
|
pinned = true,
|
||||||
|
pinOrder = 0,
|
||||||
|
source =
|
||||||
|
FeedSource.Filter(
|
||||||
|
hashtags = persistentListOf("bitcoin", "btc"),
|
||||||
|
authors = persistentListOf("abc123"),
|
||||||
|
relays = persistentListOf("wss://relay.damus.io"),
|
||||||
|
excludeAuthors = persistentListOf("spammer"),
|
||||||
|
excludeKeywords = persistentListOf("scam"),
|
||||||
|
kinds = persistentListOf(1, 6),
|
||||||
|
),
|
||||||
|
refreshMode = RefreshMode.LIVE_STREAM,
|
||||||
|
createdAt = 1000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
|
||||||
|
val deserialized = FeedDefinitionSerializer.deserializeList(json)
|
||||||
|
|
||||||
|
assertEquals(1, deserialized.size)
|
||||||
|
assertEquals(feed, deserialized[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripGlobalSource() {
|
||||||
|
val feed =
|
||||||
|
FeedDefinition(
|
||||||
|
id = "test-global",
|
||||||
|
name = "Global",
|
||||||
|
emoji = "\uD83C\uDF10",
|
||||||
|
pinned = true,
|
||||||
|
pinOrder = 1,
|
||||||
|
source = FeedSource.Global,
|
||||||
|
refreshMode = RefreshMode.LIVE_STREAM,
|
||||||
|
createdAt = 2000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
|
||||||
|
val deserialized = FeedDefinitionSerializer.deserializeList(json)
|
||||||
|
|
||||||
|
assertEquals(1, deserialized.size)
|
||||||
|
assertEquals(feed, deserialized[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripFollowingSource() {
|
||||||
|
val feed =
|
||||||
|
FeedDefinition(
|
||||||
|
id = "test-following",
|
||||||
|
name = "Following",
|
||||||
|
emoji = "\uD83C\uDFE0",
|
||||||
|
pinned = false,
|
||||||
|
pinOrder = Int.MAX_VALUE,
|
||||||
|
source = FeedSource.Following,
|
||||||
|
refreshMode = RefreshMode.LIVE_STREAM,
|
||||||
|
createdAt = 3000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
|
||||||
|
val deserialized = FeedDefinitionSerializer.deserializeList(json)
|
||||||
|
|
||||||
|
assertEquals(feed, deserialized[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripDvmSource() {
|
||||||
|
val feed =
|
||||||
|
FeedDefinition(
|
||||||
|
id = "test-dvm",
|
||||||
|
name = "Trending",
|
||||||
|
emoji = "\uD83D\uDD25",
|
||||||
|
pinned = true,
|
||||||
|
pinOrder = 2,
|
||||||
|
source = FeedSource.DVM(kind = 31990, pubkey = "dvmpub123", dTag = "trending"),
|
||||||
|
refreshMode = RefreshMode.POLL_5MIN,
|
||||||
|
createdAt = 4000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
|
||||||
|
val deserialized = FeedDefinitionSerializer.deserializeList(json)
|
||||||
|
|
||||||
|
assertEquals(feed, deserialized[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripPeopleListSource() {
|
||||||
|
val feed =
|
||||||
|
FeedDefinition(
|
||||||
|
id = "test-people",
|
||||||
|
name = "Dev Friends",
|
||||||
|
emoji = "\uD83D\uDC65",
|
||||||
|
pinned = false,
|
||||||
|
pinOrder = Int.MAX_VALUE,
|
||||||
|
source = FeedSource.PeopleList(kind = 30000, pubkey = "mypub", dTag = "devs"),
|
||||||
|
refreshMode = RefreshMode.LIVE_STREAM,
|
||||||
|
createdAt = 5000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
|
||||||
|
val deserialized = FeedDefinitionSerializer.deserializeList(json)
|
||||||
|
|
||||||
|
assertEquals(feed, deserialized[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripInterestSetSource() {
|
||||||
|
val feed =
|
||||||
|
FeedDefinition(
|
||||||
|
id = "test-interest",
|
||||||
|
name = "Nostr Dev",
|
||||||
|
emoji = "\uD83D\uDEE0",
|
||||||
|
pinned = false,
|
||||||
|
pinOrder = Int.MAX_VALUE,
|
||||||
|
source = FeedSource.InterestSet(kind = 30015, pubkey = "mypub", dTag = "nostrdev"),
|
||||||
|
refreshMode = RefreshMode.LIVE_STREAM,
|
||||||
|
createdAt = 6000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
|
||||||
|
val deserialized = FeedDefinitionSerializer.deserializeList(json)
|
||||||
|
|
||||||
|
assertEquals(feed, deserialized[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripSingleRelaySource() {
|
||||||
|
val feed =
|
||||||
|
FeedDefinition(
|
||||||
|
id = "test-relay",
|
||||||
|
name = "Damus Relay",
|
||||||
|
emoji = "\uD83D\uDCE1",
|
||||||
|
pinned = false,
|
||||||
|
pinOrder = Int.MAX_VALUE,
|
||||||
|
source = FeedSource.SingleRelay(url = "wss://relay.damus.io"),
|
||||||
|
refreshMode = RefreshMode.LIVE_STREAM,
|
||||||
|
createdAt = 7000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
|
||||||
|
val deserialized = FeedDefinitionSerializer.deserializeList(json)
|
||||||
|
|
||||||
|
assertEquals(feed, deserialized[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipleFeeds() {
|
||||||
|
val feeds =
|
||||||
|
listOf(
|
||||||
|
feedDefinition {
|
||||||
|
name = "Bitcoin"
|
||||||
|
emoji = "\u20BF"
|
||||||
|
filter {
|
||||||
|
hashtags += "bitcoin"
|
||||||
|
kinds += 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
feedDefinition {
|
||||||
|
name = "Lightning"
|
||||||
|
emoji = "\u26A1"
|
||||||
|
filter {
|
||||||
|
hashtags += "lightning"
|
||||||
|
hashtags += "ln"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = FeedDefinitionSerializer.serializeList(feeds)
|
||||||
|
val deserialized = FeedDefinitionSerializer.deserializeList(json)
|
||||||
|
|
||||||
|
assertEquals(2, deserialized.size)
|
||||||
|
assertEquals("Bitcoin", deserialized[0].name)
|
||||||
|
assertEquals("Lightning", deserialized[1].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyJsonReturnsEmptyList() {
|
||||||
|
assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(""))
|
||||||
|
assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun defaultFeedsAreValid() {
|
||||||
|
val defaults = defaultFeeds()
|
||||||
|
assertEquals(2, defaults.size)
|
||||||
|
assertTrue(defaults[0].pinned)
|
||||||
|
assertTrue(defaults[1].pinned)
|
||||||
|
assertEquals(FeedSource.Following, defaults[0].source)
|
||||||
|
assertEquals(FeedSource.Global, defaults[1].source)
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
/*
|
||||||
|
* 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.feeds.custom
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.SearchQuery
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class SearchQueryToFeedTest {
|
||||||
|
@Test
|
||||||
|
fun convertsHashtagsToFeedFilter() {
|
||||||
|
val query =
|
||||||
|
SearchQuery(
|
||||||
|
hashtags = persistentListOf("bitcoin", "nostr"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val feed = query.toFeedDefinition(name = "BTC + Nostr", emoji = "\u20BF")
|
||||||
|
|
||||||
|
assertEquals("BTC + Nostr", feed.name)
|
||||||
|
assertEquals("\u20BF", feed.emoji)
|
||||||
|
val source = feed.source as FeedSource.Filter
|
||||||
|
assertEquals(listOf("bitcoin", "nostr"), source.hashtags)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun convertsAuthorsToFeedFilter() {
|
||||||
|
val query =
|
||||||
|
SearchQuery(
|
||||||
|
authors = persistentListOf("abc123", "def456"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val feed = query.toFeedDefinition(name = "Devs")
|
||||||
|
val source = feed.source as FeedSource.Filter
|
||||||
|
assertEquals(listOf("abc123", "def456"), source.authors)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun convertsExcludeTerms() {
|
||||||
|
val query =
|
||||||
|
SearchQuery(
|
||||||
|
hashtags = persistentListOf("bitcoin"),
|
||||||
|
excludeTerms = persistentListOf("scam", "spam"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val feed = query.toFeedDefinition(name = "Clean BTC")
|
||||||
|
val source = feed.source as FeedSource.Filter
|
||||||
|
assertEquals(listOf("scam", "spam"), source.excludeKeywords)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun convertsKinds() {
|
||||||
|
val query =
|
||||||
|
SearchQuery(
|
||||||
|
kinds = persistentListOf(1, 30023),
|
||||||
|
hashtags = persistentListOf("dev"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val feed = query.toFeedDefinition(name = "Dev Articles")
|
||||||
|
val source = feed.source as FeedSource.Filter
|
||||||
|
assertEquals(listOf(1, 30023), source.kinds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canBecomeFeedWithHashtags() {
|
||||||
|
assertTrue(SearchQuery(hashtags = persistentListOf("btc")).canBecomeFeed())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canBecomeFeedWithAuthors() {
|
||||||
|
assertTrue(SearchQuery(authors = persistentListOf("abc")).canBecomeFeed())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canNotBecomeFeedEmpty() {
|
||||||
|
assertFalse(SearchQuery.EMPTY.canBecomeFeed())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canNotBecomeFeedTextOnly() {
|
||||||
|
assertFalse(SearchQuery(text = "hello").canBecomeFeed())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -690,6 +690,10 @@ fun App(
|
|||||||
return // Nothing below runs until Tor is Active
|
return // Nothing below runs until Tor is Active
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var appDrawerInitialTab by remember {
|
||||||
|
mutableStateOf<com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
val localCache = remember { DesktopLocalCache() }
|
val localCache = remember { DesktopLocalCache() }
|
||||||
val accountState by accountManager.accountState.collectAsState()
|
val accountState by accountManager.accountState.collectAsState()
|
||||||
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
||||||
@@ -871,6 +875,10 @@ fun App(
|
|||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager,
|
||||||
) {
|
) {
|
||||||
when (accountState) {
|
when (accountState) {
|
||||||
is AccountState.LoggedOut -> {
|
is AccountState.LoggedOut -> {
|
||||||
@@ -943,6 +951,11 @@ fun App(
|
|||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
onShowAppDrawer = onShowAppDrawer,
|
onShowAppDrawer = onShowAppDrawer,
|
||||||
|
onOpenFeedsDrawer = {
|
||||||
|
appDrawerInitialTab =
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
|
||||||
|
onShowAppDrawer()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -960,6 +973,7 @@ fun App(
|
|||||||
if (showAppDrawer) {
|
if (showAppDrawer) {
|
||||||
val openColumns by deckState.columns.collectAsState()
|
val openColumns by deckState.columns.collectAsState()
|
||||||
AppDrawer(
|
AppDrawer(
|
||||||
|
initialTab = appDrawerInitialTab,
|
||||||
openColumnTypes =
|
openColumnTypes =
|
||||||
if (layoutMode == LayoutMode.DECK) {
|
if (layoutMode == LayoutMode.DECK) {
|
||||||
openColumns.map { it.type.typeKey() }.toSet()
|
openColumns.map { it.type.typeKey() }.toSet()
|
||||||
@@ -1002,7 +1016,10 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDismiss = onDismissAppDrawer,
|
onDismiss = {
|
||||||
|
appDrawerInitialTab = null
|
||||||
|
onDismissAppDrawer()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1020,6 +1037,7 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainContent(
|
fun MainContent(
|
||||||
@@ -1040,6 +1058,7 @@ fun MainContent(
|
|||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
onShowAppDrawer: () -> Unit,
|
onShowAppDrawer: () -> Unit,
|
||||||
|
onOpenFeedsDrawer: () -> Unit = onShowAppDrawer,
|
||||||
) {
|
) {
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -1230,6 +1249,8 @@ fun MainContent(
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalRelayCategories provides relayCategories,
|
LocalRelayCategories provides relayCategories,
|
||||||
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
|
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager,
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
@@ -1252,6 +1273,7 @@ fun MainContent(
|
|||||||
singlePaneState = singlePaneState,
|
singlePaneState = singlePaneState,
|
||||||
pinnedNavBarState = pinnedNavBarState,
|
pinnedNavBarState = pinnedNavBarState,
|
||||||
onOpenAppDrawer = onShowAppDrawer,
|
onOpenAppDrawer = onShowAppDrawer,
|
||||||
|
onOpenFeedsDrawer = onOpenFeedsDrawer,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
|
|||||||
+55
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.feeds
|
package com.vitorpamplona.amethyst.desktop.feeds
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
|
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
|
||||||
import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder
|
import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder
|
||||||
@@ -102,6 +103,60 @@ class DesktopFollowingFeedFilter(
|
|||||||
override fun limit(): Int = 2500
|
override fun limit(): Int = 2500
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom feed filter: matches events based on FeedSource.Filter criteria.
|
||||||
|
* Excludes are applied client-side (relay can't express NOT filters).
|
||||||
|
*/
|
||||||
|
class DesktopCustomFeedFilter(
|
||||||
|
private val cache: DesktopLocalCache,
|
||||||
|
private val feedId: String,
|
||||||
|
private val source: FeedSource.Filter,
|
||||||
|
) : AdditiveFeedFilter<Note>() {
|
||||||
|
override fun feedKey(): String = "custom-$feedId"
|
||||||
|
|
||||||
|
private fun matchesSource(note: Note): Boolean {
|
||||||
|
val event = note.event ?: return false
|
||||||
|
if (!isFeedNote(event)) return false
|
||||||
|
|
||||||
|
// Kind filter
|
||||||
|
if (source.kinds.isNotEmpty() && event.kind !in source.kinds) return false
|
||||||
|
|
||||||
|
// Author filter (if specified, note must be from one of these authors)
|
||||||
|
if (source.authors.isNotEmpty() && note.author?.pubkeyHex !in source.authors) return false
|
||||||
|
|
||||||
|
// Hashtag filter (if specified, event must contain at least one)
|
||||||
|
if (source.hashtags.isNotEmpty()) {
|
||||||
|
val eventTags =
|
||||||
|
event.tags
|
||||||
|
.filter { it.size >= 2 && it[0] == "t" }
|
||||||
|
.map { it[1].lowercase() }
|
||||||
|
if (source.hashtags.none { it.lowercase() in eventTags }) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclusions
|
||||||
|
if (source.excludeAuthors.isNotEmpty() && note.author?.pubkeyHex in source.excludeAuthors) return false
|
||||||
|
if (source.excludeKeywords.isNotEmpty()) {
|
||||||
|
val content = event.content.lowercase()
|
||||||
|
if (source.excludeKeywords.any { content.contains(it.lowercase()) }) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun feed(): List<Note> =
|
||||||
|
cache.notes
|
||||||
|
.filterIntoSet { _, note -> matchesSource(note) }
|
||||||
|
.sortedWith(DefaultFeedOrder)
|
||||||
|
.deduplicateReposts()
|
||||||
|
.take(limit())
|
||||||
|
|
||||||
|
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { matchesSource(it) }
|
||||||
|
|
||||||
|
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
|
||||||
|
|
||||||
|
override fun limit(): Int = 2500
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thread feed: root note + all replies (graph walk via Note.replies).
|
* Thread feed: root note + all replies (graph walk via Note.replies).
|
||||||
*/
|
*/
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* 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.search
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.RelayUserSearchDelegate
|
||||||
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class DesktopRelayUserSearchDelegate(
|
||||||
|
private val relayManager: RelayConnectionManager,
|
||||||
|
private val localCache: DesktopLocalCache,
|
||||||
|
private val searchRelays: () -> Set<NormalizedRelayUrl>,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) : RelayUserSearchDelegate {
|
||||||
|
private var currentSubId: String? = null
|
||||||
|
|
||||||
|
override fun searchPeople(
|
||||||
|
query: String,
|
||||||
|
limit: Int,
|
||||||
|
onResult: (User) -> Unit,
|
||||||
|
onComplete: () -> Unit,
|
||||||
|
): Job =
|
||||||
|
scope.launch {
|
||||||
|
// Cancel previous subscription
|
||||||
|
currentSubId?.let { relayManager.unsubscribe(it) }
|
||||||
|
|
||||||
|
val relays = searchRelays()
|
||||||
|
if (relays.isEmpty()) {
|
||||||
|
onComplete()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val subId = generateSubId("author-search")
|
||||||
|
currentSubId = subId
|
||||||
|
|
||||||
|
relayManager.subscribe(
|
||||||
|
subId = subId,
|
||||||
|
filters = listOf(FilterBuilders.searchPeople(query, limit)),
|
||||||
|
relays = relays,
|
||||||
|
listener =
|
||||||
|
object : SubscriptionListener {
|
||||||
|
override fun onEvent(
|
||||||
|
event: Event,
|
||||||
|
isLive: Boolean,
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
forFilters: List<Filter>?,
|
||||||
|
) {
|
||||||
|
if (event is MetadataEvent) {
|
||||||
|
localCache.consumeMetadata(event)
|
||||||
|
val user = localCache.getUserIfExists(event.pubKey)
|
||||||
|
if (user != null) {
|
||||||
|
onResult(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEose(
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
forFilters: List<Filter>?,
|
||||||
|
) {
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Timeout after 8 seconds
|
||||||
|
delay(8000)
|
||||||
|
relayManager.unsubscribe(subId)
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -20,9 +20,11 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.subscriptions
|
package com.vitorpamplona.amethyst.desktop.subscriptions
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feed mode for feed subscriptions.
|
* Feed mode for feed subscriptions.
|
||||||
@@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
|||||||
enum class FeedMode {
|
enum class FeedMode {
|
||||||
GLOBAL,
|
GLOBAL,
|
||||||
FOLLOWING,
|
FOLLOWING,
|
||||||
|
CUSTOM,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -362,3 +365,77 @@ fun createChessSubscription(
|
|||||||
onEvent = onEvent,
|
onEvent = onEvent,
|
||||||
onEose = onEose,
|
onEose = onEose,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a subscription config for a custom feed based on FeedSource.Filter.
|
||||||
|
*/
|
||||||
|
fun createCustomFeedSubscription(
|
||||||
|
source: FeedSource.Filter,
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
limit: Int = 200,
|
||||||
|
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||||
|
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||||
|
): SubscriptionConfig? {
|
||||||
|
val filters = mutableListOf<Filter>()
|
||||||
|
|
||||||
|
val kinds = source.kinds.ifEmpty { listOf(1, 6, 16) }
|
||||||
|
|
||||||
|
when {
|
||||||
|
source.authors.isNotEmpty() && source.hashtags.isNotEmpty() -> {
|
||||||
|
// Authors + hashtags: two separate filters (relay does OR between filters)
|
||||||
|
filters.add(
|
||||||
|
Filter(
|
||||||
|
kinds = kinds,
|
||||||
|
authors = source.authors.toList(),
|
||||||
|
limit = limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
filters.add(
|
||||||
|
Filter(
|
||||||
|
kinds = kinds,
|
||||||
|
tags = mapOf("t" to source.hashtags.toList()),
|
||||||
|
limit = limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
source.authors.isNotEmpty() -> {
|
||||||
|
filters.add(
|
||||||
|
Filter(
|
||||||
|
kinds = kinds,
|
||||||
|
authors = source.authors.toList(),
|
||||||
|
limit = limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
source.hashtags.isNotEmpty() -> {
|
||||||
|
filters.add(
|
||||||
|
Filter(
|
||||||
|
kinds = kinds,
|
||||||
|
tags = mapOf("t" to source.hashtags.toList()),
|
||||||
|
limit = limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.isEmpty()) return null
|
||||||
|
|
||||||
|
return SubscriptionConfig(
|
||||||
|
subId = generateSubId("custom-feed"),
|
||||||
|
filters = filters,
|
||||||
|
relays =
|
||||||
|
if (source.relays.isNotEmpty()) {
|
||||||
|
source.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||||
|
} else {
|
||||||
|
relays
|
||||||
|
},
|
||||||
|
onEvent = onEvent,
|
||||||
|
onEose = onEose,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+5
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -251,21 +252,25 @@ fun ComposeNoteDialog(
|
|||||||
|
|
||||||
errorMessage?.let { error ->
|
errorMessage?.let { error ->
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
error,
|
error,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uploadState.error?.let { error ->
|
uploadState.error?.let { error ->
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
"Upload error: $error",
|
"Upload error: $error",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
|||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
* 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.ui
|
||||||
|
|
||||||
|
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.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
|
||||||
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CustomFeedScreen(
|
||||||
|
feedId: String,
|
||||||
|
relayManager: DesktopRelayConnectionManager,
|
||||||
|
localCache: DesktopLocalCache,
|
||||||
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||||
|
onNavigateToProfile: (String) -> Unit = {},
|
||||||
|
onNavigateToThread: (String) -> Unit = {},
|
||||||
|
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val feedRepository = LocalFeedRepository.current
|
||||||
|
val feeds by feedRepository.feeds.collectAsState()
|
||||||
|
val feedDef = feeds.firstOrNull { it.id == feedId }
|
||||||
|
|
||||||
|
if (feedDef == null) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("Feed not found", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val source = feedDef.source) {
|
||||||
|
is FeedSource.Filter -> {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
Text(
|
||||||
|
"${feedDef.emoji} ${feedDef.name}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
FeedScreen(
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
customFeedId = feedId,
|
||||||
|
customFeedSource = source,
|
||||||
|
initialFeedMode = FeedMode.CUSTOM,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedSource.Following -> {
|
||||||
|
FeedScreen(
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
initialFeedMode = FeedMode.FOLLOWING,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedSource.Global -> {
|
||||||
|
FeedScreen(
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
initialFeedMode = FeedMode.GLOBAL,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedSource.SingleRelay -> {
|
||||||
|
FeedScreen(
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
customFeedId = feedId,
|
||||||
|
customFeedSource = FeedSource.Filter(relays = persistentListOf(source.url)),
|
||||||
|
initialFeedMode = FeedMode.CUSTOM,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedSource.DVM,
|
||||||
|
is FeedSource.PeopleList,
|
||||||
|
is FeedSource.InterestSet,
|
||||||
|
-> {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("${feedDef.name} — coming soon", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
-11
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
|||||||
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
|
import com.vitorpamplona.amethyst.desktop.feeds.DesktopCustomFeedFilter
|
||||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter
|
import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter
|
||||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
|
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
@@ -75,6 +76,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
|||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
|
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createCustomFeedSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||||
@@ -274,6 +276,9 @@ fun FeedScreen(
|
|||||||
localCache: DesktopLocalCache,
|
localCache: DesktopLocalCache,
|
||||||
account: AccountState.LoggedIn? = null,
|
account: AccountState.LoggedIn? = null,
|
||||||
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount? = null,
|
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount? = null,
|
||||||
|
customFeedId: String? = null,
|
||||||
|
customFeedSource: com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter? = null,
|
||||||
|
onOpenFeedsDrawer: () -> Unit = {},
|
||||||
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||||
initialFeedMode: FeedMode? = null,
|
initialFeedMode: FeedMode? = null,
|
||||||
@@ -297,7 +302,15 @@ fun FeedScreen(
|
|||||||
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||||
var showRelayPicker by remember { mutableStateOf(false) }
|
var showRelayPicker by remember { mutableStateOf(false) }
|
||||||
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
|
var activeFeedId by remember { mutableStateOf(customFeedId) }
|
||||||
|
var activeFeedSource by remember {
|
||||||
|
mutableStateOf(customFeedSource)
|
||||||
|
}
|
||||||
|
var feedMode by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
if (customFeedSource != null) FeedMode.CUSTOM else (initialFeedMode ?: DesktopPreferences.feedMode),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe to contact list (kind 3) — populates localCache.followedUsers
|
// Subscribe to contact list (kind 3) — populates localCache.followedUsers
|
||||||
rememberSubscription(allRelayUrls, account, relayManager = relayManager) {
|
rememberSubscription(allRelayUrls, account, relayManager = relayManager) {
|
||||||
@@ -315,7 +328,7 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to feed events (kind 1) — populates cache via coordinator
|
// Subscribe to feed events (kind 1) — populates cache via coordinator
|
||||||
rememberSubscription(feedRelays, feedMode, followedUsers, relayManager = relayManager) {
|
rememberSubscription(feedRelays, feedMode, followedUsers, activeFeedSource, relayManager = relayManager) {
|
||||||
if (feedRelays.isEmpty()) return@rememberSubscription null
|
if (feedRelays.isEmpty()) return@rememberSubscription null
|
||||||
|
|
||||||
when (feedMode) {
|
when (feedMode) {
|
||||||
@@ -342,12 +355,27 @@ fun FeedScreen(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FeedMode.CUSTOM -> {
|
||||||
|
val src = activeFeedSource
|
||||||
|
if (src != null) {
|
||||||
|
createCustomFeedSubscription(
|
||||||
|
source = src,
|
||||||
|
relays = feedRelays,
|
||||||
|
onEvent = { event, _, relay, _ ->
|
||||||
|
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DesktopFeedViewModel keyed on feedMode — recreated on mode switch
|
// DesktopFeedViewModel keyed on feedMode — recreated on mode switch
|
||||||
val viewModel =
|
val viewModel =
|
||||||
remember(feedMode) {
|
remember(feedMode, activeFeedId) {
|
||||||
val filter =
|
val filter =
|
||||||
when (feedMode) {
|
when (feedMode) {
|
||||||
FeedMode.GLOBAL -> {
|
FeedMode.GLOBAL -> {
|
||||||
@@ -359,6 +387,15 @@ fun FeedScreen(
|
|||||||
localCache.followedUsers.value
|
localCache.followedUsers.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FeedMode.CUSTOM -> {
|
||||||
|
DesktopCustomFeedFilter(
|
||||||
|
localCache,
|
||||||
|
activeFeedId ?: "custom",
|
||||||
|
activeFeedSource ?: com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
|
||||||
|
.Filter(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DesktopFeedViewModel(filter, localCache)
|
DesktopFeedViewModel(filter, localCache)
|
||||||
}
|
}
|
||||||
@@ -489,20 +526,28 @@ fun FeedScreen(
|
|||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
ReadingColumn {
|
ReadingColumn {
|
||||||
// Header with compose button
|
// Header: pinned feed tabs + "Show More +"
|
||||||
FeedHeader(
|
FeedTabsHeader(
|
||||||
feedMode = feedMode,
|
feedMode = feedMode,
|
||||||
account = account,
|
activeFeedId = activeFeedId,
|
||||||
feedRelays = feedRelays,
|
|
||||||
followedUsersCount = followedUsers.size,
|
|
||||||
onFeedModeChange = { mode ->
|
onFeedModeChange = { mode ->
|
||||||
feedMode = mode
|
feedMode = mode
|
||||||
|
activeFeedId = null
|
||||||
|
activeFeedSource = null
|
||||||
|
if (mode != FeedMode.CUSTOM) {
|
||||||
DesktopPreferences.feedMode = mode
|
DesktopPreferences.feedMode = mode
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onRefresh = { relayManager.connect() },
|
onNavigateToFeed = { feed ->
|
||||||
|
val source = feed.source
|
||||||
|
if (source is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter) {
|
||||||
|
activeFeedId = feed.id
|
||||||
|
activeFeedSource = source
|
||||||
|
feedMode = FeedMode.CUSTOM
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpenFeedsDrawer = onOpenFeedsDrawer,
|
||||||
onCompose = onCompose,
|
onCompose = onCompose,
|
||||||
onNavigateToRelays = onNavigateToRelays,
|
|
||||||
onOpenRelayPicker = { showRelayPicker = true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -665,6 +710,83 @@ fun FeedScreen(
|
|||||||
* the relays icon (desktop convention \u2014 the at-a-glance info is preserved
|
* the relays icon (desktop convention \u2014 the at-a-glance info is preserved
|
||||||
* without stealing header real estate).
|
* without stealing header real estate).
|
||||||
*/
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FeedTabsHeader(
|
||||||
|
feedMode: FeedMode,
|
||||||
|
activeFeedId: String? = null,
|
||||||
|
onFeedModeChange: (FeedMode) -> Unit,
|
||||||
|
onNavigateToFeed: (com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition) -> Unit = {},
|
||||||
|
onOpenFeedsDrawer: () -> Unit,
|
||||||
|
onCompose: () -> Unit,
|
||||||
|
) {
|
||||||
|
val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current
|
||||||
|
val pinnedFeeds by feedRepo.pinnedFeeds.collectAsState()
|
||||||
|
val sidePadding = LocalReadingSidePadding.current
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = sidePadding + 12.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Pinned feed tabs
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
pinnedFeeds.forEach { feed ->
|
||||||
|
val isSelected =
|
||||||
|
when (feed.source) {
|
||||||
|
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> {
|
||||||
|
feedMode == FeedMode.FOLLOWING
|
||||||
|
}
|
||||||
|
|
||||||
|
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> {
|
||||||
|
feedMode == FeedMode.GLOBAL
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
activeFeedId == feed.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FilterChip(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
when (feed.source) {
|
||||||
|
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> {
|
||||||
|
onFeedModeChange(FeedMode.FOLLOWING)
|
||||||
|
}
|
||||||
|
|
||||||
|
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> {
|
||||||
|
onFeedModeChange(FeedMode.GLOBAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
onNavigateToFeed(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("${feed.emoji} ${feed.name}") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// "Show More +" button
|
||||||
|
FilterChip(
|
||||||
|
selected = false,
|
||||||
|
onClick = onOpenFeedsDrawer,
|
||||||
|
label = { Text("+ More") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose button
|
||||||
|
IconButton(onClick = onCompose) {
|
||||||
|
Icon(
|
||||||
|
MaterialSymbols.Edit,
|
||||||
|
contentDescription = "Compose",
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun FeedHeader(
|
private fun FeedHeader(
|
||||||
|
|||||||
@@ -281,6 +281,10 @@ fun ReadsScreen(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FeedMode.CUSTOM -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-2
@@ -74,6 +74,8 @@ import androidx.compose.ui.text.font.FontFamily
|
|||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
|
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.canBecomeFeed
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.toFeedDefinition
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
|
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
|
||||||
@@ -102,6 +104,7 @@ import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
|||||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchScreen(
|
fun SearchScreen(
|
||||||
@@ -459,6 +462,29 @@ fun SearchScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// "Save as Feed" button — visible when query has feed-able criteria
|
||||||
|
if (query.canBecomeFeed()) {
|
||||||
|
val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current
|
||||||
|
var showFeedBuilder by remember { mutableStateOf(false) }
|
||||||
|
IconButton(onClick = { showFeedBuilder = true }) {
|
||||||
|
Icon(
|
||||||
|
MaterialSymbols.Bookmark,
|
||||||
|
contentDescription = "Save as Feed",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showFeedBuilder) {
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.deck.FeedBuilderDialog(
|
||||||
|
initial = query.toFeedDefinition(name = ""),
|
||||||
|
localCache = localCache,
|
||||||
|
onSave = { feed ->
|
||||||
|
scope.launch { feedRepo.add(feed) }
|
||||||
|
showFeedBuilder = false
|
||||||
|
},
|
||||||
|
onDismiss = { showFeedBuilder = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search relay picker dialog
|
// Search relay picker dialog
|
||||||
@@ -510,7 +536,7 @@ fun SearchScreen(
|
|||||||
onExcludeRemoved = { state.removeExcludeTerm(it) },
|
onExcludeRemoved = { state.removeExcludeTerm(it) },
|
||||||
onLanguageChanged = { state.updateLanguage(it) },
|
onLanguageChanged = { state.updateLanguage(it) },
|
||||||
onClear = { state.clearSearch() },
|
onClear = { state.clearSearch() },
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
modifier = Modifier.padding(top = 8.dp, start = sidePadding, end = sidePadding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +548,10 @@ fun SearchScreen(
|
|||||||
|
|
||||||
if (bech32Results.isNotEmpty()) {
|
if (bech32Results.isNotEmpty()) {
|
||||||
// Show bech32 results (exact lookup)
|
// Show bech32 results (exact lookup)
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = sidePadding),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Direct lookup",
|
"Direct lookup",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@@ -544,12 +573,14 @@ fun SearchScreen(
|
|||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
onNavigateToThread = onNavigateToThread,
|
onNavigateToThread = onNavigateToThread,
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
|
modifier = Modifier.padding(horizontal = sidePadding),
|
||||||
)
|
)
|
||||||
} else if (!debouncedQuery.isEmpty && !isSearching) {
|
} else if (!debouncedQuery.isEmpty && !isSearching) {
|
||||||
Text(
|
Text(
|
||||||
"No results found. Try broader terms or fewer filters.",
|
"No results found. Try broader terms or fewer filters.",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = sidePadding),
|
||||||
)
|
)
|
||||||
} else if (!isSearching) {
|
} else if (!isSearching) {
|
||||||
// Empty state: show history + saved searches + operator hints
|
// Empty state: show history + saved searches + operator hints
|
||||||
|
|||||||
+6
-1
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui.auth
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -85,7 +86,11 @@ fun KeyInputField(
|
|||||||
isError = errorMessage != null,
|
isError = errorMessage != null,
|
||||||
supportingText =
|
supportingText =
|
||||||
errorMessage?.let {
|
errorMessage?.let {
|
||||||
{ Text(it, color = MaterialTheme.colorScheme.error) }
|
{
|
||||||
|
SelectionContainer {
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
@@ -254,11 +255,13 @@ private fun NostrConnectContent(
|
|||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
|
|
||||||
if (errorMessage != null) {
|
if (errorMessage != null) {
|
||||||
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
errorMessage!!,
|
errorMessage!!,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
errorMessage = null
|
errorMessage = null
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
@@ -137,6 +138,7 @@ fun DeckColumnType.category(): ScreenCategory =
|
|||||||
is DeckColumnType.Profile,
|
is DeckColumnType.Profile,
|
||||||
is DeckColumnType.Thread,
|
is DeckColumnType.Thread,
|
||||||
is DeckColumnType.Article,
|
is DeckColumnType.Article,
|
||||||
|
is DeckColumnType.CustomFeed,
|
||||||
-> ScreenCategory.SOCIAL
|
-> ScreenCategory.SOCIAL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@ fun DeckColumnType.param(): String? =
|
|||||||
is DeckColumnType.Hashtag -> tag
|
is DeckColumnType.Hashtag -> tag
|
||||||
is DeckColumnType.Editor -> draftSlug
|
is DeckColumnType.Editor -> draftSlug
|
||||||
is DeckColumnType.Article -> addressTag
|
is DeckColumnType.Article -> addressTag
|
||||||
|
is DeckColumnType.CustomFeed -> feedId
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +183,7 @@ val LAUNCHABLE_SCREENS: List<DeckColumnType> =
|
|||||||
enum class AppDrawerTab {
|
enum class AppDrawerTab {
|
||||||
SCREENS,
|
SCREENS,
|
||||||
WORKSPACES,
|
WORKSPACES,
|
||||||
|
FEEDS,
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- State --
|
// -- State --
|
||||||
@@ -284,6 +288,10 @@ private class AppDrawerState {
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppDrawerTab.FEEDS -> {
|
||||||
|
// Feed selection handled by FeedsDrawerTab's own click handlers
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,6 +317,7 @@ fun AppDrawer(
|
|||||||
onSwitchWorkspace: (Workspace) -> Unit,
|
onSwitchWorkspace: (Workspace) -> Unit,
|
||||||
onSelectScreen: (DeckColumnType) -> Unit,
|
onSelectScreen: (DeckColumnType) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
initialTab: AppDrawerTab? = null,
|
||||||
) {
|
) {
|
||||||
val state = remember { AppDrawerState() }
|
val state = remember { AppDrawerState() }
|
||||||
val searchFocusRequester = remember { FocusRequester() }
|
val searchFocusRequester = remember { FocusRequester() }
|
||||||
@@ -317,6 +326,11 @@ fun AppDrawer(
|
|||||||
derivedStateOf { state.filteredWorkspaces(allWorkspaces) }
|
derivedStateOf { state.filteredWorkspaces(allWorkspaces) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set initial tab if specified
|
||||||
|
LaunchedEffect(initialTab) {
|
||||||
|
if (initialTab != null) state.switchTab(initialTab)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
delay(50)
|
delay(50)
|
||||||
searchFocusRequester.requestFocus()
|
searchFocusRequester.requestFocus()
|
||||||
@@ -454,6 +468,22 @@ fun AppDrawer(
|
|||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppDrawerTab.FEEDS -> {
|
||||||
|
FeedsDrawerTab(
|
||||||
|
onSelectFeed = { feedDef ->
|
||||||
|
onSelectScreen(
|
||||||
|
DeckColumnType.CustomFeed(
|
||||||
|
feedId = feedDef.id,
|
||||||
|
feedName = feedDef.name,
|
||||||
|
feedEmoji = feedDef.emoji,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1099,6 +1129,14 @@ private fun UnifiedSearchResults(
|
|||||||
val activeIndex by workspaceManager.activeIndex.collectAsState()
|
val activeIndex by workspaceManager.activeIndex.collectAsState()
|
||||||
val allWorkspaces by workspaceManager.workspaces.collectAsState()
|
val allWorkspaces by workspaceManager.workspaces.collectAsState()
|
||||||
|
|
||||||
|
val feedRepo = LocalFeedRepository.current
|
||||||
|
val allFeeds by feedRepo.feeds.collectAsState()
|
||||||
|
val filteredFeeds =
|
||||||
|
allFeeds.filter { feed ->
|
||||||
|
feed.name.contains(state.searchQuery, ignoreCase = true) ||
|
||||||
|
feed.emoji.contains(state.searchQuery)
|
||||||
|
}
|
||||||
|
|
||||||
LazyColumn(Modifier.padding(8.dp)) {
|
LazyColumn(Modifier.padding(8.dp)) {
|
||||||
// Workspace results first
|
// Workspace results first
|
||||||
if (filteredWs.isNotEmpty()) {
|
if (filteredWs.isNotEmpty()) {
|
||||||
@@ -1147,6 +1185,46 @@ private fun UnifiedSearchResults(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Feed results
|
||||||
|
if (filteredFeeds.isNotEmpty()) {
|
||||||
|
stickyHeader {
|
||||||
|
Text(
|
||||||
|
"Feeds",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(filteredFeeds, key = { "feed-${it.id}" }) { feed ->
|
||||||
|
Surface(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp)
|
||||||
|
.clickable {
|
||||||
|
onSelectScreen(
|
||||||
|
DeckColumnType.CustomFeed(feed.id, feed.name, feed.emoji),
|
||||||
|
)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(feed.emoji.ifEmpty { "\uD83D\uDCCB" }, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(feed.name, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Screen results below
|
// Screen results below
|
||||||
if (state.filteredScreens.isNotEmpty()) {
|
if (state.filteredScreens.isNotEmpty()) {
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
|
|||||||
+1
@@ -128,4 +128,5 @@ fun DeckColumnType.icon(): MaterialSymbol =
|
|||||||
is DeckColumnType.Profile -> MaterialSymbols.Person
|
is DeckColumnType.Profile -> MaterialSymbols.Person
|
||||||
is DeckColumnType.Thread -> MaterialSymbols.AutoMirrored.Article
|
is DeckColumnType.Thread -> MaterialSymbols.AutoMirrored.Article
|
||||||
is DeckColumnType.Hashtag -> MaterialSymbols.Tag
|
is DeckColumnType.Hashtag -> MaterialSymbols.Tag
|
||||||
|
is DeckColumnType.CustomFeed -> MaterialSymbols.Tune
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
|||||||
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
|
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen
|
import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
|
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.CustomFeedScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.DraftsScreen
|
import com.vitorpamplona.amethyst.desktop.ui.DraftsScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
|
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
|
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
|
||||||
@@ -208,6 +209,7 @@ internal fun RootContent(
|
|||||||
onNavigateToArticle: (String) -> Unit = {},
|
onNavigateToArticle: (String) -> Unit = {},
|
||||||
onNavigateToEditor: (String?) -> Unit = {},
|
onNavigateToEditor: (String?) -> Unit = {},
|
||||||
onNavigateToRelays: () -> Unit = {},
|
onNavigateToRelays: () -> Unit = {},
|
||||||
|
onOpenFeedsDrawer: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -226,6 +228,7 @@ internal fun RootContent(
|
|||||||
onNavigateToThread = onNavigateToThread,
|
onNavigateToThread = onNavigateToThread,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
onNavigateToRelays = onNavigateToRelays,
|
onNavigateToRelays = onNavigateToRelays,
|
||||||
|
onOpenFeedsDrawer = onOpenFeedsDrawer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +432,18 @@ internal fun RootContent(
|
|||||||
onNavigateToThread = onNavigateToThread,
|
onNavigateToThread = onNavigateToThread,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is DeckColumnType.CustomFeed -> {
|
||||||
|
CustomFeedScreen(
|
||||||
|
feedId = columnType.feedId,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
@@ -69,6 +69,12 @@ sealed class DeckColumnType {
|
|||||||
val tag: String,
|
val tag: String,
|
||||||
) : DeckColumnType()
|
) : DeckColumnType()
|
||||||
|
|
||||||
|
data class CustomFeed(
|
||||||
|
val feedId: String,
|
||||||
|
val feedName: String = "",
|
||||||
|
val feedEmoji: String = "",
|
||||||
|
) : DeckColumnType()
|
||||||
|
|
||||||
fun title(): String =
|
fun title(): String =
|
||||||
when (this) {
|
when (this) {
|
||||||
HomeFeed -> "Home"
|
HomeFeed -> "Home"
|
||||||
@@ -89,6 +95,7 @@ sealed class DeckColumnType {
|
|||||||
is Profile -> "Profile"
|
is Profile -> "Profile"
|
||||||
is Thread -> "Thread"
|
is Thread -> "Thread"
|
||||||
is Hashtag -> "#$tag"
|
is Hashtag -> "#$tag"
|
||||||
|
is CustomFeed -> feedName.ifEmpty { "Feed" }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun typeKey(): String =
|
fun typeKey(): String =
|
||||||
@@ -111,6 +118,7 @@ sealed class DeckColumnType {
|
|||||||
is Profile -> "profile"
|
is Profile -> "profile"
|
||||||
is Thread -> "thread"
|
is Thread -> "thread"
|
||||||
is Hashtag -> "hashtag"
|
is Hashtag -> "hashtag"
|
||||||
|
is CustomFeed -> "custom_feed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ class DeckState(
|
|||||||
"profile" -> param?.let { DeckColumnType.Profile(it) }
|
"profile" -> param?.let { DeckColumnType.Profile(it) }
|
||||||
"thread" -> param?.let { DeckColumnType.Thread(it) }
|
"thread" -> param?.let { DeckColumnType.Thread(it) }
|
||||||
"hashtag" -> param?.let { DeckColumnType.Hashtag(it) }
|
"hashtag" -> param?.let { DeckColumnType.Hashtag(it) }
|
||||||
|
"custom_feed" -> param?.let { DeckColumnType.CustomFeed(feedId = it) }
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+503
@@ -0,0 +1,503 @@
|
|||||||
|
/*
|
||||||
|
* 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.ui.deck
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.InputChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
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.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.isCtrlPressed
|
||||||
|
import androidx.compose.ui.input.key.isMetaPressed
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedBuilderState
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.RefreshMode
|
||||||
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun FeedBuilderDialog(
|
||||||
|
initial: FeedDefinition? = null,
|
||||||
|
localCache: DesktopLocalCache? = null,
|
||||||
|
authorQuery: String = "",
|
||||||
|
onAuthorQueryChange: (String) -> Unit = {},
|
||||||
|
authorSuggestions: List<com.vitorpamplona.amethyst.commons.model.User> = emptyList(),
|
||||||
|
authorRelayResults: List<com.vitorpamplona.amethyst.commons.model.User> = emptyList(),
|
||||||
|
authorSearching: Boolean = false,
|
||||||
|
onSave: (FeedDefinition) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state = remember(initial) { FeedBuilderState(initial) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(horizontal = 32.dp, vertical = 32.dp).onPreviewKeyEvent { event ->
|
||||||
|
if (event.type == KeyEventType.KeyDown &&
|
||||||
|
event.key == Key.S &&
|
||||||
|
(event.isMetaPressed || event.isCtrlPressed)
|
||||||
|
) {
|
||||||
|
if (state.isValid) onSave(state.toDefinition())
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { Text(if (initial != null) "Edit Feed" else "Create Feed") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// Name + emoji
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.emoji,
|
||||||
|
onValueChange = { state.emoji = it.take(2) },
|
||||||
|
label = { Text("Icon") },
|
||||||
|
modifier = Modifier.width(72.dp),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.name,
|
||||||
|
onValueChange = { state.name = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authors (with search + npub decode)
|
||||||
|
AuthorInputSection(
|
||||||
|
authors = state.authors,
|
||||||
|
localCache = localCache,
|
||||||
|
query = authorQuery,
|
||||||
|
onQueryChange = onAuthorQueryChange,
|
||||||
|
suggestions = authorSuggestions,
|
||||||
|
relayResults = authorRelayResults,
|
||||||
|
isSearching = authorSearching,
|
||||||
|
onAdd = { hex ->
|
||||||
|
if (hex !in state.authors) state.authors.add(hex)
|
||||||
|
onAuthorQueryChange("")
|
||||||
|
},
|
||||||
|
onRemove = { state.authors.remove(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hashtags
|
||||||
|
ChipInputField(
|
||||||
|
label = "Hashtags",
|
||||||
|
items = state.hashtags,
|
||||||
|
placeholder = "Add hashtag...",
|
||||||
|
onAdd = { state.hashtags.add(it.removePrefix("#").lowercase()) },
|
||||||
|
onRemove = { state.hashtags.remove(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Relays
|
||||||
|
ChipInputField(
|
||||||
|
label = "Relays",
|
||||||
|
items = state.relays,
|
||||||
|
placeholder = "wss://...",
|
||||||
|
onAdd = { state.relays.add(it) },
|
||||||
|
onRemove = { state.relays.remove(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind filter
|
||||||
|
KindFilterSection(kinds = state.kinds)
|
||||||
|
|
||||||
|
// Exclude authors
|
||||||
|
if (localCache != null) {
|
||||||
|
AuthorInputSection(
|
||||||
|
label = "Exclude Authors",
|
||||||
|
authors = state.excludeAuthors,
|
||||||
|
localCache = localCache,
|
||||||
|
onAdd = { hex -> if (hex !in state.excludeAuthors) state.excludeAuthors.add(hex) },
|
||||||
|
onRemove = { state.excludeAuthors.remove(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude keywords
|
||||||
|
ChipInputField(
|
||||||
|
label = "Exclude keywords",
|
||||||
|
items = state.excludeKeywords,
|
||||||
|
placeholder = "Add keyword to exclude...",
|
||||||
|
onAdd = { state.excludeKeywords.add(it) },
|
||||||
|
onRemove = { state.excludeKeywords.remove(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Refresh mode
|
||||||
|
Text("Refresh", style = MaterialTheme.typography.labelMedium)
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
RefreshMode.entries.forEachIndexed { index, mode ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = state.refreshMode == mode,
|
||||||
|
onClick = { state.refreshMode = mode },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, RefreshMode.entries.size),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
when (mode) {
|
||||||
|
RefreshMode.LIVE_STREAM -> "Live"
|
||||||
|
RefreshMode.POLL_5MIN -> "Every 5 min"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = { onSave(state.toDefinition()) },
|
||||||
|
enabled = state.isValid,
|
||||||
|
) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Author search field with npub decode + profile lookup --
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun AuthorInputSection(
|
||||||
|
label: String = "Authors",
|
||||||
|
authors: List<String>,
|
||||||
|
localCache: DesktopLocalCache?,
|
||||||
|
query: String = "",
|
||||||
|
onQueryChange: (String) -> Unit = {},
|
||||||
|
suggestions: List<com.vitorpamplona.amethyst.commons.model.User> = emptyList(),
|
||||||
|
relayResults: List<com.vitorpamplona.amethyst.commons.model.User> = emptyList(),
|
||||||
|
isSearching: Boolean = false,
|
||||||
|
onAdd: (String) -> Unit,
|
||||||
|
onRemove: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelMedium)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
if (authors.isNotEmpty()) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
authors.forEach { hex ->
|
||||||
|
val user = localCache?.getUserIfExists(hex)
|
||||||
|
val displayName = user?.toBestDisplayName() ?: hex.take(12) + "..."
|
||||||
|
InputChip(
|
||||||
|
selected = true,
|
||||||
|
onClick = { onRemove(hex) },
|
||||||
|
label = { Text(displayName, style = MaterialTheme.typography.bodySmall) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = { Text("Search name or paste npub...") },
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth().onPreviewKeyEvent { event ->
|
||||||
|
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
|
||||||
|
if (query.isNotBlank()) {
|
||||||
|
val top =
|
||||||
|
suggestions.firstOrNull { it.pubkeyHex !in authors }
|
||||||
|
?: relayResults.firstOrNull { it.pubkeyHex !in authors }
|
||||||
|
if (top != null) {
|
||||||
|
onAdd(top.pubkeyHex)
|
||||||
|
} else {
|
||||||
|
onAdd(decodePublicKeyAsHexOrNull(query.trim()) ?: query.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val filteredLocal = suggestions.filter { it.pubkeyHex !in authors }
|
||||||
|
val filteredRelay =
|
||||||
|
relayResults.filter { r ->
|
||||||
|
r.pubkeyHex !in authors && filteredLocal.none { it.pubkeyHex == r.pubkeyHex }
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasResults = filteredLocal.isNotEmpty() || filteredRelay.isNotEmpty()
|
||||||
|
val showNoResults = query.length >= 2 && !hasResults && !isSearching
|
||||||
|
|
||||||
|
if (hasResults || isSearching || showNoResults) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Surface(
|
||||||
|
tonalElevation = 4.dp,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp),
|
||||||
|
) {
|
||||||
|
LazyColumn {
|
||||||
|
items(filteredLocal, key = { "c-${it.pubkeyHex}" }) { user ->
|
||||||
|
AuthorRow(user) { onAdd(user.pubkeyHex) }
|
||||||
|
}
|
||||||
|
if (filteredRelay.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"From relays",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(filteredRelay, key = { "r-${it.pubkeyHex}" }) { user ->
|
||||||
|
AuthorRow(user) { onAdd(user.pubkeyHex) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isSearching) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"Searching relays...",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showNoResults) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"No users found",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AuthorRow(
|
||||||
|
user: com.vitorpamplona.amethyst.commons.model.User,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
com.vitorpamplona.amethyst.commons.ui.components.UserAvatar(
|
||||||
|
userHex = user.pubkeyHex,
|
||||||
|
pictureUrl = user.profilePicture(),
|
||||||
|
size = 28.dp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
user.toBestDisplayName(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
user.pubkeyNpub().take(20) + "...",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Kind filter checkboxes --
|
||||||
|
|
||||||
|
private data class KindOption(
|
||||||
|
val label: String,
|
||||||
|
val kinds: List<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val KIND_OPTIONS =
|
||||||
|
listOf(
|
||||||
|
KindOption("Notes", listOf(1)),
|
||||||
|
KindOption("Reposts", listOf(6, 16)),
|
||||||
|
KindOption("Articles", listOf(30023)),
|
||||||
|
KindOption("Highlights", listOf(9802)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun KindFilterSection(kinds: MutableList<Int>) {
|
||||||
|
Column {
|
||||||
|
Text("Event kinds", style = MaterialTheme.typography.labelMedium)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
KIND_OPTIONS.forEach { option ->
|
||||||
|
val isSelected = option.kinds.any { it in kinds }
|
||||||
|
FilterChip(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
if (isSelected) {
|
||||||
|
kinds.removeAll(option.kinds)
|
||||||
|
} else {
|
||||||
|
kinds.addAll(option.kinds)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(option.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kinds.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"No filter = all kinds",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Simple chip input field (hashtags, relays, keywords) --
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun ChipInputField(
|
||||||
|
label: String,
|
||||||
|
items: List<String>,
|
||||||
|
placeholder: String,
|
||||||
|
onAdd: (String) -> Unit,
|
||||||
|
onRemove: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelMedium)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
if (items.isNotEmpty()) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
items.forEach { item ->
|
||||||
|
InputChip(
|
||||||
|
selected = true,
|
||||||
|
onClick = { onRemove(item) },
|
||||||
|
label = { Text(item, style = MaterialTheme.typography.bodySmall) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
var input by remember { mutableStateOf("") }
|
||||||
|
OutlinedTextField(
|
||||||
|
value = input,
|
||||||
|
onValueChange = { input = it },
|
||||||
|
placeholder = { Text(placeholder) },
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth().onPreviewKeyEvent { event ->
|
||||||
|
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
|
||||||
|
if (input.isNotBlank()) {
|
||||||
|
onAdd(input.trim())
|
||||||
|
input = ""
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
trailingIcon = {
|
||||||
|
if (input.isNotBlank()) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onAdd(input.trim())
|
||||||
|
input = ""
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Add")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+349
@@ -0,0 +1,349 @@
|
|||||||
|
/*
|
||||||
|
* 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.ui.deck
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinitionRepository
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.MAX_PINNED_FEEDS
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FeedsDrawerTab(
|
||||||
|
onSelectFeed: (FeedDefinition) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
localCache: DesktopLocalCache? = LocalDesktopCache.current,
|
||||||
|
relayManager: com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager? = LocalRelayManager.current,
|
||||||
|
feedRepository: FeedDefinitionRepository = LocalFeedRepository.current,
|
||||||
|
scope: CoroutineScope = LocalFeedScope.current,
|
||||||
|
) {
|
||||||
|
val grouped by feedRepository.groupedFeeds.collectAsState()
|
||||||
|
var showBuilder by remember { mutableStateOf(false) }
|
||||||
|
var editingFeed by remember { mutableStateOf<FeedDefinition?>(null) }
|
||||||
|
var deletingFeed by remember { mutableStateOf<FeedDefinition?>(null) }
|
||||||
|
|
||||||
|
// Author search — same pattern as NewDmDialog: SearchBarState + rememberSubscription
|
||||||
|
val searchState =
|
||||||
|
remember(localCache) {
|
||||||
|
localCache?.let {
|
||||||
|
com.vitorpamplona.amethyst.commons.viewmodels
|
||||||
|
.SearchBarState(it, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val authorQuery = searchState?.searchText?.collectAsState()?.value ?: ""
|
||||||
|
val authorLocal = searchState?.cachedUserResults?.collectAsState()?.value ?: emptyList()
|
||||||
|
val authorRelay = searchState?.relaySearchResults?.collectAsState()?.value ?: emptyList()
|
||||||
|
val authorSearching = searchState?.isSearchingRelays?.collectAsState()?.value ?: false
|
||||||
|
|
||||||
|
// NIP-50 relay search — fires when local cache has few results (same as NewDmDialog)
|
||||||
|
if (relayManager != null && searchState != null) {
|
||||||
|
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||||
|
val connectedRelays = relayStatuses.keys
|
||||||
|
|
||||||
|
com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription(
|
||||||
|
connectedRelays,
|
||||||
|
authorQuery,
|
||||||
|
authorLocal.size,
|
||||||
|
relayManager = relayManager,
|
||||||
|
) {
|
||||||
|
if (connectedRelays.isEmpty()) return@rememberSubscription null
|
||||||
|
if (!searchState.shouldSearchRelays) return@rememberSubscription null
|
||||||
|
|
||||||
|
searchState.startRelaySearch()
|
||||||
|
com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription(
|
||||||
|
relays = connectedRelays,
|
||||||
|
searchQuery = authorQuery,
|
||||||
|
limit = 30,
|
||||||
|
onEvent = { event, _, _, _ ->
|
||||||
|
if (event is com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent &&
|
||||||
|
localCache != null
|
||||||
|
) {
|
||||||
|
localCache.consumeMetadata(event)
|
||||||
|
localCache.getUserIfExists(event.pubKey)?.let {
|
||||||
|
searchState.addRelaySearchResult(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEose = { _, _ -> searchState.endRelaySearch() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset search when dialogs close
|
||||||
|
LaunchedEffect(showBuilder, editingFeed) {
|
||||||
|
if (!showBuilder && editingFeed == null) {
|
||||||
|
searchState?.clearSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create dialog
|
||||||
|
if (showBuilder) {
|
||||||
|
FeedBuilderDialog(
|
||||||
|
localCache = localCache,
|
||||||
|
authorQuery = authorQuery,
|
||||||
|
onAuthorQueryChange = { searchState?.updateSearchText(it) },
|
||||||
|
authorSuggestions = authorLocal,
|
||||||
|
authorRelayResults = authorRelay,
|
||||||
|
authorSearching = authorSearching,
|
||||||
|
onSave = { feed ->
|
||||||
|
scope.launch { feedRepository.add(feed) }
|
||||||
|
showBuilder = false
|
||||||
|
},
|
||||||
|
onDismiss = { showBuilder = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit dialog
|
||||||
|
editingFeed?.let { feed ->
|
||||||
|
FeedBuilderDialog(
|
||||||
|
initial = feed,
|
||||||
|
localCache = localCache,
|
||||||
|
authorQuery = authorQuery,
|
||||||
|
onAuthorQueryChange = { searchState?.updateSearchText(it) },
|
||||||
|
authorSuggestions = authorLocal,
|
||||||
|
authorRelayResults = authorRelay,
|
||||||
|
authorSearching = authorSearching,
|
||||||
|
onSave = { updated ->
|
||||||
|
scope.launch { feedRepository.update(updated) }
|
||||||
|
editingFeed = null
|
||||||
|
},
|
||||||
|
onDismiss = { editingFeed = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation
|
||||||
|
deletingFeed?.let { feed ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deletingFeed = null },
|
||||||
|
title = { Text("Delete Feed") },
|
||||||
|
text = { Text("Delete \"${feed.name}\"? This cannot be undone.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
scope.launch { feedRepository.delete(feed.id) }
|
||||||
|
deletingFeed = null
|
||||||
|
}) {
|
||||||
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { deletingFeed = null }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
if (grouped.pinned.isNotEmpty()) {
|
||||||
|
item(key = "pinned-header") {
|
||||||
|
SectionHeader("Pinned (${grouped.pinned.size}/$MAX_PINNED_FEEDS)")
|
||||||
|
}
|
||||||
|
items(grouped.pinned, key = { "pinned-${it.id}" }) { feed ->
|
||||||
|
FeedRow(
|
||||||
|
feed = feed,
|
||||||
|
onSelect = { onSelectFeed(feed) },
|
||||||
|
onPin = null,
|
||||||
|
onUnpin = { scope.launch { feedRepository.unpin(feed.id) } },
|
||||||
|
onEdit =
|
||||||
|
if (feed.source is FeedSource.Filter) {
|
||||||
|
{ editingFeed = feed }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onDelete =
|
||||||
|
if (feed.id.startsWith("default-")) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
{ deletingFeed = feed }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grouped.myFeeds.isNotEmpty()) {
|
||||||
|
item(key = "my-header") {
|
||||||
|
SectionHeader("My Feeds")
|
||||||
|
}
|
||||||
|
items(grouped.myFeeds, key = { "my-${it.id}" }) { feed ->
|
||||||
|
FeedRow(
|
||||||
|
feed = feed,
|
||||||
|
onSelect = { onSelectFeed(feed) },
|
||||||
|
onPin = { scope.launch { feedRepository.pin(feed.id) } },
|
||||||
|
onUnpin = null,
|
||||||
|
onEdit =
|
||||||
|
if (feed.source is FeedSource.Filter) {
|
||||||
|
{ editingFeed = feed }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onDelete = { deletingFeed = feed },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grouped.algoFeeds.isNotEmpty()) {
|
||||||
|
item(key = "algo-header") {
|
||||||
|
SectionHeader("Algo Feeds")
|
||||||
|
}
|
||||||
|
items(grouped.algoFeeds, key = { "algo-${it.id}" }) { feed ->
|
||||||
|
FeedRow(
|
||||||
|
feed = feed,
|
||||||
|
onSelect = { onSelectFeed(feed) },
|
||||||
|
onPin = { scope.launch { feedRepository.pin(feed.id) } },
|
||||||
|
onUnpin =
|
||||||
|
if (feed.pinned) {
|
||||||
|
{ scope.launch { feedRepository.unpin(feed.id) } }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onEdit = null,
|
||||||
|
onDelete = { deletingFeed = feed },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = { showBuilder = true }) {
|
||||||
|
Text("+ Create Feed")
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { /* TODO: browse DVMs */ }) {
|
||||||
|
Text("Browse DVMs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(title: String) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FeedRow(
|
||||||
|
feed: FeedDefinition,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
onPin: (() -> Unit)?,
|
||||||
|
onUnpin: (() -> Unit)?,
|
||||||
|
onEdit: (() -> Unit)? = null,
|
||||||
|
onDelete: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onSelect),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
tonalElevation = 1.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = feed.emoji, fontSize = 20.sp)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = feed.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (onEdit != null) {
|
||||||
|
IconButton(onClick = onEdit, modifier = Modifier.size(28.dp)) {
|
||||||
|
Icon(MaterialSymbols.Edit, contentDescription = "Edit", modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onDelete != null) {
|
||||||
|
IconButton(onClick = onDelete, modifier = Modifier.size(28.dp)) {
|
||||||
|
Icon(
|
||||||
|
MaterialSymbols.Close,
|
||||||
|
contentDescription = "Delete",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onUnpin != null) {
|
||||||
|
TextButton(onClick = onUnpin, modifier = Modifier.size(height = 32.dp, width = 60.dp)) {
|
||||||
|
Text("Unpin", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onPin != null) {
|
||||||
|
TextButton(onClick = onPin, modifier = Modifier.size(height = 32.dp, width = 48.dp)) {
|
||||||
|
Text("Pin", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
* 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.ui.deck
|
||||||
|
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinitionRepository
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinitionSerializer
|
||||||
|
import com.vitorpamplona.amethyst.commons.feeds.custom.defaultFeeds
|
||||||
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import java.util.prefs.Preferences
|
||||||
|
|
||||||
|
private const val FEEDS_PREFS_KEY = "custom_feeds_json"
|
||||||
|
|
||||||
|
private val feedPrefs: Preferences by lazy {
|
||||||
|
Preferences.userRoot().node("amethyst/feeds")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val defaultRepository by lazy {
|
||||||
|
val repo = FeedDefinitionRepository(GlobalScope)
|
||||||
|
|
||||||
|
// Load persisted feeds (or defaults on first run)
|
||||||
|
val json = feedPrefs.get(FEEDS_PREFS_KEY, "")
|
||||||
|
val persisted = FeedDefinitionSerializer.deserializeList(json)
|
||||||
|
if (persisted.isNotEmpty()) {
|
||||||
|
repo.load(persisted)
|
||||||
|
} else {
|
||||||
|
repo.load(defaultFeeds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-persist on every change
|
||||||
|
repo.feeds
|
||||||
|
.onEach { feeds ->
|
||||||
|
val serialized = FeedDefinitionSerializer.serializeList(feeds)
|
||||||
|
feedPrefs.put(FEEDS_PREFS_KEY, serialized)
|
||||||
|
feedPrefs.flush()
|
||||||
|
}.launchIn(GlobalScope)
|
||||||
|
|
||||||
|
repo
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalFeedRepository =
|
||||||
|
compositionLocalOf<FeedDefinitionRepository> {
|
||||||
|
defaultRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalFeedScope =
|
||||||
|
compositionLocalOf<CoroutineScope> {
|
||||||
|
GlobalScope
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalDesktopCache =
|
||||||
|
compositionLocalOf<DesktopLocalCache?> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalRelayManager =
|
||||||
|
compositionLocalOf<DesktopRelayConnectionManager?> {
|
||||||
|
null
|
||||||
|
}
|
||||||
+6
-2
@@ -87,6 +87,7 @@ fun SinglePaneLayout(
|
|||||||
singlePaneState: SinglePaneState,
|
singlePaneState: SinglePaneState,
|
||||||
pinnedNavBarState: PinnedNavBarState,
|
pinnedNavBarState: PinnedNavBarState,
|
||||||
onOpenAppDrawer: () -> Unit,
|
onOpenAppDrawer: () -> Unit,
|
||||||
|
onOpenFeedsDrawer: () -> Unit = onOpenAppDrawer,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
onZapFeedback: (ZapFeedback) -> Unit,
|
onZapFeedback: (ZapFeedback) -> Unit,
|
||||||
@@ -112,6 +113,8 @@ fun SinglePaneLayout(
|
|||||||
) {
|
) {
|
||||||
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
|
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
|
||||||
pinnedScreens.forEach { screenType ->
|
pinnedScreens.forEach { screenType ->
|
||||||
|
// Rename "Home" to "Feeds" in the nav rail
|
||||||
|
val label = if (screenType == DeckColumnType.HomeFeed) "Feeds" else screenType.title()
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
selected = currentColumnType == screenType && navStack.isEmpty(),
|
selected = currentColumnType == screenType && navStack.isEmpty(),
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -121,13 +124,13 @@ fun SinglePaneLayout(
|
|||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
screenType.icon(),
|
screenType.icon(),
|
||||||
contentDescription = screenType.title(),
|
contentDescription = label,
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
screenType.title(),
|
label,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
@@ -249,6 +252,7 @@ fun SinglePaneLayout(
|
|||||||
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
||||||
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
|
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
|
||||||
onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) },
|
onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) },
|
||||||
|
onOpenFeedsDrawer = onOpenFeedsDrawer,
|
||||||
)
|
)
|
||||||
if (currentOverlay != null) {
|
if (currentOverlay != null) {
|
||||||
Surface(
|
Surface(
|
||||||
|
|||||||
+6
-3
@@ -88,23 +88,26 @@ fun AnimatedGifImage(
|
|||||||
val data = gifFrames
|
val data = gifFrames
|
||||||
when {
|
when {
|
||||||
data != null && data.frames.size > 1 -> {
|
data != null && data.frames.size > 1 -> {
|
||||||
|
val safeFrame = currentFrame.coerceIn(0, data.frames.size - 1)
|
||||||
|
|
||||||
LaunchedEffect(data) {
|
LaunchedEffect(data) {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val duration = data.durations[currentFrame].coerceAtLeast(MIN_FRAME_DURATION_MS)
|
val frameIdx = currentFrame.coerceIn(0, data.frames.size - 1)
|
||||||
|
val duration = data.durations[frameIdx].coerceAtLeast(MIN_FRAME_DURATION_MS)
|
||||||
delay(duration.toLong())
|
delay(duration.toLong())
|
||||||
currentFrame = (currentFrame + 1) % data.frames.size
|
currentFrame = (currentFrame + 1) % data.frames.size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Image(
|
Image(
|
||||||
bitmap = data.frames[currentFrame],
|
bitmap = data.frames[safeFrame],
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data != null -> {
|
data != null && data.frames.isNotEmpty() -> {
|
||||||
Image(
|
Image(
|
||||||
bitmap = data.frames[0],
|
bitmap = data.frames[0],
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
|
|||||||
Vendored
+2
@@ -112,6 +112,8 @@ class CoordinatorPipelineTest {
|
|||||||
relayList: Set<NormalizedRelayUrl>,
|
relayList: Set<NormalizedRelayUrl>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
override fun pendingPublishRelaysFor(eventId: HexKey): Set<NormalizedRelayUrl>? = null
|
||||||
|
|
||||||
override fun addConnectionListener(listener: RelayConnectionListener) {}
|
override fun addConnectionListener(listener: RelayConnectionListener) {}
|
||||||
|
|
||||||
override fun removeConnectionListener(listener: RelayConnectionListener) {}
|
override fun removeConnectionListener(listener: RelayConnectionListener) {}
|
||||||
|
|||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
* 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.cache
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class FindUsersTest {
|
||||||
|
private fun createCache() = DesktopLocalCache()
|
||||||
|
|
||||||
|
private fun fakeMetadata(
|
||||||
|
pubKey: String,
|
||||||
|
name: String,
|
||||||
|
displayName: String = name,
|
||||||
|
): MetadataEvent =
|
||||||
|
MetadataEvent(
|
||||||
|
id = (pubKey.take(16) + "meta").padEnd(64, '0'),
|
||||||
|
pubKey = pubKey,
|
||||||
|
createdAt = System.currentTimeMillis() / 1000,
|
||||||
|
tags = emptyArray(),
|
||||||
|
content = """{"name":"$name","display_name":"$displayName"}""",
|
||||||
|
sig = "0".repeat(128),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun userWithoutMetadataNotFoundByName() {
|
||||||
|
val cache = createCache()
|
||||||
|
val pubkey = KeyPair().pubKey.toHexKey()
|
||||||
|
|
||||||
|
cache.getOrCreateUser(pubkey)
|
||||||
|
|
||||||
|
val results = cache.findUsersStartingWith("test", 10)
|
||||||
|
assertTrue(results.isEmpty(), "User without metadata should not match name search")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun userWithMetadataFoundByDisplayName() {
|
||||||
|
val cache = createCache()
|
||||||
|
val pubkey = KeyPair().pubKey.toHexKey()
|
||||||
|
|
||||||
|
cache.consumeMetadata(fakeMetadata(pubkey, "vitor", "Vitor Pamplona"))
|
||||||
|
|
||||||
|
val results = cache.findUsersStartingWith("Vitor", 10)
|
||||||
|
assertEquals(1, results.size, "Should find user by display name")
|
||||||
|
assertEquals(pubkey, results[0].pubkeyHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun userWithMetadataFoundByName() {
|
||||||
|
val cache = createCache()
|
||||||
|
val pubkey = KeyPair().pubKey.toHexKey()
|
||||||
|
|
||||||
|
cache.consumeMetadata(fakeMetadata(pubkey, "vitor"))
|
||||||
|
|
||||||
|
val results = cache.findUsersStartingWith("vit", 10)
|
||||||
|
assertEquals(1, results.size, "Should find user by name prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun userWithMetadataFoundCaseInsensitive() {
|
||||||
|
val cache = createCache()
|
||||||
|
val pubkey = KeyPair().pubKey.toHexKey()
|
||||||
|
|
||||||
|
cache.consumeMetadata(fakeMetadata(pubkey, "Vitor", "Vitor Pamplona"))
|
||||||
|
|
||||||
|
val lower = cache.findUsersStartingWith("vitor", 10)
|
||||||
|
assertEquals(1, lower.size, "Should find case-insensitively (lowercase)")
|
||||||
|
|
||||||
|
val upper = cache.findUsersStartingWith("VITOR", 10)
|
||||||
|
assertEquals(1, upper.size, "Should find case-insensitively (uppercase)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun userWithoutMetadataFoundByPubkey() {
|
||||||
|
val cache = createCache()
|
||||||
|
val pubkey = KeyPair().pubKey.toHexKey()
|
||||||
|
|
||||||
|
cache.getOrCreateUser(pubkey)
|
||||||
|
|
||||||
|
val results = cache.findUsersStartingWith(pubkey.take(8), 10)
|
||||||
|
assertEquals(1, results.size, "Should find user by pubkey prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipleUsersWithMetadata() {
|
||||||
|
val cache = createCache()
|
||||||
|
|
||||||
|
cache.consumeMetadata(fakeMetadata(KeyPair().pubKey.toHexKey(), "alice"))
|
||||||
|
cache.consumeMetadata(fakeMetadata(KeyPair().pubKey.toHexKey(), "bob"))
|
||||||
|
cache.consumeMetadata(fakeMetadata(KeyPair().pubKey.toHexKey(), "alex"))
|
||||||
|
|
||||||
|
val results = cache.findUsersStartingWith("al", 10)
|
||||||
|
assertEquals(2, results.size, "Should find alice and alex")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun usersFromNotesWithoutMetadataNotMatchNameSearch() {
|
||||||
|
val cache = createCache()
|
||||||
|
|
||||||
|
// Simulate users created from kind 1 notes (no metadata)
|
||||||
|
repeat(10) { cache.getOrCreateUser(KeyPair().pubKey.toHexKey()) }
|
||||||
|
|
||||||
|
assertEquals(10, cache.userCount())
|
||||||
|
|
||||||
|
val results = cache.findUsersStartingWith("test", 10)
|
||||||
|
assertEquals(0, results.size, "Users without metadata should not match name search")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verifyMetadataIsActuallyParsed() {
|
||||||
|
val cache = createCache()
|
||||||
|
val pubkey = KeyPair().pubKey.toHexKey()
|
||||||
|
|
||||||
|
cache.consumeMetadata(fakeMetadata(pubkey, "testuser", "Test User"))
|
||||||
|
|
||||||
|
val user = cache.getUserIfExists(pubkey)
|
||||||
|
val metadata = user?.metadataOrNull()
|
||||||
|
|
||||||
|
assertTrue(metadata != null, "Metadata should exist after consumeMetadata")
|
||||||
|
assertTrue(
|
||||||
|
metadata.anyNameOrAddressContains(
|
||||||
|
listOf(
|
||||||
|
com.vitorpamplona.quartz.utils
|
||||||
|
.DualCase("test", "TEST"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"Metadata should match 'test' search",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.jetbrainsKotlinJvm)
|
||||||
|
alias(libs.plugins.serialization)
|
||||||
|
application
|
||||||
|
`java-test-fixtures`
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("com.vitorpamplona.geode.MainKt")
|
||||||
|
applicationName = "geode"
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
kotlin.srcDir("src/main/kotlin")
|
||||||
|
}
|
||||||
|
test {
|
||||||
|
kotlin.srcDir("src/test/kotlin")
|
||||||
|
}
|
||||||
|
// The `java-test-fixtures` plugin auto-creates a `testFixtures`
|
||||||
|
// source set; we just point it at our Kotlin layout so the
|
||||||
|
// `geode.fixtures` (synthetic events) and `geode.testing`
|
||||||
|
// (RelayClientTest, collectUntilEose) packages don't ship in
|
||||||
|
// production jars but are still usable by every consumer's test
|
||||||
|
// source via `testImplementation(testFixtures(project(":geode")))`.
|
||||||
|
named("testFixtures") {
|
||||||
|
kotlin.srcDir("src/testFixtures/kotlin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
// Forward `-DrunLoadBenchmark=true` to the test JVM so the
|
||||||
|
// perf.LoadBenchmark tests opt in. Off by default — load tests
|
||||||
|
// are noisy and slow.
|
||||||
|
systemProperty("runLoadBenchmark", System.getProperty("runLoadBenchmark") ?: "false")
|
||||||
|
System.getProperty("fanoutScalingEvents")?.let { systemProperty("fanoutScalingEvents", it) }
|
||||||
|
System.getProperty("fanoutScalingSubs")?.let { systemProperty("fanoutScalingSubs", it) }
|
||||||
|
// Show println output from test JVM so the benchmark numbers are
|
||||||
|
// actually visible without grepping the report XML.
|
||||||
|
testLogging {
|
||||||
|
showStandardStreams =
|
||||||
|
(System.getProperty("runLoadBenchmark") == "true")
|
||||||
|
events("standard_out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(project(":quartz"))
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.jackson.module.kotlin)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
// Bundled SQLite driver — Relay's default in-memory EventStore creates
|
||||||
|
// an in-memory DB at runtime.
|
||||||
|
implementation(libs.androidx.sqlite.bundled.jvm)
|
||||||
|
|
||||||
|
// Ktor server engine + WebSocket plugin so Relay can serve real ws://
|
||||||
|
// traffic. CIO is the coroutine-based engine — lighter than Netty.
|
||||||
|
api(libs.ktor.server.core)
|
||||||
|
api(libs.ktor.server.cio)
|
||||||
|
api(libs.ktor.server.websockets)
|
||||||
|
|
||||||
|
// TOML parsing for the operator config file. Mirrors the section
|
||||||
|
// layout of nostr-rs-relay's config.toml so existing operators can
|
||||||
|
// port their configs nearly verbatim.
|
||||||
|
implementation(libs.fourkoma)
|
||||||
|
|
||||||
|
// testFixtures: code in src/testFixtures/kotlin (RelayClientTest +
|
||||||
|
// synthetic event builders). Not shipped in the production jar but
|
||||||
|
// exposed to consumers via testImplementation(testFixtures(...)).
|
||||||
|
testFixturesApi(project(":quartz"))
|
||||||
|
testFixturesApi(libs.junit)
|
||||||
|
testFixturesImplementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
|
testImplementation(libs.kotlin.test)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation(libs.secp256k1.kmp.jni.jvm)
|
||||||
|
testImplementation(libs.okhttp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Example config for geode. Section layout mirrors
|
||||||
|
# nostr-rs-relay's config.toml so existing operators can port across.
|
||||||
|
#
|
||||||
|
# Run with:
|
||||||
|
# ./gradlew :geode:run --args="--config /etc/geode.toml"
|
||||||
|
#
|
||||||
|
# CLI flags override individual values: e.g. `--port 8888` wins over
|
||||||
|
# `[network].port`.
|
||||||
|
|
||||||
|
[info]
|
||||||
|
# The wss:// URL clients use to reach this relay (mandatory for NIP-42
|
||||||
|
# AUTH challenges). If not set, the relay synthesises one from the
|
||||||
|
# [network] section.
|
||||||
|
relay_url = "wss://relay.example.com/"
|
||||||
|
name = "Example Geode"
|
||||||
|
description = "A geode deployment."
|
||||||
|
contact = "admin@example.com"
|
||||||
|
# Operator pubkey (NIP-11). Optional.
|
||||||
|
# pubkey = "..."
|
||||||
|
# Override the supported NIPs advertised on the NIP-11 endpoint. If
|
||||||
|
# omitted, the relay advertises the NIPs it actually implements.
|
||||||
|
# supported_nips = [1, 9, 11, 40, 42, 45, 50, 62]
|
||||||
|
|
||||||
|
[network]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 7447
|
||||||
|
path = "/"
|
||||||
|
# Ktor CIO event-loop pool sizing. Leave commented-out for sensible
|
||||||
|
# per-CPU defaults (typical for <2k concurrent connections). Lift on
|
||||||
|
# big-VM deployments targeting 10k+ connections — over-threading at
|
||||||
|
# low connection counts hurts L1/L2 cache locality, so always
|
||||||
|
# benchmark before/after when tuning these.
|
||||||
|
#
|
||||||
|
# Operators targeting >1k concurrent WebSockets should also raise the
|
||||||
|
# OS file-descriptor limit: `ulimit -n 65536` (or higher) before
|
||||||
|
# launching, plus a matching `LimitNOFILE=` in any systemd unit. The
|
||||||
|
# default of 1024 on most distros caps the relay well below 1k FDs
|
||||||
|
# (one per WS plus DB and listening sockets).
|
||||||
|
# connection_group_size = 4
|
||||||
|
# worker_group_size = 16
|
||||||
|
# call_group_size = 64
|
||||||
|
|
||||||
|
[database]
|
||||||
|
# True keeps an in-memory SQLite db (events vanish on restart). Useful
|
||||||
|
# for tests; set false + `file = "..."` for persistent storage.
|
||||||
|
in_memory = false
|
||||||
|
file = "/var/lib/geode/events.db"
|
||||||
|
|
||||||
|
[options]
|
||||||
|
# Drop events whose Schnorr signature does not verify. Strongly
|
||||||
|
# recommended for any relay accepting traffic from real clients.
|
||||||
|
# Verify Schnorr signatures on every EVENT. Default: true. Disable
|
||||||
|
# only for trusted-input scenarios (test fixtures, mirror replays).
|
||||||
|
# verify_signatures = true
|
||||||
|
|
||||||
|
# Run signature verification in parallel inside the IngestQueue
|
||||||
|
# (across all CPU cores) instead of serially on each connection's
|
||||||
|
# WebSocket pump. Default: true. Set false to fall back to the
|
||||||
|
# legacy in-policy verify path.
|
||||||
|
# parallel_verify = true
|
||||||
|
|
||||||
|
# Require clients to NIP-42 AUTH before REQ/EVENT/COUNT.
|
||||||
|
require_auth = false
|
||||||
|
|
||||||
|
# Reject events whose `created_at` is more than this many seconds in
|
||||||
|
# the future. Enforced by RejectFutureEventsPolicy.
|
||||||
|
# reject_future_seconds = 1800
|
||||||
|
|
||||||
|
[limits]
|
||||||
|
# Maximum WebSocket frame size. Frames larger than this are dropped at
|
||||||
|
# the WS layer. (max_ws_message_bytes maps to the same setting since
|
||||||
|
# Ktor's WebSockets plugin only exposes per-frame caps.)
|
||||||
|
# max_ws_message_bytes = 1048576
|
||||||
|
# max_ws_frame_bytes = 1048576
|
||||||
|
|
||||||
|
[authorization]
|
||||||
|
# Allow / deny lists. Allow is a permissive ceiling; deny still
|
||||||
|
# removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy.
|
||||||
|
# pubkey_whitelist = ["abcdef...64hex..."]
|
||||||
|
# pubkey_blacklist = []
|
||||||
|
# kind_whitelist = [0, 1, 3, 7, 1059, 30023]
|
||||||
|
# kind_blacklist = [4]
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
# NIP-86 relay management API. When `pubkeys` is non-empty, the relay
|
||||||
|
# accepts HTTP POST application/nostr+json+rpc on the same URL,
|
||||||
|
# authenticated with NIP-98 HTTP-Auth. Only events signed by one of
|
||||||
|
# the listed pubkeys can run admin RPCs (banpubkey / banevent /
|
||||||
|
# changerelayname / …). Empty (the default) disables the endpoint.
|
||||||
|
# pubkeys = ["abcdef...64hex..."]
|
||||||
|
#
|
||||||
|
# Canonical URL the relay is reachable at, e.g. behind a reverse proxy.
|
||||||
|
# NIP-98 binds requests to this URL via the `u` tag. **Required** in
|
||||||
|
# any production deployment — without it, an attacker can spoof the
|
||||||
|
# Host header to bypass URL binding.
|
||||||
|
# public_url = "https://relay.example.com/"
|
||||||
|
|
||||||
|
# Path for the JSON snapshot that persists NIP-86 admin state (ban
|
||||||
|
# lists + the live NIP-11 doc) across restarts. When unset, admin
|
||||||
|
# state is in-memory only and forgotten on every restart. Convention
|
||||||
|
# is to place this next to the SQLite event-store file.
|
||||||
|
# state_file = "/var/lib/geode/events.db.admin.json"
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# Connection scaling: pushing past 2 000
|
||||||
|
|
||||||
|
> **Status (2026-05-07):** Sketches A and B shipped on
|
||||||
|
> `claude/connection-scaling-plan-YVjc8`. Sketch C landed as a smaller
|
||||||
|
> slice in Quartz — the streaming-filter cut — once the audit showed
|
||||||
|
> the rest of the plan's premise was overstated. Verification
|
||||||
|
> benchmarks (`connectionsHeldOpen10k`, `connectionsHeldOpenWithFanout`)
|
||||||
|
> are wired up but only run under `-DrunLoadBenchmark=true`. Remaining
|
||||||
|
> open work, including fan-out de-duplication, is now tracked in
|
||||||
|
> [`live-broadcast-fanout-index.md`](./2026-05-07-live-broadcast-fanout-index.md).
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Current measurement (`LoadBenchmark.connectionsHeldOpen`): **~2 000
|
||||||
|
concurrent connections** before file-descriptor pressure / Ktor CIO
|
||||||
|
event-loop saturation. Real-world relays (e.g. nostr.wine, nos.lol)
|
||||||
|
sustain 10–30k. Geode shouldn't be the bottleneck for an Amethyst-
|
||||||
|
adjacent operator who scales beyond a thousand-user community.
|
||||||
|
|
||||||
|
## What's spending memory per connection today
|
||||||
|
|
||||||
|
| Cost | Per connection | At 5 000 conns |
|
||||||
|
| ----------------------- | -------------------------------------------------------- | -------------- |
|
||||||
|
| `outQueue` Channel | 8 192 string slots × ~8 b ref | ~320 MB pinned |
|
||||||
|
| `RelaySession` | `LargeCache<String, Job>` for subs (likely 1–10 entries) | ~negligible |
|
||||||
|
| `NegSessionRegistry` | `HashMap<String, NegentropyServerSession>` — usually 0 | ~negligible |
|
||||||
|
| Ktor CIO buffers | TCP read + write buffers | ~10 MB |
|
||||||
|
| Per-session writer Job | one coroutine | ~few KB |
|
||||||
|
|
||||||
|
The `outQueue` reservation is the dominant cost. The 8 192 was sized
|
||||||
|
for a worst case "thousands of subscriptions, one event matches all" —
|
||||||
|
but at 5 000 connections we've over-provisioned by ~300 MB just on
|
||||||
|
the channel array, even though most connections never fan out.
|
||||||
|
|
||||||
|
## Sketch
|
||||||
|
|
||||||
|
### A — adaptive outQueue capacity ✅ shipped
|
||||||
|
|
||||||
|
> Original plan: start at `INITIAL_OUTGOING_BUFFER = 64` and swap to a
|
||||||
|
> wider channel under a per-session lock when a high-water mark
|
||||||
|
> trips. **Not how it shipped.**
|
||||||
|
|
||||||
|
What actually shipped is the simpler alternative the original Risks
|
||||||
|
section called out: `Channel.UNLIMITED` plus an `AtomicInteger`
|
||||||
|
backlog cap. kotlinx.coroutines' `BufferedChannel` allocates segments
|
||||||
|
lazily, so an unlimited channel pays only the small head-segment cost
|
||||||
|
on idle connections — there is no preallocated buffer to scale.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private val outQueue = Channel<String>(capacity = Channel.UNLIMITED)
|
||||||
|
private val outstanding = AtomicInteger(0)
|
||||||
|
|
||||||
|
// producer side
|
||||||
|
val depth = outstanding.incrementAndGet()
|
||||||
|
if (depth > MAX_OUTGOING_BUFFER) { // 8192
|
||||||
|
outstanding.decrementAndGet()
|
||||||
|
droppedForBackpressure = true
|
||||||
|
outQueue.close() // NIP-01: drop the conn
|
||||||
|
return@connect
|
||||||
|
}
|
||||||
|
val res = outQueue.trySend(json)
|
||||||
|
if (!res.isSuccess) outstanding.decrementAndGet() // closed concurrently
|
||||||
|
|
||||||
|
// writer side
|
||||||
|
for (json in outQueue) {
|
||||||
|
ws.outgoing.send(Frame.Text(json))
|
||||||
|
outstanding.decrementAndGet()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Memory characteristic the plan asked for is intact: idle connections
|
||||||
|
no longer reserve an 8 192-slot fixed buffer; hot fan-out connections
|
||||||
|
still get bounded at the same 2 MiB cap before the slow-client cutoff
|
||||||
|
fires. NIP-01 ordering is preserved (no silent drop — connection is
|
||||||
|
killed at the cap).
|
||||||
|
|
||||||
|
Implementation: `geode/.../server/WebSocketSessionPump.kt`. The
|
||||||
|
channel-swap approach was rejected because
|
||||||
|
`Channel.UNLIMITED` already gives the lazy-allocation behavior the
|
||||||
|
swap was simulating, with none of the swap's race surface.
|
||||||
|
|
||||||
|
### B — per-relay event-loop pool sizing ✅ shipped
|
||||||
|
|
||||||
|
Three optional knobs added to `[network]` in `RelayConfig`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[network]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 7447
|
||||||
|
path = "/"
|
||||||
|
# connection_group_size = 4
|
||||||
|
# worker_group_size = 16
|
||||||
|
# call_group_size = 64
|
||||||
|
```
|
||||||
|
|
||||||
|
Default is **`null` (Ktor default)** — no behavior change unless an
|
||||||
|
operator explicitly tunes them. The values are wired through
|
||||||
|
`LocalRelayServer` into the new `embeddedServer(factory = CIO,
|
||||||
|
rootConfig = serverConfig {…}, configure = {…})` overload (the
|
||||||
|
short-form `embeddedServer(factory, host, port) {…}` overload doesn't
|
||||||
|
expose CIO config). The auto-connector that the short form created
|
||||||
|
now has to be added explicitly via `connector { host = …; port = …}`.
|
||||||
|
|
||||||
|
`config.example.toml` documents the knobs and includes the operator
|
||||||
|
note that targeting >1k connections needs `ulimit -n 65536` (or
|
||||||
|
matching `LimitNOFILE=` in a systemd unit).
|
||||||
|
|
||||||
|
### C — reduce per-message JSON allocations ✅ partially shipped (in Quartz)
|
||||||
|
|
||||||
|
> Original plan claim: "`OptimizedJsonMapper.fromJsonToCommand`
|
||||||
|
> allocates a `JsonNode` tree per incoming frame." **Overstated.**
|
||||||
|
|
||||||
|
Audit of `quartz/.../jackson` showed the Command/Message envelope is
|
||||||
|
already streaming:
|
||||||
|
|
||||||
|
| Path | Already streaming? | Tree alloc? |
|
||||||
|
| ----------------------- | ------------------ | -------------------------------------------------- |
|
||||||
|
| `MessageDeserializer` | yes | only for `COUNT` result (rare) |
|
||||||
|
| `CommandDeserializer` | yes | only for **filter sub-objects** in REQ/COUNT/NEG-OPEN |
|
||||||
|
| `EventDeserializer` | yes | none — `currentName().hashCode()` dispatch |
|
||||||
|
| `ManualFilterDeserializer` | **no** | `jp.codec.readTree(jp)` per filter |
|
||||||
|
|
||||||
|
So the only relay-inbound tree allocation worth chasing was filter
|
||||||
|
parsing — the bulk of the per-frame allocations on a REQ-heavy
|
||||||
|
relay.
|
||||||
|
|
||||||
|
What shipped: a streaming `ManualFilterDeserializer.fromJson(jp:
|
||||||
|
JsonParser)` modeled exactly on `EventDeserializer`. Token-loop with
|
||||||
|
field-name dispatch (`ids` / `authors` / `kinds` / `since` / `until` /
|
||||||
|
`limit` / `search`, plus dynamic `#x` / `&x` tag keys), and
|
||||||
|
`readStringArray` / `readIntArray` helpers that drop invalid entries
|
||||||
|
silently to match the tree path's `mapNotNull { asTextOrNull() }`
|
||||||
|
tolerance. Wired into all four internal call sites:
|
||||||
|
`FilterDeserializer.deserialize` and the three `CommandDeserializer`
|
||||||
|
paths (REQ, COUNT, NEG-OPEN).
|
||||||
|
|
||||||
|
The tree-based `fromJson(ObjectNode)` overload is retained for
|
||||||
|
external/cross-format adapters (Quartz is a published library).
|
||||||
|
|
||||||
|
What was NOT done — and why:
|
||||||
|
- **Streaming Jackson for the Command envelope**: already streaming.
|
||||||
|
No allocation to remove.
|
||||||
|
- **kotlinx-serialization for the inbound path**: not pursued. The
|
||||||
|
cross-mapper round-trip tests in `KotlinSerializationMapperTest`
|
||||||
|
show the two formats are interchangeable, but the engine swap is a
|
||||||
|
much larger lift than the filter cut and there's no evidence the
|
||||||
|
KS path is faster on this code shape.
|
||||||
|
- **Per-session `ObjectMapper`**: Jackson's `ObjectMapper` is
|
||||||
|
thread-safe and stateless — sharing one is the recommended pattern.
|
||||||
|
Per-session would *increase* allocation, not decrease it.
|
||||||
|
|
||||||
|
## How to verify ✅ shipped
|
||||||
|
|
||||||
|
Two new benchmarks in `geode.perf.LoadBenchmark`, gated behind
|
||||||
|
`-DrunLoadBenchmark=true`:
|
||||||
|
|
||||||
|
- **`connectionsHeldOpen10k`** — opens 10 000 idle WebSocket
|
||||||
|
connections, asserts every one settles to EOSE inside 120 s, and
|
||||||
|
measures retained JVM heap (after `System.gc()` + 200 ms settle)
|
||||||
|
with a 1 GiB ceiling assertion. Requires `ulimit -n 32768` on
|
||||||
|
Linux.
|
||||||
|
- **`connectionsHeldOpenWithFanout`** — 5 000 subscribers all
|
||||||
|
matching `kinds:[1]`, one publisher emitting `targetEps × duration`
|
||||||
|
events, prints p50 / p99 last-fanout latency. No assertion on
|
||||||
|
latency — just regression-detection via stdout logging.
|
||||||
|
|
||||||
|
The original `connectionsHeldOpen` benchmark stays as the **baseline
|
||||||
|
floor (~2 000 conns)** for before/after comparisons.
|
||||||
|
|
||||||
|
Note on heap-vs-RSS: the original plan said "RSS stays under 1 GB"
|
||||||
|
but the JVM can only measure heap from inside; `Runtime.totalMemory
|
||||||
|
- freeMemory` is what the benchmark asserts on. RSS will be higher
|
||||||
|
because of code, native buffers, off-heap (Ktor CIO), etc.
|
||||||
|
|
||||||
|
## Risks (post-implementation)
|
||||||
|
|
||||||
|
- ~~**Adaptive channel swap is fiddly**~~ — sidestepped by using
|
||||||
|
`Channel.UNLIMITED` instead of swapping bounded channels.
|
||||||
|
- **Bumping CIO group sizes can hurt** — kept the defaults `null`.
|
||||||
|
Operators must opt in, and the docstrings explicitly say to
|
||||||
|
benchmark before/after.
|
||||||
|
- **OS-level FD limit** — documented in `config.example.toml` next to
|
||||||
|
the CIO knobs. Test prereq is also documented in the benchmark
|
||||||
|
KDoc.
|
||||||
|
|
||||||
|
## Open work
|
||||||
|
|
||||||
|
- **Fan-out de-duplication** — when one EVENT matches N subscribers,
|
||||||
|
we currently re-serialize and copy the JSON N times into N
|
||||||
|
channels. Caching one pre-serialized payload per event and
|
||||||
|
broadcasting a shared reference is a much bigger win than anything
|
||||||
|
in this plan; tracked in
|
||||||
|
[`live-broadcast-fanout-index.md`](./2026-05-07-live-broadcast-fanout-index.md).
|
||||||
|
- **Filter-matching index** — same plan. At 10k conns × ~5 filters
|
||||||
|
that's 50k evaluations per published EVENT, almost all of which
|
||||||
|
could be culled by indexing subscriptions on `kinds` / `authors` /
|
||||||
|
`#e` / `#p`.
|
||||||
|
- **Netty engine evaluation** — Ktor's Netty engine handles many idle
|
||||||
|
connections with measurably lower per-connection overhead than
|
||||||
|
CIO. Not pursued here because it changes the transport layer
|
||||||
|
wholesale; revisit only if the CIO knobs in (B) prove insufficient
|
||||||
|
for an operator at 20k+ connections.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Event ingestion: write batching + pipelined OK
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
EVENT acceptance is the hot path on a busy relay — every published note,
|
||||||
|
every reaction, every DM lands here. Today the per-event flow is fully
|
||||||
|
serial:
|
||||||
|
|
||||||
|
1. `RelaySession.handleEvent` (`quartz/nip01Core/relay/server/RelaySession.kt:131`)
|
||||||
|
awaits `policy.accept(cmd)` (Schnorr verify if `VerifyPolicy` is in
|
||||||
|
the stack — ~0.1 ms on JVM).
|
||||||
|
2. Awaits `store.insert(cmd.event)` — a single SQLite write, guarded by
|
||||||
|
the connection-pool writer mutex (`SQLiteConnectionPool`).
|
||||||
|
3. Sends `OkMessage` back through the writer coroutine.
|
||||||
|
|
||||||
|
`LoadBenchmark.publishThroughputSingleClient` measured **~760 EPS**;
|
||||||
|
the concurrent variant **~2000 EPS** (limited by SQLite writer mutex
|
||||||
|
contention, not WS throughput).
|
||||||
|
|
||||||
|
## Constraints we must keep
|
||||||
|
|
||||||
|
- **OK pairs by event id, not by order**: the OK frame carries the
|
||||||
|
event id, so clients pair replies to publishes by id. OKs can be
|
||||||
|
emitted in any order — including reordered against the EVENT
|
||||||
|
stream, and against each other on the same connection. This frees
|
||||||
|
us to fan OKs out as soon as the writer has a per-row decision.
|
||||||
|
- **OK semantics = accepted, not fsynced**: NIP-01 treats `OK true`
|
||||||
|
as "accepted by the relay," not "durably on disk." We can reply as
|
||||||
|
soon as SQLite returns success for the row (inside the open
|
||||||
|
transaction, before commit/fsync). Group commit can batch the
|
||||||
|
fsync without holding OKs back.
|
||||||
|
- **Per-row decision still required**: the OK reason field is
|
||||||
|
per-event (duplicate, blocked, invalid sig, pow, etc.), so we
|
||||||
|
cannot fan out a single batch-level OK. Each row's OK must reflect
|
||||||
|
that row's outcome.
|
||||||
|
|
||||||
|
## Sketch
|
||||||
|
|
||||||
|
### Tier 1 — SQLite WAL + group commit (cheap win)
|
||||||
|
|
||||||
|
WAL is already on (`PRAGMA journal_mode=WAL`). The pool runs with
|
||||||
|
`PRAGMA synchronous=OFF`, which is one notch more permissive than
|
||||||
|
the originally-sketched `synchronous=NORMAL` — we keep it as-is
|
||||||
|
because the project already accepted the OS-crash trade-off there.
|
||||||
|
|
||||||
|
Group commit is implemented via a new `IEventStore.batchInsert`:
|
||||||
|
the SQLite override holds the writer mutex once and wraps N events
|
||||||
|
in one `BEGIN IMMEDIATE … COMMIT`. Per-row error isolation uses
|
||||||
|
SAVEPOINTs so one bad event (expired, duplicate id) doesn't roll
|
||||||
|
back the good ones — just that row reports `Rejected`.
|
||||||
|
|
||||||
|
OKs fire as soon as each row's outcome is known inside the writer
|
||||||
|
batch, not waiting for fsync (per the OK-semantics constraint above).
|
||||||
|
|
||||||
|
Implementation lives in `quartz/nip01Core/store/sqlite/SQLiteEventStore.batchInsertEvents`,
|
||||||
|
exposed through `IEventStore.batchInsert` and consumed by the new
|
||||||
|
`IngestQueue` (Tier 2 below).
|
||||||
|
|
||||||
|
Expected: **~5–10× write throughput** on a fast SSD. SQLite group
|
||||||
|
commit is well-trodden territory (nostr-rs-relay, strfry both do it).
|
||||||
|
|
||||||
|
### Tier 2 — pipelined OK over multiple in-flight EVENTs
|
||||||
|
|
||||||
|
`RelaySession.receive` was single-flight: one EVENT in, process, OK
|
||||||
|
out, next EVENT. With Tier 2 the connection's pump posts to the
|
||||||
|
shared `IngestQueue` and returns immediately — the WS pump moves
|
||||||
|
straight to the next frame.
|
||||||
|
|
||||||
|
`IngestQueue` (one per `NostrServer`) holds a bounded
|
||||||
|
`Channel<Submission>` (capacity = 1024 per the `DEFAULT_CAPACITY`
|
||||||
|
constant) drained by a single writer coroutine. The writer pulls
|
||||||
|
the first item to start a batch then `tryReceive`-drains everything
|
||||||
|
else queued (up to 64 — `DEFAULT_MAX_BATCH`), feeds the whole batch
|
||||||
|
to `IEventStore.batchInsert`, and dispatches each row's
|
||||||
|
`onComplete` callback as soon as the batch returns. The callback
|
||||||
|
turns into the `OK` frame at the WS layer.
|
||||||
|
|
||||||
|
OKs are not order-preserving (per the constraints above). The
|
||||||
|
writer coroutine starts lazily on first `submit` so subscription-
|
||||||
|
only sessions don't pay for it and don't perturb `Dispatchers.Default`
|
||||||
|
scheduling.
|
||||||
|
|
||||||
|
Expected: hides verify+insert latency behind the next EVENT's parse,
|
||||||
|
gets us closer to network-bound throughput.
|
||||||
|
|
||||||
|
### Tier 3 — eager Schnorr verify off the writer thread
|
||||||
|
|
||||||
|
`VerifyPolicy` ran synchronously on `receive`, serialising verify
|
||||||
|
on each connection's pump coroutine. With Tier 3, `IngestQueue`
|
||||||
|
takes a `verify: ((Event) -> Boolean)?` hook; when set, the writer
|
||||||
|
fan-outs a `coroutineScope { events.map { async(Default) { verify(it) } }.awaitAll() }`
|
||||||
|
on each batch before opening the SQLite transaction. Failed
|
||||||
|
verifies pre-mark `Rejected` and skip the insert.
|
||||||
|
|
||||||
|
Wired through `NostrServer(parallelVerify = ...)` and
|
||||||
|
`geode.Relay(parallelVerify = ...)`, controlled by
|
||||||
|
`[options].parallel_verify` in the relay config (default `true`)
|
||||||
|
and `--no-parallel-verify` on the CLI. Internal direct callers of
|
||||||
|
`NostrServer` (tests, library users) are opt-in: the flag defaults
|
||||||
|
to `false` to keep existing `VerifyPolicy`-in-chain semantics
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
`VerifyPolicy` was split into a parameterised
|
||||||
|
`VerifyEventsAndAuthPolicy(verifyEvents)` with two singletons:
|
||||||
|
|
||||||
|
- `VerifyPolicy` (default): verifies both `EVENT` and `AUTH`.
|
||||||
|
- `VerifyAuthOnlyPolicy`: verifies `AUTH` only, used when the
|
||||||
|
`IngestQueue` is doing the EVENT verify.
|
||||||
|
|
||||||
|
When `parallelVerify` is on, `composePolicy` swaps `VerifyPolicy`
|
||||||
|
for `VerifyAuthOnlyPolicy` so EVENTs aren't verified twice while
|
||||||
|
AUTH commands — which bypass the queue entirely — keep their
|
||||||
|
signature check. Without this split, removing `VerifyPolicy` from
|
||||||
|
the chain would let a forged AUTH event mark a pubkey as
|
||||||
|
authenticated.
|
||||||
|
|
||||||
|
Expected: ≈CPU_COUNT× verify-step speed-up on burst publishes
|
||||||
|
from a single connection, where verify was previously serial on
|
||||||
|
that pump.
|
||||||
|
|
||||||
|
## How to verify
|
||||||
|
|
||||||
|
`geode.perf.LoadBenchmark` carries the perf tests:
|
||||||
|
|
||||||
|
- `publishGroupCommitSingleClient` — sequential publish-and-confirm
|
||||||
|
on one connection (the same shape as the original
|
||||||
|
`publishThroughputSingleClient`). Synchronous publishing means
|
||||||
|
batch size is always 1, so this case shows per-event SQLite tx
|
||||||
|
cost rather than the group-commit win — kept as a 500-EPS floor
|
||||||
|
to catch regressions from the rewrite.
|
||||||
|
- `publishPipelinedSingleClient` — bursts 10 000 EVENTs back-to-
|
||||||
|
back without awaiting intermediate OKs; verifies end-to-end
|
||||||
|
throughput and that every event id receives exactly one OK (in
|
||||||
|
any order). This is where Tier 1 + Tier 2 both light up.
|
||||||
|
|
||||||
|
Existing benchmarks stay as the regression floor.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Group commit windows**: if a single bad event in the batch fails
|
||||||
|
validation, we must not roll back the good ones. The batch needs
|
||||||
|
per-row commit semantics (row-level errors → row-level OK false).
|
||||||
|
- **Backpressure on slow disks**: deeper pipelines on slow storage
|
||||||
|
amplify out-of-memory pressure. Cap the in-flight queue depth and
|
||||||
|
apply existing slow-client backpressure if it fills.
|
||||||
|
- **Replay protection**: the existing dedupe table needs to see the
|
||||||
|
event before commit, not after — keep that check inside the writer
|
||||||
|
coroutine.
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Live broadcast: indexed filter matching for fanout
|
||||||
|
|
||||||
|
> **Status (2026-05-07):** Phase 1 (relay server) and Phase 2 (Amethyst client
|
||||||
|
> `LocalCache.observables`) are implemented. Phase 3 (per-projection dispatch
|
||||||
|
> from `ObservableEventStore.changes`) is left as future work — see "What's
|
||||||
|
> next" below.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Every accepted EVENT runs through `LiveEventStore.newEventStream`
|
||||||
|
(`quartz/nip01Core/relay/server/LiveEventStore.kt`) — a
|
||||||
|
`MutableSharedFlow<Event>` that every active subscription collects.
|
||||||
|
Each subscriber's collector then calls:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
if (filters.any { it.match(newEvent) }) onEach(newEvent)
|
||||||
|
```
|
||||||
|
|
||||||
|
That's **O(N_subscribers × N_filters_per_sub)** per published event.
|
||||||
|
With 5k connections × ~3 filters average that's 15k Filter.match
|
||||||
|
calls per EVENT — and each `Filter.match` itself walks `kinds`,
|
||||||
|
`authors`, tag prefixes, since/until, etc. At 2k EPS ingest that's
|
||||||
|
~30M comparisons/sec.
|
||||||
|
|
||||||
|
The same shape recurs in two more places:
|
||||||
|
|
||||||
|
1. **`LocalCache.observables`** in `amethyst/.../LocalCache.kt` —
|
||||||
|
a `ConcurrentHashMap<Observable, Observable>` of feed observers.
|
||||||
|
`refreshNewNoteObservers` iterates every observer for every
|
||||||
|
accepted event; each observer's `new()` runs `filter.match`.
|
||||||
|
2. **`EventStoreProjection`** under `quartz/.../cache/projection/` —
|
||||||
|
each projection collects every `StoreChange.Insert` from
|
||||||
|
`ObservableEventStore.changes` and runs its own filter list.
|
||||||
|
|
||||||
|
All three follow the pattern "many filter-bearing observers, one
|
||||||
|
incoming event — find which observers match". Today that's a per-event
|
||||||
|
walk over N observers; with an inverted index it becomes a few hash
|
||||||
|
lookups followed by `Filter.match` only on the (small) candidate set.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### `FilterIndex<S>`
|
||||||
|
|
||||||
|
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterIndex.kt`
|
||||||
|
is a generic, KMP-friendly inverted index parameterised by the
|
||||||
|
subscriber type. It lives next to `Filter.kt`, not under `relay/server/`,
|
||||||
|
because it isn't relay-specific.
|
||||||
|
|
||||||
|
API:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class FilterIndex<S : Any> {
|
||||||
|
fun register(filter: Filter, subscriber: S)
|
||||||
|
fun register(filters: List<Filter>, subscriber: S)
|
||||||
|
fun registerUnindexed(subscriber: S) // predicate-only callers
|
||||||
|
fun unregister(subscriber: S)
|
||||||
|
fun candidatesFor(event: Event): Set<S> // index lookup
|
||||||
|
fun forEach(action: (S) -> Unit) // full iteration (delete paths)
|
||||||
|
fun size(): Int
|
||||||
|
fun isEmpty(): Boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
State is held in a single `AtomicReference<State<S>>` (BanStore-style)
|
||||||
|
so reads in the hot path are wait-free. Writes copy-on-write; that's
|
||||||
|
fine because writes are subscription-rate (rare) while reads are
|
||||||
|
event-rate (frequent).
|
||||||
|
|
||||||
|
Indexing strategy: each filter contributes entries to **one**
|
||||||
|
dimension — the most selective indexable field. Picking one dimension
|
||||||
|
instead of all of them avoids over-counting subscribers in
|
||||||
|
`candidatesFor` and minimises bucket churn:
|
||||||
|
|
||||||
|
1. `ids` (most selective; an id matches one event).
|
||||||
|
2. `authors`.
|
||||||
|
3. The first single-letter tag in `tags` (then `tagsAll`).
|
||||||
|
4. `kinds`.
|
||||||
|
5. None of the above → registered into the unindexed pool.
|
||||||
|
|
||||||
|
Multi-filter registrations OR the per-filter selections together,
|
||||||
|
which mirrors `filters.any { it.match(...) }`. Negative constraints
|
||||||
|
(`since` / `until` / `tagsAll` / saturated `limit`) stay in
|
||||||
|
`Filter.match` — the index produces a super-set, callers post-filter.
|
||||||
|
|
||||||
|
### Phase 1: `LiveEventStore`
|
||||||
|
|
||||||
|
`LiveEventStore.kt` no longer uses a `MutableSharedFlow`. Instead:
|
||||||
|
|
||||||
|
- One `FilterIndex<LiveSubscription>` shared across all REQs.
|
||||||
|
- `insert()` calls `index.candidatesFor(event)` then runs
|
||||||
|
`Filter.match` on each candidate. Synchronous delivery —
|
||||||
|
callers (the relay's `RelaySession`) keep the `deliver` callback
|
||||||
|
cheap (queue-to-outbound).
|
||||||
|
- `query()` registers the subscription into the index *before* the
|
||||||
|
historical replay starts (closes the same race the previous
|
||||||
|
`onSubscription` handoff closed), runs the replay, signals EOSE,
|
||||||
|
then `awaitCancellation()`. Live events arrive via index dispatch
|
||||||
|
during the suspend; `finally { index.unregister(sub) }` cleans up.
|
||||||
|
- Dedupe set during the historical phase is held in an
|
||||||
|
`AtomicReference<HashSet<String>?>` so the live-dispatch coroutine
|
||||||
|
sees the post-EOSE handoff promptly.
|
||||||
|
|
||||||
|
### Phase 2: `LocalCache.observables`
|
||||||
|
|
||||||
|
`amethyst/.../LocalCache.kt` swapped its
|
||||||
|
`ConcurrentHashMap<Observable, Observable>` for a
|
||||||
|
`FilterIndex<Observable>`. `observeNotes` / `observeEvents` /
|
||||||
|
`observeNewEvents(filter: Filter)` now `register(filter, observer)`;
|
||||||
|
the predicate-only `observeNewEvents(predicate)` overload uses
|
||||||
|
`registerUnindexed` because the index can't introspect an opaque
|
||||||
|
predicate. Dispatch:
|
||||||
|
|
||||||
|
- `refreshNewNoteObservers` iterates `observables.candidatesFor(event)`
|
||||||
|
instead of every observer.
|
||||||
|
- `refreshDeletedNoteObservers` still uses `observables.forEach { ... }`
|
||||||
|
— the index doesn't help on the delete path because every observer
|
||||||
|
might hold the deleted note in its result set, and there's no
|
||||||
|
event-shape to consult.
|
||||||
|
|
||||||
|
### How to verify
|
||||||
|
|
||||||
|
`geode.perf.LoadBenchmark.fanoutScaling` (added):
|
||||||
|
|
||||||
|
- N connections, each subscribes to `{authors: [pk_i], kinds: [1]}`.
|
||||||
|
- Publish events round-robin across the N pubkeys; each event matches
|
||||||
|
exactly one subscriber.
|
||||||
|
- Measure end-to-end latency p50/p99 for N ∈ {100, 1000, 5000}.
|
||||||
|
|
||||||
|
The benchmark adapts the per-N event count downward to stay below
|
||||||
|
geode's `WebSocketSessionPump.MAX_OUTGOING_BUFFER` (8192 frames per
|
||||||
|
session). The single-WS test subClient can't drain (subs + events)
|
||||||
|
frames at full firehose rate above ~5k subs in a shared test JVM —
|
||||||
|
that's a test-infra ceiling, not a relay one. Override with
|
||||||
|
`-DfanoutScalingEvents=N` to push past the default.
|
||||||
|
|
||||||
|
Measured numbers from a development laptop (Linux, JDK 21, full
|
||||||
|
sweep):
|
||||||
|
|
||||||
|
| N subs | events | mean (ms) | p50 (ms) | p99 (ms) | p999 (ms) |
|
||||||
|
|-------:|-------:|----------:|---------:|---------:|----------:|
|
||||||
|
| 100 | 2 000 | 1.63 | 1.35 | 5.71 | 20.49 |
|
||||||
|
| 1 000 | 2 000 | 1.12 | 1.05 | 3.05 | 9.40 |
|
||||||
|
| 5 000 | 1 000 | 1.07 | 1.01 | 2.96 | 7.38 |
|
||||||
|
|
||||||
|
p50 stays at ~1 ms across all three N values — the index is producing
|
||||||
|
the predicted O(1)-per-event scaling. The minor p99 spread comes from
|
||||||
|
GC pauses and OkHttp scheduler jitter on the test client, not from
|
||||||
|
linear per-event work in the relay.
|
||||||
|
|
||||||
|
For the LocalCache side, a similar benchmark would publish events
|
||||||
|
matching one of M observers and measure dispatch cost as M grows.
|
||||||
|
Not yet added — Phase 2 candidate for a follow-up.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Subscription churn**: re-subscribing on every page (the way some
|
||||||
|
client features work) means many index insert/remove operations.
|
||||||
|
COW on a single `AtomicReference` makes each write a full inner-map
|
||||||
|
copy; benchmark this path on a busy account to confirm the constants
|
||||||
|
stay reasonable.
|
||||||
|
- **Tag explosion**: an EVENT with many `e`/`p` tags hits many tag
|
||||||
|
buckets. The single-dimension-per-filter selection caps how many
|
||||||
|
buckets contribute candidates per event — registering on the
|
||||||
|
*most* selective dimension means tag-keyed filters typically pick
|
||||||
|
one specific tag value, so an event's tag walk only finds filters
|
||||||
|
registered under that exact `(letter, value)`.
|
||||||
|
- **Memory**: the index is a per-bucket set of subscription handles.
|
||||||
|
At 5k subs × average 1 dimension × a handful of values per filter,
|
||||||
|
~10k–20k entries — negligible.
|
||||||
|
- **Correctness fence**: `register` happens before historical replay
|
||||||
|
on the relay side, and inside the `callbackFlow`'s `register` /
|
||||||
|
`awaitClose { unregister }` pair on the client side. Events
|
||||||
|
arriving mid-historical are deduped via `seenIds` (relay) or are a
|
||||||
|
non-issue because the client `observe*` flow seeds via `init()`
|
||||||
|
before any new event can fire.
|
||||||
|
- **Filters with no narrowing field** (e.g. `{since: X}`) fall into
|
||||||
|
the unindexed pool and behave like today — every event reaches
|
||||||
|
them. That's the worst case; it's not worse than the pre-index
|
||||||
|
baseline.
|
||||||
|
- **AddressableEvent / replaceable v2 path**: an observer holding v1
|
||||||
|
whose filter doesn't match v2 won't be in `candidatesFor(v2)`.
|
||||||
|
Today such an observer wouldn't update its membership either
|
||||||
|
(`filter.match(v2) == false` short-circuits before the
|
||||||
|
re-emit branch). Pre-existing behaviour preserved.
|
||||||
|
|
||||||
|
## What's next (Phase 3)
|
||||||
|
|
||||||
|
`ObservableEventStore.changes` is still a `SharedFlow<StoreChange>`
|
||||||
|
that every projection collects. To use the index there, the dispatcher
|
||||||
|
between `_changes.emit` and the per-projection collectors would consult
|
||||||
|
a `FilterIndex<EventStoreProjection<*>>`-style index and only deliver
|
||||||
|
to interested projections. Doable, but the SharedFlow contract is
|
||||||
|
public; replacing it is a larger refactor than Phase 1/2 and the ROI
|
||||||
|
is lower (per-projection apply is already small). Treat as a follow-up.
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# NIP-77 negentropy at scale: strfry-interop snapshot path
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`RelaySession` delegates NEG-OPEN to `NegSessionRegistry.open`
|
||||||
|
(`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NegSessionRegistry.kt:58-77`),
|
||||||
|
which calls `store.snapshotQuery(filters)` and feeds the **entire**
|
||||||
|
result list of full `Event` objects into `NegentropyServerSession`
|
||||||
|
(`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyServerSession.kt:40-54`).
|
||||||
|
For a relay holding 5 M events that match a broad NEG-OPEN filter
|
||||||
|
(`{kinds: [1, 7]}`), this materialises 5 M `Event` objects with
|
||||||
|
content/tags/sig — call it ~1 KB/event, ~5 GB transient pressure per
|
||||||
|
concurrent NEG-OPEN — before the first NEG-MSG goes out.
|
||||||
|
|
||||||
|
The negentropy library itself is fine. Internally it pivots into a
|
||||||
|
sealed `StorageVector` of `(uint64 timestamp, byte[32] id)` items —
|
||||||
|
40 bytes/entry. The waste is purely in the snapshot step.
|
||||||
|
|
||||||
|
Two operator-visible symptoms:
|
||||||
|
|
||||||
|
1. NEG-OPEN with a broad filter spikes JVM heap; under load, GC pause
|
||||||
|
stalls every other handler on the same process.
|
||||||
|
2. NEG-OPEN latency before the first NEG-MSG response is O(N) — for
|
||||||
|
large stores the client waits seconds for what should be a
|
||||||
|
millisecond round-trip.
|
||||||
|
|
||||||
|
## Reference: strfry
|
||||||
|
|
||||||
|
We want byte-for-byte interop and comparable throughput against
|
||||||
|
[hoytech/strfry](https://github.com/hoytech/strfry). The relevant
|
||||||
|
defaults from `strfry/src/apps/relay/RelayNegentropy.cpp` and
|
||||||
|
`golpe.yaml`:
|
||||||
|
|
||||||
|
| Knob | strfry default | Notes |
|
||||||
|
|---------------------------------------|-----------------------|-------|
|
||||||
|
| `frameSizeLimit` (NEG-MSG payload) | **500_000 bytes** | Hard-coded `Negentropy ne(storage, 500'000)` |
|
||||||
|
| `relay__negentropy__maxSyncEvents` | **1_000_000** | Hard cap on items in the snapshot |
|
||||||
|
| `relay__maxSubsPerConnection` | **200** | Shared between REQ and NEG sessions |
|
||||||
|
| `idSize` on the wire | **32 bytes** | NIP-77 v1 (`PROTOCOL_VERSION = 0x61`) |
|
||||||
|
| Default `since` window | **none** | Filter is honored as-is |
|
||||||
|
| Filter parser | same as REQ | Honors `limit`, `kinds`, `authors`, `#tags` |
|
||||||
|
| Snapshot data | `(created_at, id)` only | LMDB scan inserts into `negentropy::storage::Vector` |
|
||||||
|
|
||||||
|
Two-phase materialisation in strfry: `QueryScheduler` scans LMDB
|
||||||
|
asynchronously, batching matched level-ids into a `vector<uint64_t>`;
|
||||||
|
on completion the worker pulls each event header, calls
|
||||||
|
`storageVector.insert(packed.created_at(), packed.id())`, then
|
||||||
|
`seal()`s. The session response is sent only after seal.
|
||||||
|
|
||||||
|
Our equivalent must (a) match the snapshot footprint of ~40 bytes per
|
||||||
|
event, and (b) match the wire-level frame-cap so a single
|
||||||
|
reconciliation round-trip exchanges the same payload size.
|
||||||
|
|
||||||
|
## Sketch
|
||||||
|
|
||||||
|
### A — id-and-time-only snapshot path (memory parity)
|
||||||
|
|
||||||
|
Negentropy only needs `(createdAt, id)` pairs. Add a streaming
|
||||||
|
`IEventStore.snapshotIdsForNegentropy(filter)` that returns
|
||||||
|
`Sequence<IdAndTime>` (`data class IdAndTime(val createdAt: Long, val id: ByteArray)`)
|
||||||
|
— no content/tags/sig, no `Event` allocation. The SQLite path is a
|
||||||
|
plain `SELECT id, created_at FROM event_headers WHERE …` against the
|
||||||
|
existing `query_by_created_at_id` index
|
||||||
|
(`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt:79`).
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// IEventStore
|
||||||
|
suspend fun snapshotIdsForNegentropy(filters: List<Filter>): List<IdAndTime>
|
||||||
|
|
||||||
|
// LiveEventStore — already deduplicates union of multi-filter results
|
||||||
|
suspend fun snapshotIdsForNegentropy(filters: List<Filter>): List<IdAndTime>
|
||||||
|
```
|
||||||
|
|
||||||
|
`NegentropyServerSession` is rewritten to take that list (or a
|
||||||
|
two-phase `pendingIds → seal` builder) and feed it directly into
|
||||||
|
`StorageVector`. Memory drops from O(N × ~1 KB) to O(N × 40 B) — for
|
||||||
|
1 M events, ~40 MB instead of ~1 GB; matches strfry's per-session
|
||||||
|
footprint.
|
||||||
|
|
||||||
|
**id encoding:** strfry stores 32-byte raw ids (NIP-77 v1
|
||||||
|
`ID_SIZE = 32`); the 16-byte truncation is `FINGERPRINT_SIZE`, only used
|
||||||
|
internally for SHA-256 accumulator output. The current Kotlin code
|
||||||
|
already passes the hex `event.id` string to `storage.insert(..)`
|
||||||
|
(`NegentropyServerSession.kt:50`); the kmp-negentropy library decodes
|
||||||
|
that to 32 raw bytes. Confirm this is preserved when we switch to a
|
||||||
|
`ByteArray` id input — do not pre-truncate.
|
||||||
|
|
||||||
|
### B — bounded snapshot size, NOT a default `since` window
|
||||||
|
|
||||||
|
The previous draft of this plan suggested defaulting `since` to a 90-day
|
||||||
|
horizon. **Drop that.** strfry doesn't do it — the filter is honored
|
||||||
|
as-is — and silently bounding `since` would break interop with strfry's
|
||||||
|
sync clients (e.g. `strfry sync`, nostr-sdk's negentropy reconciler):
|
||||||
|
they ask for "everything" and rely on getting it.
|
||||||
|
|
||||||
|
Instead match strfry's protection: a hard cap on the number of items
|
||||||
|
that go into a single snapshot.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[negentropy]
|
||||||
|
max_sync_events = 1_000_000 # matches strfry's relay__negentropy__maxSyncEvents
|
||||||
|
```
|
||||||
|
|
||||||
|
`NegSessionRegistry.open` checks `count >= max_sync_events` after the
|
||||||
|
SQLite count or during scan, and on overflow sends:
|
||||||
|
|
||||||
|
```
|
||||||
|
["NEG-ERR", "<subId>", "blocked: too many query results"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Exact wording matches strfry so client error-handling that string-matches
|
||||||
|
behaves identically. (Spec doesn't normatively define error text; this is
|
||||||
|
de-facto interop.)
|
||||||
|
|
||||||
|
### C — frame-size cap on NEG-MSG: 500_000 bytes (strfry parity)
|
||||||
|
|
||||||
|
`NegentropyServerSession` is constructed with `frameSizeLimit = 0`
|
||||||
|
today. **Set `frameSizeLimit = 500_000`** (matching strfry) so a single
|
||||||
|
NEG-MSG round-trip carries the same payload as strfry's. 64 KB — what
|
||||||
|
the previous draft suggested — would force 8× more round-trips for
|
||||||
|
large reconciliations and noticeably slow sync against strfry-style
|
||||||
|
clients that expect ~1 MB hex-encoded NEG-MSGs.
|
||||||
|
|
||||||
|
The kmp-negentropy library enforces `frameSizeLimit >= 4096` (or `0`
|
||||||
|
for unlimited). 500_000 is well above the floor. Pure config change in
|
||||||
|
`NegSessionRegistry.open`; expose via `[negentropy].frame_size_limit`
|
||||||
|
for operators who tune it down to fit smaller WS frame budgets.
|
||||||
|
|
||||||
|
Note: `LimitsSection.max_ws_frame_bytes`
|
||||||
|
(`geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayConfig.kt:148`)
|
||||||
|
applies to the WebSocket layer. After hex-encoding a 500_000-byte
|
||||||
|
negentropy payload doubles to ~1_000_000 bytes on the wire; ensure
|
||||||
|
`max_ws_frame_bytes` is at least 2 MB (or unlimited) in default
|
||||||
|
config so the response isn't truncated by the WS layer.
|
||||||
|
|
||||||
|
### D — concurrent NEG-OPEN cap, shared with REQ
|
||||||
|
|
||||||
|
A NEG-OPEN holds session state until NEG-CLOSE (or connection close).
|
||||||
|
Today `NegSessionRegistry.sessions` is unbounded. strfry caps at 200
|
||||||
|
sessions per connection **shared with REQ** — both count against
|
||||||
|
`relay__maxSubsPerConnection`.
|
||||||
|
|
||||||
|
Implement as:
|
||||||
|
- Reuse the existing per-connection REQ subscription cap (or introduce
|
||||||
|
one if absent) and let NEG sessions consume the same budget.
|
||||||
|
- Default cap: **200** to match strfry. Configurable via
|
||||||
|
`[limits].max_subs_per_connection`.
|
||||||
|
- On overflow strfry sends a NOTICE, not NEG-ERR:
|
||||||
|
`["NOTICE", "too many concurrent NEG requests"]`. We should match
|
||||||
|
this — `NEG-ERR` is reserved for per-session protocol errors in
|
||||||
|
strfry's model.
|
||||||
|
|
||||||
|
### E — pre-built fingerprint tree (follow-up, not in scope here)
|
||||||
|
|
||||||
|
strfry's real production-scale advantage is the **`negentropy::storage::BTreeLMDB`** backend: a persistent, incrementally-maintained B-tree
|
||||||
|
of `(timestamp, id)` keys with per-node fingerprint accumulators.
|
||||||
|
When NEG-OPEN's filter string matches a pre-registered
|
||||||
|
`NegentropyFilter` tree, strfry skips the materialise-and-seal step
|
||||||
|
entirely and reconciles directly off the LMDB B-tree in O(log n)
|
||||||
|
fingerprint computations. See `env.foreach_NegentropyFilter` and
|
||||||
|
`addStatelessView` in `RelayNegentropy.cpp`.
|
||||||
|
|
||||||
|
Equivalent for geode: a `quartz/.../nip77Negentropy/PrebuiltStorage`
|
||||||
|
backed by a SQLite-side incremental fingerprint index, registered per
|
||||||
|
canonical filter. This is a substantial piece of work and **out of
|
||||||
|
scope for this plan** — call it out for a follow-up
|
||||||
|
(`geode/plans/2026-05-08-negentropy-prebuilt-tree.md`). The A+B+C+D
|
||||||
|
combination is enough to match strfry's `MemoryView` path, which is
|
||||||
|
what 99% of ad-hoc NEG-OPENs hit.
|
||||||
|
|
||||||
|
## Concrete error-string interop table
|
||||||
|
|
||||||
|
Match strfry verbatim — clients in the wild string-match on these:
|
||||||
|
|
||||||
|
| Condition | strfry frame |
|
||||||
|
|---------------------------------------|-----------------------------------------------------------|
|
||||||
|
| Snapshot exceeds `max_sync_events` | `["NEG-ERR", "<subId>", "blocked: too many query results"]` |
|
||||||
|
| NEG-MSG for unknown subId | `["NEG-ERR", "<subId>", "closed: unknown subscription handle"]` |
|
||||||
|
| Library `reconcile()` parse failure | `["NEG-ERR", "<subId>", "PROTOCOL-ERROR"]` |
|
||||||
|
| Per-connection sub cap exceeded | `["NOTICE", "too many concurrent NEG requests"]` |
|
||||||
|
| NEG-MSG before NEG-OPEN seal complete | `["NOTICE", "negentropy error: got NEG-MSG before NEG-OPEN complete"]` |
|
||||||
|
|
||||||
|
The current Kotlin code in `NegSessionRegistry.kt:82` sends
|
||||||
|
`"error: no negentropy session for <subId>"` for the unknown-subId
|
||||||
|
case. Update to strfry's wording.
|
||||||
|
|
||||||
|
## Wire-level conformance checks
|
||||||
|
|
||||||
|
Before merging, verify against strfry as ground truth:
|
||||||
|
|
||||||
|
1. **Round-trip with `strfry sync`**: stand up a small geode instance,
|
||||||
|
point `strfry sync ws://geode-host` at it, confirm sync completes
|
||||||
|
and converges in ≤ comparable round-trips for an N=100k corpus.
|
||||||
|
2. **idSize**: assert kmp-negentropy round-trips full 32-byte ids;
|
||||||
|
the 16-byte fingerprint stays internal to the library.
|
||||||
|
3. **Protocol version byte**: NIP-77 v1 = `0x61`. Confirm
|
||||||
|
`NegentropyServerSession.processMessage` neither emits nor accepts
|
||||||
|
a different version byte. Negotiation happens inside the library;
|
||||||
|
surface the version on `NIP-11.limitation.negentropy = 1` so clients
|
||||||
|
know the v1 path is supported.
|
||||||
|
|
||||||
|
## How to verify
|
||||||
|
|
||||||
|
Add to `geode/src/test/kotlin/com/vitorpamplona/geode/perf/LoadBenchmark.kt`:
|
||||||
|
|
||||||
|
- `negentropyOpenLatencyLargeCorpus` — preload 1 M events, measure
|
||||||
|
NEG-OPEN → first NEG-MSG latency. Target **<200 ms** (strfry's
|
||||||
|
C++ `MemoryView` path on equivalent hardware does ~80–150 ms;
|
||||||
|
we expect a 1.5–2× JVM tax).
|
||||||
|
- `negentropyMemoryPressure` — open 10 concurrent NEG-OPENs on the
|
||||||
|
same large corpus; measure RSS delta. Target **<500 MB**
|
||||||
|
(10 × ~40 MB session footprint + scan overhead).
|
||||||
|
- `negentropyStrfryInterop` — programmatic round-trip against a
|
||||||
|
containerised `hoytech/strfry`: same fixture corpus loaded in both,
|
||||||
|
cross-sync, assert byte-identical id-set convergence in equal
|
||||||
|
round-trips ±1.
|
||||||
|
|
||||||
|
Add to `quartz/.../nip77Negentropy/`:
|
||||||
|
|
||||||
|
- `NegentropyServerSessionTest.processMessage_atFrameLimit_splitsAcrossRounds`
|
||||||
|
— large symmetric difference, assert each NEG-MSG payload is
|
||||||
|
≤ 500_000 bytes and the session completes in N rounds (not 1).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Cursor lifetime**: holding a SQLite cursor open across the full
|
||||||
|
sync is fragile if the client stalls. Materialise to a smaller
|
||||||
|
in-memory `List<IdAndTime>` (40 bytes/entry) once at NEG-OPEN time,
|
||||||
|
reuse for the lifetime of the session. Bounded by `max_sync_events`.
|
||||||
|
- **Frame-size 500_000 vs WS frame budget**: hex-encoded payload is
|
||||||
|
~1 MB. If `LimitsSection.max_ws_frame_bytes` is set lower than
|
||||||
|
~1.5 MB the response gets truncated/rejected by the WS layer. Lift
|
||||||
|
the WS default OR cap `frame_size_limit` to
|
||||||
|
`max_ws_frame_bytes / 2` at startup; fail-fast log a warning if the
|
||||||
|
operator's config makes negentropy unusable.
|
||||||
|
- **No default `since` (intentional, but worth flagging)**: a hostile
|
||||||
|
client doing `NEG-OPEN {kinds:[1]}` against a large corpus will hit
|
||||||
|
`max_sync_events` and get NEG-ERR. The cap is the protection; do
|
||||||
|
not also silently bound `since`.
|
||||||
|
- **kmp-negentropy library version**: pinned to `v1.0.2`
|
||||||
|
(`gradle/libs.versions.toml:9`). Confirm v1.0.2 enforces
|
||||||
|
`frameSizeLimit >= 4096`, supports protocol byte `0x61`, and
|
||||||
|
internal `ID_SIZE = 32`. If any of those don't match strfry, this
|
||||||
|
plan needs an upstream fix on kmp-negentropy first.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# geode plans
|
||||||
|
|
||||||
|
Performance-focused design docs for future work. Each file is a
|
||||||
|
self-contained sketch — problem statement, observed numbers, proposed
|
||||||
|
fix, how to verify, risks. None of these are committed work; they're
|
||||||
|
the queue.
|
||||||
|
|
||||||
|
Ordered roughly by expected impact:
|
||||||
|
|
||||||
|
| Plan | Headline gain |
|
||||||
|
| ---- | ------------- |
|
||||||
|
| [2026-05-07-event-ingestion-batching.md](2026-05-07-event-ingestion-batching.md) | 5–10× write EPS via SQLite group commit + ingest pipelining |
|
||||||
|
| [2026-05-07-live-broadcast-fanout-index.md](2026-05-07-live-broadcast-fanout-index.md) | >10× fanout speedup at >2 000 subscribers |
|
||||||
|
| [2026-05-07-connection-scaling.md](2026-05-07-connection-scaling.md) | 2 000 → 10 000+ concurrent connections |
|
||||||
|
| [2026-05-07-negentropy-large-corpus.md](2026-05-07-negentropy-large-corpus.md) | 25× lower memory + faster NEG-OPEN on M-event corpora |
|
||||||
|
|
||||||
|
Verification target for each plan is a new method on
|
||||||
|
`geode.perf.LoadBenchmark` (gated by `-DrunLoadBenchmark=true`) so
|
||||||
|
regressions show up in the regular CI matrix once they're enabled.
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
/*
|
||||||
|
* 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.geode
|
||||||
|
|
||||||
|
import com.vitorpamplona.geode.server.Nip86HttpRoute
|
||||||
|
import com.vitorpamplona.geode.server.WebSocketSessionPump
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
|
||||||
|
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
|
||||||
|
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.application.serverConfig
|
||||||
|
import io.ktor.server.cio.CIO
|
||||||
|
import io.ktor.server.cio.CIOApplicationEngine
|
||||||
|
import io.ktor.server.engine.connector
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.request.header
|
||||||
|
import io.ktor.server.response.respondText
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.post
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.server.websocket.WebSockets
|
||||||
|
import io.ktor.server.websocket.webSocket
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts a [Relay] over a real `ws://` endpoint backed by Ktor + CIO.
|
||||||
|
*
|
||||||
|
* Use this when something other than the in-process
|
||||||
|
* [com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket]
|
||||||
|
* needs to talk to the relay — Android instrumented tests, the `cli`
|
||||||
|
* tooling, external clients, or a standalone "run a Nostr relay"
|
||||||
|
* process.
|
||||||
|
*
|
||||||
|
* For unit-test wiring inside a single JVM, prefer [RelayHub] + the
|
||||||
|
* in-process socket — same protocol, no socket overhead.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* ```
|
||||||
|
* val server = LocalRelayServer(Relay(url = ...)).start()
|
||||||
|
* println("listening on ${server.url}")
|
||||||
|
* // ... do stuff ...
|
||||||
|
* server.stop()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class LocalRelayServer(
|
||||||
|
val relay: Relay,
|
||||||
|
val host: String = "127.0.0.1",
|
||||||
|
/** Pass 0 to let the OS pick a free port. Read [url] after [start] to learn it. */
|
||||||
|
val port: Int = 0,
|
||||||
|
val path: String = "/",
|
||||||
|
/**
|
||||||
|
* Per-frame size cap; mirrors `[limits].max_ws_frame_bytes` in the
|
||||||
|
* config. Frames larger than this are rejected at the WebSocket
|
||||||
|
* layer, which is the only layer that sees the raw bytes. `null`
|
||||||
|
* uses Ktor's default (~1 MiB).
|
||||||
|
*/
|
||||||
|
val maxFrameBytes: Long? = null,
|
||||||
|
/**
|
||||||
|
* Pubkeys allowed to call NIP-86 admin RPCs. Empty (the default)
|
||||||
|
* disables the admin endpoint entirely — POSTs return 403.
|
||||||
|
* Otherwise: HTTP POSTs to [path] with `Content-Type:
|
||||||
|
* application/nostr+json+rpc` are dispatched to [Nip86Server],
|
||||||
|
* gated by NIP-98 HTTP-Auth membership in this set.
|
||||||
|
*/
|
||||||
|
val adminPubkeys: Set<HexKey> = emptySet(),
|
||||||
|
/**
|
||||||
|
* Canonical public URL the relay is reachable at, e.g.
|
||||||
|
* `https://relay.example.com/`. NIP-98 admin requests must sign
|
||||||
|
* the **same** URL string they're sending to. When the relay sits
|
||||||
|
* behind TLS termination or a reverse proxy, the `Host` header
|
||||||
|
* the relay sees does not match what the client signs, so the
|
||||||
|
* verifier must compare against this configured value.
|
||||||
|
*
|
||||||
|
* `null` (the default) falls back to the request's `Host` header
|
||||||
|
* with `http://` — fine for local-loopback unit tests, **NOT
|
||||||
|
* SAFE** in a public deployment because an attacker can spoof
|
||||||
|
* `Host` and bind their signature to any URL.
|
||||||
|
*/
|
||||||
|
val publicUrl: String? = null,
|
||||||
|
/**
|
||||||
|
* Maximum body size accepted on the NIP-86 POST endpoint, in
|
||||||
|
* bytes. Bounded *before* auth verification because we read the
|
||||||
|
* body to compute its sha256 for NIP-98's payload binding —
|
||||||
|
* unbounded reads would let an unauthenticated attacker stream
|
||||||
|
* gigabytes and OOM the relay. 1 MiB easily fits any plausible
|
||||||
|
* RPC payload.
|
||||||
|
*/
|
||||||
|
val maxAdminBodyBytes: Int = 1 shl 20,
|
||||||
|
/**
|
||||||
|
* Ktor CIO acceptor-thread count. `null` keeps Ktor's default.
|
||||||
|
* Lift on machines with many cores when targeting 10k+
|
||||||
|
* concurrent connections — see `[network]` config docs.
|
||||||
|
*/
|
||||||
|
val connectionGroupSize: Int? = null,
|
||||||
|
/** Ktor CIO worker-thread count. `null` keeps Ktor's default. */
|
||||||
|
val workerGroupSize: Int? = null,
|
||||||
|
/** Ktor CIO call-handling thread count. `null` keeps Ktor's default. */
|
||||||
|
val callGroupSize: Int? = null,
|
||||||
|
) {
|
||||||
|
private val infoHolder =
|
||||||
|
object : Nip86Server.InfoHolder {
|
||||||
|
override fun get(): Nip11RelayInformation = relay.info.document
|
||||||
|
|
||||||
|
override fun set(info: Nip11RelayInformation) {
|
||||||
|
relay.updateInfo { info }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val nip86Server = Nip86Server(banStore = relay.banStore, infoHolder = infoHolder, store = relay.store)
|
||||||
|
private val nip86Route =
|
||||||
|
Nip86HttpRoute(
|
||||||
|
server = nip86Server,
|
||||||
|
verifier = Nip98AuthVerifier(),
|
||||||
|
allowList = adminPubkeys.mapTo(HashSet()) { it.lowercase() },
|
||||||
|
maxBodyBytes = maxAdminBodyBytes,
|
||||||
|
signedUrlFor = { call ->
|
||||||
|
publicUrl ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private var engine: CIOApplicationEngine? = null
|
||||||
|
private var resolvedPort: Int = -1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set when [stop] begins. Once true, the WebSocket handler refuses
|
||||||
|
* new upgrades — Ktor's `engine.stop` will eventually do this too,
|
||||||
|
* but Ktor's grace window means new connections can land between
|
||||||
|
* `notifyShutdown` and the actual port-close, missing the NOTICE
|
||||||
|
* we just sent to existing clients.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var shuttingDown: Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active client sessions, registered when their WebSocket handler
|
||||||
|
* runs and removed on disconnect. Exposed (read-only) so [stop] can
|
||||||
|
* NOTICE every connected client during graceful drain, and so tests
|
||||||
|
* can assert lifecycle bookkeeping.
|
||||||
|
*/
|
||||||
|
private val activeSessions: MutableSet<RelaySession> = ConcurrentHashMap.newKeySet()
|
||||||
|
|
||||||
|
/** Number of WebSocket sessions currently connected to the server. */
|
||||||
|
val activeSessionCount: Int get() = activeSessions.size
|
||||||
|
|
||||||
|
/** `ws://host:port/path` — only valid after [start]. */
|
||||||
|
val url: String
|
||||||
|
get() {
|
||||||
|
check(resolvedPort != -1) { "Server not started" }
|
||||||
|
return "ws://$host:$resolvedPort$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds the Ktor engine. Returns once the engine reports ready, so
|
||||||
|
* [url] is safe to read on the very next line.
|
||||||
|
*/
|
||||||
|
fun start(): LocalRelayServer {
|
||||||
|
// Snapshot the constructor-supplied overrides into locals so
|
||||||
|
// the `configure` lambda below can assign to its receiver
|
||||||
|
// without the names colliding with outer properties.
|
||||||
|
val connGrp = connectionGroupSize
|
||||||
|
val workGrp = workerGroupSize
|
||||||
|
val callGrp = callGroupSize
|
||||||
|
val bindHost = host
|
||||||
|
val bindPort = port
|
||||||
|
val server =
|
||||||
|
embeddedServer(
|
||||||
|
factory = CIO,
|
||||||
|
rootConfig =
|
||||||
|
serverConfig {
|
||||||
|
module {
|
||||||
|
install(WebSockets) {
|
||||||
|
maxFrameBytes?.let { maxFrameSize = it }
|
||||||
|
}
|
||||||
|
routing {
|
||||||
|
// NIP-11: GET on the relay URL with Accept:
|
||||||
|
// application/nostr+json returns the relay info doc.
|
||||||
|
// We mount this *before* the webSocket route so Ktor
|
||||||
|
// serves NIP-11 for plain HTTP GETs and only upgrades
|
||||||
|
// to a WebSocket when the request is a WS upgrade.
|
||||||
|
get(path) {
|
||||||
|
val accept = call.request.header(HttpHeaders.Accept).orEmpty()
|
||||||
|
if (accept.contains("application/nostr+json")) {
|
||||||
|
call.response.headers.append("Access-Control-Allow-Origin", "*")
|
||||||
|
call.respondText(
|
||||||
|
relay.info.json,
|
||||||
|
ContentType.parse("application/nostr+json"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
call.respondText(
|
||||||
|
"Use a Nostr client (NIP-01 WebSocket) or send Accept: application/nostr+json (NIP-11).",
|
||||||
|
ContentType.Text.Plain,
|
||||||
|
HttpStatusCode.UpgradeRequired,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// NIP-86: POST application/nostr+json+rpc with a NIP-98
|
||||||
|
// signed Authorization header → JSON-RPC dispatch.
|
||||||
|
post(path) {
|
||||||
|
nip86Route.handle(call)
|
||||||
|
}
|
||||||
|
webSocket(path) {
|
||||||
|
if (shuttingDown) {
|
||||||
|
// Just return — Ktor closes the WS for us.
|
||||||
|
return@webSocket
|
||||||
|
}
|
||||||
|
WebSocketSessionPump(this).pump(
|
||||||
|
server = relay.server,
|
||||||
|
registerSession = activeSessions::add,
|
||||||
|
unregisterSession = activeSessions::remove,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
configure = {
|
||||||
|
connector {
|
||||||
|
host = bindHost
|
||||||
|
port = bindPort
|
||||||
|
}
|
||||||
|
// Keep Ktor defaults unless the operator overrode
|
||||||
|
// them — Ktor's per-CPU sizing is sensible for
|
||||||
|
// most deployments, and over-threading hurts L1/L2
|
||||||
|
// locality at low connection counts.
|
||||||
|
connGrp?.let { connectionGroupSize = it }
|
||||||
|
workGrp?.let { workerGroupSize = it }
|
||||||
|
callGrp?.let { callGroupSize = it }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
server.start(wait = false)
|
||||||
|
engine = server.engine
|
||||||
|
// Ktor 3.x made resolvedConnectors() suspend. We block here so
|
||||||
|
// start() returns synchronously with [url] readable on the next line.
|
||||||
|
resolvedPort =
|
||||||
|
runBlocking {
|
||||||
|
server.engine
|
||||||
|
.resolvedConnectors()
|
||||||
|
.first()
|
||||||
|
.port
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graceful shutdown. Safe to call multiple times.
|
||||||
|
*
|
||||||
|
* 1. Sends a NOTICE("closing: …") to every currently-connected
|
||||||
|
* client so well-behaved clients know to reconnect later.
|
||||||
|
* 2. Stops the Ktor engine: rejects new connections immediately,
|
||||||
|
* then waits up to [gracePeriodMillis] for active WebSocket
|
||||||
|
* handlers to finish whatever they're processing (so an in-flight
|
||||||
|
* `EVENT` lands its `OK` reply before the socket dies). After
|
||||||
|
* the grace window, in-progress handlers are cancelled and the
|
||||||
|
* engine waits up to [timeoutMillis] - [gracePeriodMillis] for
|
||||||
|
* that cancellation to complete.
|
||||||
|
*
|
||||||
|
* Defaults to 5 s grace / 10 s total — generous enough that a
|
||||||
|
* SQLite write + reply round-trip can land for typical event
|
||||||
|
* sizes. Override either with a tighter budget if your operator
|
||||||
|
* knows their workload.
|
||||||
|
*/
|
||||||
|
fun stop(
|
||||||
|
gracePeriodMillis: Long = 5_000,
|
||||||
|
timeoutMillis: Long = 10_000,
|
||||||
|
) {
|
||||||
|
val e = engine ?: return
|
||||||
|
// Order: (1) refuse new connections so they don't slip in and
|
||||||
|
// miss the NOTICE; (2) NOTICE every existing session so
|
||||||
|
// well-behaved clients reconnect later; (3) hand off to Ktor
|
||||||
|
// for the grace + timeout dance.
|
||||||
|
shuttingDown = true
|
||||||
|
notifyShutdown()
|
||||||
|
e.stop(gracePeriodMillis, timeoutMillis)
|
||||||
|
engine = null
|
||||||
|
resolvedPort = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort NOTICE to every active client. Failures are
|
||||||
|
* swallowed — a flaky socket on its way out is exactly the case
|
||||||
|
* where a NOTICE will fail anyway, and the client's read of the
|
||||||
|
* close frame is the authoritative shutdown signal.
|
||||||
|
*/
|
||||||
|
private fun notifyShutdown() {
|
||||||
|
val notice = NoticeMessage("closing: relay is shutting down — please reconnect later")
|
||||||
|
activeSessions.forEach { session ->
|
||||||
|
runCatching { session.send(notice) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/*
|
||||||
|
* 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.geode
|
||||||
|
|
||||||
|
import com.vitorpamplona.geode.config.RelayConfig
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone entry point.
|
||||||
|
*
|
||||||
|
* Run with:
|
||||||
|
* ./gradlew :geode:run --args="--config /etc/geode.toml"
|
||||||
|
* or
|
||||||
|
* java -cp ... com.vitorpamplona.geode.MainKt --port 7447 --verify
|
||||||
|
*
|
||||||
|
* Configuration precedence (highest to lowest):
|
||||||
|
* 1. CLI flags (`--host`, `--port`, …)
|
||||||
|
* 2. TOML file passed via `--config <path>`
|
||||||
|
* 3. Built-in defaults (host=0.0.0.0, port=7447, in-memory db, …)
|
||||||
|
*
|
||||||
|
* Every section is enforced: `[info]` populates the NIP-11 doc,
|
||||||
|
* `[network]` controls the bind, `[database]` chooses the SQLite path,
|
||||||
|
* `[options]` toggles AUTH/verify/future-skew, `[limits]` and
|
||||||
|
* `[authorization]` plug into the relay's policy stack.
|
||||||
|
*
|
||||||
|
* CLI flags:
|
||||||
|
* --config <file> TOML config (see config.example.toml)
|
||||||
|
* --host <addr> bind address (default from config or 0.0.0.0)
|
||||||
|
* --port <n> tcp port (default from config or 7447, 0 to autobind)
|
||||||
|
* --path <p> ws path (default from config or /)
|
||||||
|
* --info <file> NIP-11 doc file (overrides [info] section)
|
||||||
|
* --db <file> sqlite db path (overrides [database].file)
|
||||||
|
* --auth require NIP-42 AUTH (sets options.require_auth = true)
|
||||||
|
* --no-verify DO NOT verify event signatures (off by default
|
||||||
|
* verify is on; use only for trusted-input
|
||||||
|
* scenarios like fixture replay).
|
||||||
|
*/
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
val a = parseArgs(args)
|
||||||
|
|
||||||
|
val config: RelayConfig =
|
||||||
|
a
|
||||||
|
.opt("--config")
|
||||||
|
?.let { RelayConfig.fromFile(File(it)) }
|
||||||
|
?: RelayConfig()
|
||||||
|
|
||||||
|
val host = a.opt("--host") ?: config.network.host
|
||||||
|
val port = a.opt("--port")?.toInt() ?: config.network.port
|
||||||
|
val path = a.opt("--path") ?: config.network.path
|
||||||
|
|
||||||
|
val cliInfoFile = a.opt("--info")?.let { File(it) }
|
||||||
|
val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory }
|
||||||
|
val requireAuth = a.flag("--auth") || config.options.require_auth
|
||||||
|
// Verify is on by default; only disable when the operator explicitly
|
||||||
|
// opts out (CLI `--no-verify` or `[options].verify_signatures = false`
|
||||||
|
// in the config).
|
||||||
|
val verifySigs = !a.flag("--no-verify") && config.options.verify_signatures
|
||||||
|
// Parallel verify is on whenever signature checking is on; the
|
||||||
|
// IngestQueue handles it instead of VerifyPolicy. Operators can
|
||||||
|
// force the legacy in-policy path with `--no-parallel-verify` or
|
||||||
|
// `[options].parallel_verify = false`.
|
||||||
|
val parallelVerify =
|
||||||
|
verifySigs && !a.flag("--no-parallel-verify") && config.options.parallel_verify
|
||||||
|
|
||||||
|
// Advertised URL: explicit `info.relay_url` wins, then build from
|
||||||
|
// host/port/path. 0.0.0.0 bind → 127.0.0.1 in the URL so NIP-42
|
||||||
|
// challenges are well-formed.
|
||||||
|
val advertisedHost = if (host == "0.0.0.0") "127.0.0.1" else host
|
||||||
|
val advertisedUrl =
|
||||||
|
(config.info.relay_url ?: "ws://$advertisedHost:$port$path").normalizeRelayUrl()
|
||||||
|
|
||||||
|
val info =
|
||||||
|
cliInfoFile?.let { RelayInfo.fromFile(it) }
|
||||||
|
?: config.resolveInfo(advertisedUrl)
|
||||||
|
|
||||||
|
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
|
||||||
|
|
||||||
|
val policyBuilder: () -> IRelayPolicy = {
|
||||||
|
composePolicy(config, advertisedUrl, requireAuth, verifySigs, parallelVerify)
|
||||||
|
}
|
||||||
|
|
||||||
|
val stateFile = config.admin.state_file?.let { File(it) }
|
||||||
|
val negentropySettings =
|
||||||
|
NegentropySettings(
|
||||||
|
frameSizeLimit = config.negentropy.frame_size_limit,
|
||||||
|
maxSyncEvents = config.negentropy.max_sync_events,
|
||||||
|
maxSessionsPerConnection = config.negentropy.max_sessions_per_connection,
|
||||||
|
)
|
||||||
|
val relay =
|
||||||
|
Relay(
|
||||||
|
advertisedUrl,
|
||||||
|
store,
|
||||||
|
info,
|
||||||
|
policyBuilder,
|
||||||
|
stateFile = stateFile,
|
||||||
|
parallelVerify = parallelVerify,
|
||||||
|
negentropySettings = negentropySettings,
|
||||||
|
)
|
||||||
|
// Frame cap honors max_ws_frame_bytes when set; max_ws_message_bytes
|
||||||
|
// is treated as the same cap (Ktor's WebSockets plugin only exposes
|
||||||
|
// a single per-frame limit; multi-frame messages remain unbounded).
|
||||||
|
val frameLimit =
|
||||||
|
(config.limits.max_ws_frame_bytes ?: config.limits.max_ws_message_bytes)?.toLong()
|
||||||
|
val server =
|
||||||
|
LocalRelayServer(
|
||||||
|
relay,
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
path = path,
|
||||||
|
maxFrameBytes = frameLimit,
|
||||||
|
adminPubkeys = config.admin.pubkeys.toSet(),
|
||||||
|
publicUrl = config.admin.public_url,
|
||||||
|
connectionGroupSize = config.network.connection_group_size,
|
||||||
|
workerGroupSize = config.network.worker_group_size,
|
||||||
|
callGroupSize = config.network.call_group_size,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
Runtime.getRuntime().addShutdownHook(
|
||||||
|
Thread {
|
||||||
|
// Each step wrapped so a throw in `server.stop()` doesn't
|
||||||
|
// skip `relay.close()` (which closes the SQLite store).
|
||||||
|
runCatching { server.stop() }
|
||||||
|
runCatching { relay.close() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
println("geode listening on ${server.url}")
|
||||||
|
println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$advertisedHost:$port$path")
|
||||||
|
|
||||||
|
// Park the main thread; shutdown hook handles teardown.
|
||||||
|
Thread.currentThread().join()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the policy stack for one connection from the config.
|
||||||
|
*
|
||||||
|
* Order matters — cheap rejection paths run before expensive ones:
|
||||||
|
* 1. AUTH (drops everything if not authenticated)
|
||||||
|
* 2. Future-timestamp + allow/deny lists
|
||||||
|
* 3. Signature verification (most expensive — Schnorr verify)
|
||||||
|
*/
|
||||||
|
private fun composePolicy(
|
||||||
|
config: RelayConfig,
|
||||||
|
advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
|
||||||
|
requireAuth: Boolean,
|
||||||
|
verifySigs: Boolean,
|
||||||
|
parallelVerify: Boolean,
|
||||||
|
): IRelayPolicy {
|
||||||
|
val pieces = mutableListOf<IRelayPolicy>()
|
||||||
|
|
||||||
|
if (requireAuth) {
|
||||||
|
pieces += FullAuthPolicy(advertisedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.options.reject_future_seconds?.let { secs ->
|
||||||
|
pieces += RejectFutureEventsPolicy(secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
val auth = config.authorization
|
||||||
|
if (auth.kind_whitelist.isNotEmpty() || auth.kind_blacklist.isNotEmpty()) {
|
||||||
|
pieces += KindAllowDenyPolicy(auth.kind_whitelist.toSet(), auth.kind_blacklist.toSet())
|
||||||
|
}
|
||||||
|
if (auth.pubkey_whitelist.isNotEmpty() || auth.pubkey_blacklist.isNotEmpty()) {
|
||||||
|
pieces += PubkeyAllowDenyPolicy(auth.pubkey_whitelist.toSet(), auth.pubkey_blacklist.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifySigs) {
|
||||||
|
// When parallel verify is on, the IngestQueue handles EVENT
|
||||||
|
// verification on the writer's CPU fan-out — but AUTH events
|
||||||
|
// bypass the queue, so we still need the policy chain to
|
||||||
|
// verify those. `VerifyAuthOnlyPolicy` does exactly that.
|
||||||
|
pieces += if (parallelVerify) VerifyAuthOnlyPolicy else VerifyPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
return pieces.fold<IRelayPolicy, IRelayPolicy>(EmptyPolicy) { acc, p ->
|
||||||
|
if (acc === EmptyPolicy) p else acc + p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Args(
|
||||||
|
private val opts: Map<String, String>,
|
||||||
|
private val flags: Set<String>,
|
||||||
|
) {
|
||||||
|
fun opt(k: String) = opts[k]
|
||||||
|
|
||||||
|
fun flag(k: String) = k in flags
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseArgs(args: Array<String>): Args {
|
||||||
|
val opts = mutableMapOf<String, String>()
|
||||||
|
val flags = mutableSetOf<String>()
|
||||||
|
var i = 0
|
||||||
|
while (i < args.size) {
|
||||||
|
val a = args[i]
|
||||||
|
if (a.startsWith("--")) {
|
||||||
|
// Support both `--key value` and `--key=value`. Splitting
|
||||||
|
// on the first `=` lets operators paste config values that
|
||||||
|
// happen to contain `=` (e.g. NIP-11 contact emails) by
|
||||||
|
// using the space-separated form.
|
||||||
|
val eq = a.indexOf('=')
|
||||||
|
if (eq > 0) {
|
||||||
|
opts[a.substring(0, eq)] = a.substring(eq + 1)
|
||||||
|
i += 1
|
||||||
|
} else {
|
||||||
|
val next = args.getOrNull(i + 1)
|
||||||
|
if (next != null && !next.startsWith("--")) {
|
||||||
|
opts[a] = next
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
flags += a
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Args(opts, flags)
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/*
|
||||||
|
* 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.geode
|
||||||
|
|
||||||
|
import com.vitorpamplona.geode.persistence.BannedEntry
|
||||||
|
import com.vitorpamplona.geode.persistence.RelayPersistedState
|
||||||
|
import com.vitorpamplona.geode.persistence.RelayStateStore
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||||
|
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A self-contained Nostr relay scoped to a single URL. Wraps a [NostrServer]
|
||||||
|
* over an [EventStore] (defaults to an in-memory SQLite database).
|
||||||
|
*
|
||||||
|
* Speaks NIP-01 (REQ/EVENT/EOSE/CLOSE), NIP-11 (relay info via [info]),
|
||||||
|
* NIP-42 (AUTH — supply [policyBuilder] = `{ FullAuthPolicy(url) }` or
|
||||||
|
* stack one with [com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy.plus]),
|
||||||
|
* NIP-45 (COUNT) and NIP-50 (search via the SQLite FTS index).
|
||||||
|
*
|
||||||
|
* Two transports:
|
||||||
|
* - [com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket] /
|
||||||
|
* [RelayHub] — no socket, fastest path, ideal
|
||||||
|
* for unit tests inside one JVM.
|
||||||
|
* - [LocalRelayServer] — Ktor `embeddedServer` listening on a real port.
|
||||||
|
* Use when external clients need to connect (`cli`, instrumented tests,
|
||||||
|
* standalone deployment).
|
||||||
|
*/
|
||||||
|
class Relay(
|
||||||
|
val url: NormalizedRelayUrl,
|
||||||
|
val store: IEventStore = EventStore(dbName = null, relay = url),
|
||||||
|
info: RelayInfo = RelayInfo.default(url),
|
||||||
|
policyBuilder: () -> IRelayPolicy = { EmptyPolicy },
|
||||||
|
parentContext: CoroutineContext = SupervisorJob(),
|
||||||
|
/**
|
||||||
|
* Optional path for the operator-state JSON snapshot. When set,
|
||||||
|
* the file is loaded at boot to seed [info] and [banStore], and
|
||||||
|
* rewritten atomically on every NIP-86 mutation and
|
||||||
|
* [updateInfo] call so admin actions survive restarts.
|
||||||
|
*
|
||||||
|
* Convention: place next to the SQLite event-store file
|
||||||
|
* (e.g. `events.db` → `events.db.admin.json`). `null` keeps
|
||||||
|
* everything in memory only — fine for tests.
|
||||||
|
*/
|
||||||
|
stateFile: File? = null,
|
||||||
|
/**
|
||||||
|
* Run Schnorr signature verification in parallel inside the
|
||||||
|
* [com.vitorpamplona.quartz.nip01Core.relay.server.IngestQueue]
|
||||||
|
* instead of serially in the policy chain. Enables the Tier-3
|
||||||
|
* win in `geode/plans/2026-05-07-event-ingestion-batching.md`.
|
||||||
|
*
|
||||||
|
* When set, callers MUST omit `VerifyPolicy` from [policyBuilder]
|
||||||
|
* — having both verifies the same event twice for no benefit.
|
||||||
|
* `Main.kt` skips `VerifyPolicy` when this flag is on.
|
||||||
|
*/
|
||||||
|
parallelVerify: Boolean = false,
|
||||||
|
/**
|
||||||
|
* NIP-77 server-side tuning (frame cap, snapshot cap,
|
||||||
|
* per-connection session cap). Defaults to strfry-parity values.
|
||||||
|
*/
|
||||||
|
negentropySettings: NegentropySettings = NegentropySettings.Default,
|
||||||
|
) : AutoCloseable {
|
||||||
|
private val stateStore: RelayStateStore? = stateFile?.let { RelayStateStore(it) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-11 doc. Mutable so NIP-86 admin RPCs (`changerelayname`,
|
||||||
|
* `changerelaydescription`, `changerelayicon`) can swap the doc
|
||||||
|
* atomically. Readers (the NIP-11 GET endpoint) re-read on every
|
||||||
|
* request so changes are visible immediately, no restart needed.
|
||||||
|
*
|
||||||
|
* If a [RelayStateStore] is configured and the snapshot exists,
|
||||||
|
* the persisted info doc takes precedence over the constructor
|
||||||
|
* default — operators expect their last `changerelayname` to
|
||||||
|
* survive a restart.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var info: RelayInfo =
|
||||||
|
stateStore?.load()?.info?.let { RelayInfo(it) } ?: info
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Mutates the live NIP-11 doc. Called by [Nip86Server]. */
|
||||||
|
fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
|
||||||
|
info = RelayInfo(transform(info.document))
|
||||||
|
snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime-mutable ban / allow lists. NIP-86 RPC handlers in
|
||||||
|
* [Nip86Server] mutate this; the policy stack consults it on
|
||||||
|
* every accept call via [BanListPolicy].
|
||||||
|
*/
|
||||||
|
val banStore: BanStore = BanStore(onMutation = { snapshot() })
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Seed the in-memory ban state from disk *without* triggering
|
||||||
|
// [snapshot] on every entry — the snapshot is exactly what we
|
||||||
|
// just loaded.
|
||||||
|
stateStore?.load()?.let { snap ->
|
||||||
|
banStore.seedFromSnapshot(
|
||||||
|
bannedPubkeys = snap.bannedPubkeys.map { it.key to it.reason },
|
||||||
|
allowedPubkeys = snap.allowedPubkeys.map { it.key to it.reason },
|
||||||
|
bannedEvents = snap.bannedEvents.map { it.key to it.reason },
|
||||||
|
allowedKinds = snap.allowedKinds,
|
||||||
|
disallowedKinds = snap.disallowedKinds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the current state (NIP-11 doc + ban lists) to disk.
|
||||||
|
* No-op when no `stateFile` was configured.
|
||||||
|
*
|
||||||
|
* Best-effort: any I/O failure is logged to stderr and swallowed
|
||||||
|
* so an unwritable disk doesn't take the relay down. Operators
|
||||||
|
* monitor for missing snapshots out-of-band.
|
||||||
|
*/
|
||||||
|
fun snapshot() {
|
||||||
|
val s = stateStore ?: return
|
||||||
|
runCatching {
|
||||||
|
s.save(
|
||||||
|
RelayPersistedState(
|
||||||
|
info = info.document,
|
||||||
|
bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) },
|
||||||
|
allowedPubkeys = banStore.listAllowedPubkeys().map { (k, r) -> BannedEntry(k, r) },
|
||||||
|
bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) },
|
||||||
|
allowedKinds = banStore.listAllowedKinds(),
|
||||||
|
disallowedKinds = banStore.listDisallowedKinds(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
System.err.println("warning: failed to write relay state file: ${it.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val server =
|
||||||
|
NostrServer(
|
||||||
|
store,
|
||||||
|
// Always prepend a BanListPolicy so NIP-86 admin actions
|
||||||
|
// bite. When the operator-supplied builder returns
|
||||||
|
// [EmptyPolicy] we use the dynamic policy alone; otherwise
|
||||||
|
// we stack them so both layers must accept.
|
||||||
|
policyBuilder = {
|
||||||
|
val user = policyBuilder()
|
||||||
|
if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore)
|
||||||
|
},
|
||||||
|
parentContext = parentContext,
|
||||||
|
parallelVerify = parallelVerify,
|
||||||
|
negentropySettings = negentropySettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts events directly into the underlying store, bypassing the wire protocol.
|
||||||
|
*
|
||||||
|
* Use this for **pre-test setup** — events that exist before any client connects.
|
||||||
|
* It does NOT broadcast to active subscriptions. For sending events that should
|
||||||
|
* fan out to live subscribers (post-EOSE), use [publish] instead.
|
||||||
|
*/
|
||||||
|
suspend fun preload(events: Iterable<Event>) {
|
||||||
|
events.forEach { store.insert(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @see preload(Iterable) */
|
||||||
|
suspend fun preload(vararg events: Event) = preload(events.toList())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes an event through the relay's session machinery so it both lands
|
||||||
|
* in the store and fans out to active subscriptions matching its filters
|
||||||
|
* (mirrors what a real client would do via an `EVENT` command).
|
||||||
|
*/
|
||||||
|
suspend fun publish(event: Event) {
|
||||||
|
val session = server.connect { /* ignore OK echo */ }
|
||||||
|
try {
|
||||||
|
session.receive(OptimizedJsonMapper.toJson(EventCmd(event)))
|
||||||
|
} finally {
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() = server.close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/*
|
||||||
|
* 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.geode
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of [Relay] instances keyed by relay URL. Implements
|
||||||
|
* [WebsocketBuilder] so it can be plugged into
|
||||||
|
* [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] in place of
|
||||||
|
* `BasicOkHttpWebSocket.Builder` to redirect every outbound connection to an
|
||||||
|
* in-memory relay.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```
|
||||||
|
* val hub = RelayHub()
|
||||||
|
* val relay = hub.getOrCreate("ws://test.relay/")
|
||||||
|
* runBlocking { relay.preload(listOf(event1, event2)) }
|
||||||
|
* val client = NostrClient(hub, scope)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Unknown URLs auto-create an empty relay so a single hub can transparently
|
||||||
|
* back any number of test endpoints.
|
||||||
|
*/
|
||||||
|
class RelayHub(
|
||||||
|
private val defaultPolicy: () -> IRelayPolicy = { EmptyPolicy },
|
||||||
|
private val negentropySettings: NegentropySettings = NegentropySettings.Default,
|
||||||
|
) : WebsocketBuilder,
|
||||||
|
AutoCloseable {
|
||||||
|
private val relays = ConcurrentHashMap<NormalizedRelayUrl, Relay>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
fun getOrCreate(url: NormalizedRelayUrl): Relay {
|
||||||
|
check(!closed) { "RelayHub has been closed" }
|
||||||
|
return relays.getOrPut(url) {
|
||||||
|
Relay(
|
||||||
|
url = url,
|
||||||
|
policyBuilder = defaultPolicy,
|
||||||
|
negentropySettings = negentropySettings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getOrCreate(url: String): Relay = getOrCreate(RelayUrlNormalizer.normalize(url))
|
||||||
|
|
||||||
|
fun get(url: NormalizedRelayUrl): Relay? = relays[url]
|
||||||
|
|
||||||
|
fun urls(): Set<NormalizedRelayUrl> = relays.keys.toSet()
|
||||||
|
|
||||||
|
override fun build(
|
||||||
|
url: NormalizedRelayUrl,
|
||||||
|
out: WebSocketListener,
|
||||||
|
): WebSocket = InProcessWebSocket(getOrCreate(url).server, out)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent. Sets the closed flag first so concurrent
|
||||||
|
* `getOrCreate` calls fail-fast — otherwise a relay created
|
||||||
|
* between iteration and clear would leak (its store would never
|
||||||
|
* be closed).
|
||||||
|
*/
|
||||||
|
override fun close() {
|
||||||
|
closed = true
|
||||||
|
relays.values.forEach { runCatching { it.close() } }
|
||||||
|
relays.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Default URL for tests that only need one relay. The URL itself
|
||||||
|
* has no semantic meaning — it's just a stable key into the hub
|
||||||
|
* — but it normalises through [RelayUrlNormalizer] (loopback) so
|
||||||
|
* the production [com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer]
|
||||||
|
* accepts it. Prefer this over typing `"ws://127.0.0.1:7770/"`
|
||||||
|
* everywhere.
|
||||||
|
*/
|
||||||
|
val DEFAULT_URL: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.geode
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay-side handle for the NIP-11 information document. Wraps the
|
||||||
|
* client-side [Nip11RelayInformation] model and provides loaders for
|
||||||
|
* config files plus a default doc that advertises the NIPs this relay
|
||||||
|
* actually implements.
|
||||||
|
*/
|
||||||
|
data class RelayInfo(
|
||||||
|
val document: Nip11RelayInformation,
|
||||||
|
) {
|
||||||
|
/** Pre-rendered JSON, ready to write into the HTTP response body. */
|
||||||
|
val json: String by lazy { JsonMapper.toJson(document) }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NAME = "geode"
|
||||||
|
const val DESCRIPTION = "Embedded Nostr relay from the Amethyst quartz library."
|
||||||
|
const val SOFTWARE = "https://github.com/vitorpamplona/amethyst/tree/main/geode"
|
||||||
|
const val VERSION = "1.08.0"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIPs this relay implements out of the box. Single source of
|
||||||
|
* truth — both [default] and [com.vitorpamplona.geode.config.RelayConfig.resolveInfo]
|
||||||
|
* consult this list. Add a NIP here when its handler is wired
|
||||||
|
* into [com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession]
|
||||||
|
* (or in this module's policy stack).
|
||||||
|
*
|
||||||
|
* Currently:
|
||||||
|
* - 1 NIP-01 basic
|
||||||
|
* - 9 NIP-09 deletion (DeletionRequestModule)
|
||||||
|
* - 11 NIP-11 this doc
|
||||||
|
* - 40 NIP-40 expiration (ExpirationModule)
|
||||||
|
* - 42 NIP-42 AUTH (when policy enables)
|
||||||
|
* - 45 NIP-45 COUNT
|
||||||
|
* - 50 NIP-50 search (SQLite FTS)
|
||||||
|
* - 62 NIP-62 right to vanish
|
||||||
|
* - 77 NIP-77 negentropy reconciliation
|
||||||
|
* - 86 NIP-86 relay management API (when admin pubkeys configured)
|
||||||
|
*/
|
||||||
|
val SUPPORTED_NIPS: List<String> =
|
||||||
|
listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86")
|
||||||
|
|
||||||
|
/** Pre-built default for `Relay(url = ...)` — advertises the supported NIPs. */
|
||||||
|
fun default(url: NormalizedRelayUrl): RelayInfo =
|
||||||
|
RelayInfo(
|
||||||
|
Nip11RelayInformation(
|
||||||
|
name = NAME,
|
||||||
|
description = DESCRIPTION,
|
||||||
|
software = SOFTWARE,
|
||||||
|
version = VERSION,
|
||||||
|
supported_nips = SUPPORTED_NIPS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Loads a NIP-11 doc from a JSON file (e.g. a relay operator's config). */
|
||||||
|
fun fromFile(file: File): RelayInfo = RelayInfo(Nip11RelayInformation.fromJson(file.readText()))
|
||||||
|
|
||||||
|
/** Parses a NIP-11 doc from a raw JSON string. */
|
||||||
|
fun fromJson(json: String): RelayInfo = RelayInfo(Nip11RelayInformation.fromJson(json))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/*
|
||||||
|
* 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.geode.config
|
||||||
|
|
||||||
|
import cc.ekblad.toml.decode
|
||||||
|
import cc.ekblad.toml.tomlMapper
|
||||||
|
import com.vitorpamplona.geode.RelayInfo
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator-facing configuration. Section layout matches nostr-rs-relay's
|
||||||
|
* `config.toml` so existing configs can be ported with little churn.
|
||||||
|
*
|
||||||
|
* Every section is optional; values not set fall back to sensible
|
||||||
|
* defaults (or, for fields also exposed on the CLI, the CLI value wins).
|
||||||
|
*/
|
||||||
|
data class RelayConfig(
|
||||||
|
val info: InfoSection = InfoSection(),
|
||||||
|
val network: NetworkSection = NetworkSection(),
|
||||||
|
val database: DatabaseSection = DatabaseSection(),
|
||||||
|
val options: OptionsSection = OptionsSection(),
|
||||||
|
val limits: LimitsSection = LimitsSection(),
|
||||||
|
val authorization: AuthorizationSection = AuthorizationSection(),
|
||||||
|
val admin: AdminSection = AdminSection(),
|
||||||
|
val negentropy: NegentropySection = NegentropySection(),
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Maps the `[info]` section into a [RelayInfo] used by the NIP-11
|
||||||
|
* endpoint. `relay_url` and CLI overrides take precedence.
|
||||||
|
*/
|
||||||
|
fun resolveInfo(advertisedUrl: NormalizedRelayUrl): RelayInfo =
|
||||||
|
RelayInfo(
|
||||||
|
Nip11RelayInformation(
|
||||||
|
name = info.name ?: RelayInfo.NAME,
|
||||||
|
description = info.description ?: RelayInfo.DESCRIPTION,
|
||||||
|
pubkey = info.pubkey,
|
||||||
|
contact = info.contact,
|
||||||
|
icon = info.icon,
|
||||||
|
software = info.software ?: RelayInfo.SOFTWARE,
|
||||||
|
version = info.version ?: RelayInfo.VERSION,
|
||||||
|
supported_nips =
|
||||||
|
info.supported_nips?.map(Int::toString)
|
||||||
|
?: RelayInfo.SUPPORTED_NIPS,
|
||||||
|
privacy_policy = info.privacy_policy,
|
||||||
|
terms_of_service = info.terms_of_service,
|
||||||
|
relay_countries = info.relay_countries,
|
||||||
|
language_tags = info.language_tags,
|
||||||
|
tags = info.tags,
|
||||||
|
),
|
||||||
|
).also {
|
||||||
|
// Touch [advertisedUrl] so the parameter isn't unused — we keep
|
||||||
|
// it in the signature because future fields (e.g. self-pubkey
|
||||||
|
// selection, fee URLs) will want it.
|
||||||
|
advertisedUrl.url
|
||||||
|
}
|
||||||
|
|
||||||
|
data class InfoSection(
|
||||||
|
val relay_url: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val pubkey: String? = null,
|
||||||
|
val contact: String? = null,
|
||||||
|
val icon: String? = null,
|
||||||
|
val software: String? = null,
|
||||||
|
val version: String? = null,
|
||||||
|
/** NIP numbers as ints (e.g. `[1, 9, 11]`). Stringified at render time. */
|
||||||
|
val supported_nips: List<Int>? = null,
|
||||||
|
val privacy_policy: String? = null,
|
||||||
|
val terms_of_service: String? = null,
|
||||||
|
val relay_countries: List<String>? = null,
|
||||||
|
val language_tags: List<String>? = null,
|
||||||
|
val tags: List<String>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NetworkSection(
|
||||||
|
val host: String = "0.0.0.0",
|
||||||
|
val port: Int = 7447,
|
||||||
|
val path: String = "/",
|
||||||
|
/**
|
||||||
|
* Ktor CIO acceptor-thread count. `null` (default) keeps Ktor's
|
||||||
|
* default sizing — fine up to a few thousand concurrent
|
||||||
|
* connections. On big-VM deployments targeting 10k+
|
||||||
|
* connections, lift this to roughly half the available cores
|
||||||
|
* so the acceptor doesn't starve workers.
|
||||||
|
*/
|
||||||
|
val connection_group_size: Int? = null,
|
||||||
|
/**
|
||||||
|
* Ktor CIO worker-thread count (handles socket I/O). `null`
|
||||||
|
* keeps Ktor's default. Each connection's WebSocket read/write
|
||||||
|
* is dispatched onto this pool; for many idle long-lived
|
||||||
|
* connections the pool can stay small, but 10k+ connections
|
||||||
|
* benefit from sizing this to the full CPU count.
|
||||||
|
*/
|
||||||
|
val worker_group_size: Int? = null,
|
||||||
|
/**
|
||||||
|
* Ktor CIO call-handling thread count. `null` keeps Ktor's
|
||||||
|
* default. Sized higher than [worker_group_size] because each
|
||||||
|
* call (incl. WebSocket upgrade) may suspend on I/O — at
|
||||||
|
* 10k+ connections, ~4× cores is a reasonable starting point.
|
||||||
|
*/
|
||||||
|
val call_group_size: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DatabaseSection(
|
||||||
|
/** True keeps an in-memory SQLite db (default — events vanish on restart). */
|
||||||
|
val in_memory: Boolean = true,
|
||||||
|
/** Filesystem path for a persistent SQLite db. Ignored when [in_memory] is true. */
|
||||||
|
val file: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class OptionsSection(
|
||||||
|
/** Reject events whose `created_at` is more than this many seconds in the future. */
|
||||||
|
val reject_future_seconds: Int? = null,
|
||||||
|
/** Require NIP-42 AUTH for REQ/EVENT/COUNT. */
|
||||||
|
val require_auth: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Drop events whose Schnorr signature does not verify. **Defaults
|
||||||
|
* to `true`**: any relay accepting traffic from real clients
|
||||||
|
* should verify signatures, and verifying-by-default closes the
|
||||||
|
* footgun of forgetting the flag. Set explicitly to `false` only
|
||||||
|
* for trusted-input scenarios (test fixtures, mirror replays).
|
||||||
|
*/
|
||||||
|
val verify_signatures: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Run signature verification in parallel inside the IngestQueue
|
||||||
|
* (CPU fan-out across `Dispatchers.Default`) instead of serially
|
||||||
|
* on each connection's WebSocket pump. Tier-3 of the
|
||||||
|
* `event-ingestion-batching` plan. Wins scale with how many
|
||||||
|
* EVENTs a single connection sends back-to-back: ~CPU_COUNT×
|
||||||
|
* verify-step speed-up on burst publishes. Set false to keep
|
||||||
|
* the legacy in-policy verify path.
|
||||||
|
*
|
||||||
|
* Only takes effect when [verify_signatures] is also true.
|
||||||
|
*/
|
||||||
|
val parallel_verify: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LimitsSection(
|
||||||
|
val max_ws_message_bytes: Int? = null,
|
||||||
|
val max_ws_frame_bytes: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-77 negentropy tuning. Defaults track strfry
|
||||||
|
* (`hoytech/strfry`) so a Geode relay accepts the same workload
|
||||||
|
* shape and exchanges the same NEG-MSG round-trip size as
|
||||||
|
* strfry — the de-facto reference implementation.
|
||||||
|
*
|
||||||
|
* - [frame_size_limit] mirrors strfry's hard-coded
|
||||||
|
* `Negentropy ne(storage, 500'000)` in `RelayNegentropy.cpp`.
|
||||||
|
* Hex-encoded that's ~1 MB on the wire per NEG-MSG; ensure
|
||||||
|
* `[limits].max_ws_frame_bytes` (when set) is at least double
|
||||||
|
* this or NEG-MSGs get truncated by the WS layer.
|
||||||
|
* - [max_sync_events] mirrors strfry's
|
||||||
|
* `relay__negentropy__maxSyncEvents`. NEG-OPEN whose snapshot
|
||||||
|
* exceeds this returns
|
||||||
|
* `["NEG-ERR", "<subId>", "blocked: too many query results"]`.
|
||||||
|
* - [max_sessions_per_connection] caps concurrent NEG-OPEN
|
||||||
|
* sessions held by a single connection. strfry shares its
|
||||||
|
* 200-cap with REQ subs via `relay__maxSubsPerConnection`;
|
||||||
|
* Geode counts NEG independently for now (REQ has no cap yet).
|
||||||
|
* Overflow returns NOTICE
|
||||||
|
* `"too many concurrent NEG requests"` (matches strfry).
|
||||||
|
*/
|
||||||
|
data class NegentropySection(
|
||||||
|
val frame_size_limit: Long = 500_000L,
|
||||||
|
val max_sync_events: Int = 1_000_000,
|
||||||
|
val max_sessions_per_connection: Int = 200,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AuthorizationSection(
|
||||||
|
val pubkey_whitelist: List<String> = emptyList(),
|
||||||
|
val pubkey_blacklist: List<String> = emptyList(),
|
||||||
|
val kind_whitelist: List<Int> = emptyList(),
|
||||||
|
val kind_blacklist: List<Int> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-86 relay management API. When [pubkeys] is non-empty,
|
||||||
|
* `LocalRelayServer` exposes a POST endpoint at the relay path
|
||||||
|
* that accepts JSON-RPC admin requests authenticated via NIP-98
|
||||||
|
* HTTP-Auth. Only requests signed by one of these pubkeys are
|
||||||
|
* dispatched.
|
||||||
|
*
|
||||||
|
* [public_url] is the canonical URL the relay is reachable at,
|
||||||
|
* e.g. `https://relay.example.com/`. NIP-98's URL binding compares
|
||||||
|
* the signed `u` tag against this — without it, an attacker can
|
||||||
|
* spoof the `Host` header to bind their signature to any URL.
|
||||||
|
* Required when running behind TLS termination or a reverse proxy.
|
||||||
|
*/
|
||||||
|
data class AdminSection(
|
||||||
|
val pubkeys: List<String> = emptyList(),
|
||||||
|
val public_url: String? = null,
|
||||||
|
/**
|
||||||
|
* Path for the JSON snapshot that persists NIP-86 admin state
|
||||||
|
* (ban lists + the live NIP-11 doc) across restarts. When
|
||||||
|
* unset, admin state is in-memory only.
|
||||||
|
*
|
||||||
|
* Convention: place next to the SQLite event-store file —
|
||||||
|
* e.g. `[database].file = "/var/lib/geode/events.db"`
|
||||||
|
* pairs with `[admin].state_file = "/var/lib/geode/events.db.admin.json"`.
|
||||||
|
*/
|
||||||
|
val state_file: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val mapper = tomlMapper { }
|
||||||
|
|
||||||
|
/** Parse a TOML string. */
|
||||||
|
fun fromToml(toml: String): RelayConfig = mapper.decode<RelayConfig>(toml)
|
||||||
|
|
||||||
|
/** Load a TOML config file. */
|
||||||
|
fun fromFile(file: File): RelayConfig = mapper.decode<RelayConfig>(file.toPath())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the URL the relay advertises in NIP-11 and NIP-42
|
||||||
|
* challenges. Picks (in order):
|
||||||
|
* 1. `info.relay_url` from the config
|
||||||
|
* 2. The `network` section's host/port/path (with 0.0.0.0 → 127.0.0.1)
|
||||||
|
* 3. The CLI override (handled in `Main.kt`).
|
||||||
|
*/
|
||||||
|
fun advertisedUrl(config: RelayConfig): NormalizedRelayUrl =
|
||||||
|
(
|
||||||
|
config.info.relay_url
|
||||||
|
?: defaultUrl(config.network)
|
||||||
|
).normalizeRelayUrl()
|
||||||
|
|
||||||
|
private fun defaultUrl(net: NetworkSection): String {
|
||||||
|
val host = if (net.host == "0.0.0.0") "127.0.0.1" else net.host
|
||||||
|
return "ws://$host:${net.port}${net.path}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.geode.persistence
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-disk snapshot of the relay's *operator-mutable* state — the
|
||||||
|
* NIP-11 info doc (so `changerelayname/description/icon` survive a
|
||||||
|
* restart) and the NIP-86 ban / allow / kind lists.
|
||||||
|
*
|
||||||
|
* One JSON file per relay. Lives next to the SQLite event store by
|
||||||
|
* convention, but the path is configurable independently. Atomic
|
||||||
|
* write via temp + atomic rename so a crash mid-save can never leave
|
||||||
|
* the file half-written.
|
||||||
|
*
|
||||||
|
* The schema below intentionally mirrors NIP-86 list responses
|
||||||
|
* (`pubkey + reason`, `id + reason`) so a future operator-tools CLI
|
||||||
|
* can read these straight from disk without translation.
|
||||||
|
*/
|
||||||
|
class RelayStateStore(
|
||||||
|
val file: File,
|
||||||
|
) {
|
||||||
|
/** Load the snapshot from disk, or `null` if the file does not yet exist. */
|
||||||
|
@Synchronized
|
||||||
|
fun load(): RelayPersistedState? {
|
||||||
|
if (!file.exists()) return null
|
||||||
|
return try {
|
||||||
|
json.decodeFromString(RelayPersistedState.serializer(), file.readText())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Corrupt file — log to stderr and refuse to overwrite. The
|
||||||
|
// operator chooses whether to fix or delete; we don't blow
|
||||||
|
// away their state silently.
|
||||||
|
System.err.println("warning: failed to read relay state file ${file.absolutePath}: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Atomically write the snapshot. */
|
||||||
|
@Synchronized
|
||||||
|
fun save(state: RelayPersistedState) {
|
||||||
|
file.parentFile?.let { if (!it.exists()) it.mkdirs() }
|
||||||
|
val tmp = File(file.parentFile ?: file.absoluteFile.parentFile, "${file.name}.tmp")
|
||||||
|
tmp.writeText(json.encodeToString(RelayPersistedState.serializer(), state))
|
||||||
|
Files.move(
|
||||||
|
tmp.toPath(),
|
||||||
|
file.toPath(),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
StandardCopyOption.ATOMIC_MOVE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val json =
|
||||||
|
Json {
|
||||||
|
prettyPrint = true
|
||||||
|
encodeDefaults = false
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RelayPersistedState(
|
||||||
|
val info: Nip11RelayInformation? = null,
|
||||||
|
val bannedPubkeys: List<BannedEntry> = emptyList(),
|
||||||
|
val allowedPubkeys: List<BannedEntry> = emptyList(),
|
||||||
|
val bannedEvents: List<BannedEntry> = emptyList(),
|
||||||
|
val allowedKinds: List<Int> = emptyList(),
|
||||||
|
val disallowedKinds: List<Int> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BannedEntry(
|
||||||
|
val key: String,
|
||||||
|
val reason: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/*
|
||||||
|
* 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.geode.server
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
|
||||||
|
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.application.ApplicationCall
|
||||||
|
import io.ktor.server.request.header
|
||||||
|
import io.ktor.server.request.receiveChannel
|
||||||
|
import io.ktor.server.response.respondText
|
||||||
|
import io.ktor.utils.io.readAvailable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-86 admin POST handler. Owns the gating order:
|
||||||
|
* 1. 403 if no admin pubkey list is configured (endpoint disabled).
|
||||||
|
* 2. 413 if body exceeds [maxBodyBytes] (declared or actual).
|
||||||
|
* 3. 401 if the NIP-98 Authorization header is missing/invalid.
|
||||||
|
* 4. 403 if the verified pubkey isn't in [allowList].
|
||||||
|
* 5. 400 if the body isn't a valid Nip86Request.
|
||||||
|
* 6. 200 with a Nip86Response JSON body otherwise.
|
||||||
|
*
|
||||||
|
* The [signedUrlFor] callback resolves what URL the client must have
|
||||||
|
* signed in their NIP-98 token. Operators configure the canonical
|
||||||
|
* `publicUrl`; loopback tests fall back to the request's `Host`
|
||||||
|
* header. We pass it as a callback rather than a string so the route
|
||||||
|
* doesn't need to know about Ktor request internals.
|
||||||
|
*/
|
||||||
|
internal class Nip86HttpRoute(
|
||||||
|
private val server: Nip86Server,
|
||||||
|
private val verifier: Nip98AuthVerifier,
|
||||||
|
private val allowList: Set<HexKey>,
|
||||||
|
private val maxBodyBytes: Int,
|
||||||
|
private val signedUrlFor: (ApplicationCall) -> String,
|
||||||
|
) {
|
||||||
|
suspend fun handle(call: ApplicationCall) {
|
||||||
|
if (allowList.isEmpty()) {
|
||||||
|
call.respondText(
|
||||||
|
"NIP-86 management API is not enabled on this relay.",
|
||||||
|
ContentType.Text.Plain,
|
||||||
|
HttpStatusCode.Forbidden,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = readBoundedBody(call) ?: return
|
||||||
|
val pubkey = verifyAuth(call, body) ?: return
|
||||||
|
if (pubkey.lowercase() !in allowList) {
|
||||||
|
call.respondText(
|
||||||
|
"pubkey is not on the admin list",
|
||||||
|
ContentType.Text.Plain,
|
||||||
|
HttpStatusCode.Forbidden,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val req =
|
||||||
|
try {
|
||||||
|
JsonMapper.fromJson<Nip86Request>(body.decodeToString())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
call.respondText(
|
||||||
|
"invalid Nip86Request: ${e.message ?: e::class.simpleName}",
|
||||||
|
ContentType.Text.Plain,
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val response: Nip86Response = server.dispatch(req)
|
||||||
|
audit(pubkey, req, response)
|
||||||
|
call.respondText(
|
||||||
|
JsonMapper.toJson(response),
|
||||||
|
ContentType.parse("application/nostr+json+rpc"),
|
||||||
|
HttpStatusCode.OK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readBoundedBody(call: ApplicationCall): ByteArray? {
|
||||||
|
val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull()
|
||||||
|
if (declared != null && declared > maxBodyBytes) {
|
||||||
|
call.respondText(
|
||||||
|
"request body exceeds $maxBodyBytes-byte cap",
|
||||||
|
ContentType.Text.Plain,
|
||||||
|
HttpStatusCode.PayloadTooLarge,
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val ch = call.receiveChannel()
|
||||||
|
val buf = ByteArray(maxBodyBytes + 1)
|
||||||
|
var pos = 0
|
||||||
|
while (pos <= maxBodyBytes) {
|
||||||
|
val read = ch.readAvailable(buf, pos, buf.size - pos)
|
||||||
|
if (read <= 0) break
|
||||||
|
pos += read
|
||||||
|
}
|
||||||
|
if (pos > maxBodyBytes) {
|
||||||
|
call.respondText(
|
||||||
|
"request body exceeds $maxBodyBytes-byte cap",
|
||||||
|
ContentType.Text.Plain,
|
||||||
|
HttpStatusCode.PayloadTooLarge,
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return buf.copyOfRange(0, pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun verifyAuth(
|
||||||
|
call: ApplicationCall,
|
||||||
|
body: ByteArray,
|
||||||
|
): HexKey? {
|
||||||
|
val header = call.request.header(HttpHeaders.Authorization)
|
||||||
|
val verification = verifier.verify(header, method = "POST", url = signedUrlFor(call), body = body)
|
||||||
|
return when (verification) {
|
||||||
|
is Nip98AuthVerifier.Result.Verified -> {
|
||||||
|
verification.pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip98AuthVerifier.Result.Missing -> {
|
||||||
|
call.response.headers.append(HttpHeaders.WWWAuthenticate, Nip98AuthVerifier.SCHEME.trim())
|
||||||
|
call.respondText(
|
||||||
|
"missing Authorization header (NIP-98)",
|
||||||
|
ContentType.Text.Plain,
|
||||||
|
HttpStatusCode.Unauthorized,
|
||||||
|
)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
is Nip98AuthVerifier.Result.Malformed -> {
|
||||||
|
call.respondText(
|
||||||
|
"invalid NIP-98 Authorization: ${verification.reason}",
|
||||||
|
ContentType.Text.Plain,
|
||||||
|
HttpStatusCode.Unauthorized,
|
||||||
|
)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit log: structured single line so an operator can grep
|
||||||
|
* "nip86" / pubkey / method without a logging framework
|
||||||
|
* dependency. Best-effort — a missing log line shouldn't fail
|
||||||
|
* the response.
|
||||||
|
*/
|
||||||
|
private fun audit(
|
||||||
|
pubkey: HexKey,
|
||||||
|
req: Nip86Request,
|
||||||
|
response: Nip86Response,
|
||||||
|
) {
|
||||||
|
runCatching {
|
||||||
|
System.err.println(
|
||||||
|
"nip86 audit pubkey=$pubkey method=${req.method} ok=${response.error == null}" +
|
||||||
|
(response.error?.let { " error=$it" } ?: ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/*
|
||||||
|
* 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.geode.server
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
|
||||||
|
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||||
|
import io.ktor.websocket.Frame
|
||||||
|
import io.ktor.websocket.readText
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||||
|
import kotlinx.coroutines.channels.consumeEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-WebSocket pump that owns the bounded outbound queue and the
|
||||||
|
* writer coroutine. Pulled out of `LocalRelayServer` so that file
|
||||||
|
* stays focused on Ktor wiring; the slow-client / backpressure
|
||||||
|
* policy now lives next to the data structures it manages.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* 1. `connect(server, registerSession)` opens a [RelaySession],
|
||||||
|
* registers it with the supplied callback, and starts the
|
||||||
|
* writer coroutine that drains [outQueue] into [outgoing].
|
||||||
|
* 2. `pump()` reads inbound frames until the socket closes.
|
||||||
|
* 3. `finally`-style teardown closes the queue, cancels the
|
||||||
|
* writer, unregisters the session, and closes it.
|
||||||
|
*
|
||||||
|
* Slow-client policy: once the outbound backlog reaches
|
||||||
|
* [MAX_OUTGOING_BUFFER] frames, the connection is dropped rather
|
||||||
|
* than silently losing EVENT/EOSE — silent drop would corrupt
|
||||||
|
* NIP-01.
|
||||||
|
*
|
||||||
|
* Memory model: the outbound queue is `Channel.UNLIMITED`, which in
|
||||||
|
* kotlinx.coroutines allocates segments lazily — an idle connection
|
||||||
|
* pays only a small head-segment cost. The cap is enforced via
|
||||||
|
* [outstanding] rather than the channel's own capacity so we don't
|
||||||
|
* reserve a fixed-size buffer up-front for every connection. At
|
||||||
|
* 5 000+ idle connections this matters: an 8 192-slot fixed buffer
|
||||||
|
* per connection would otherwise dominate JVM heap usage even
|
||||||
|
* though the vast majority of connections never fan out.
|
||||||
|
*/
|
||||||
|
internal class WebSocketSessionPump(
|
||||||
|
private val ws: DefaultWebSocketServerSession,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Unbounded channel — bounded by [outstanding] above, not by the
|
||||||
|
* channel's own capacity. See class kdoc for memory rationale.
|
||||||
|
*/
|
||||||
|
private val outQueue = Channel<String>(capacity = Channel.UNLIMITED)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of frames queued but not yet written to the socket.
|
||||||
|
* Producer increments before [Channel.trySend]; writer decrements
|
||||||
|
* after the frame is handed to Ktor. When this would cross
|
||||||
|
* [MAX_OUTGOING_BUFFER] we treat the client as slow and close
|
||||||
|
* the queue.
|
||||||
|
*/
|
||||||
|
private val outstanding = AtomicInteger(0)
|
||||||
|
|
||||||
|
@Volatile private var droppedForBackpressure = false
|
||||||
|
|
||||||
|
suspend fun pump(
|
||||||
|
server: NostrServer,
|
||||||
|
registerSession: (RelaySession) -> Unit,
|
||||||
|
unregisterSession: (RelaySession) -> Unit,
|
||||||
|
) {
|
||||||
|
val writerJob =
|
||||||
|
ws.launch {
|
||||||
|
try {
|
||||||
|
for (json in outQueue) {
|
||||||
|
ws.outgoing.send(Frame.Text(json))
|
||||||
|
outstanding.decrementAndGet()
|
||||||
|
}
|
||||||
|
} catch (_: ClosedSendChannelException) {
|
||||||
|
// socket closed — outer handler runs normal teardown.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val session =
|
||||||
|
server.connect { json ->
|
||||||
|
// The channel itself is UNLIMITED, so trySend can't
|
||||||
|
// report "full". Enforce the cap explicitly: increment
|
||||||
|
// first, refuse if we'd cross the bound, otherwise
|
||||||
|
// enqueue.
|
||||||
|
val depth = outstanding.incrementAndGet()
|
||||||
|
if (depth > MAX_OUTGOING_BUFFER) {
|
||||||
|
outstanding.decrementAndGet()
|
||||||
|
droppedForBackpressure = true
|
||||||
|
outQueue.close()
|
||||||
|
return@connect
|
||||||
|
}
|
||||||
|
val res = outQueue.trySend(json)
|
||||||
|
if (!res.isSuccess) {
|
||||||
|
// Channel was closed concurrently (e.g. teardown).
|
||||||
|
// Roll back the counter; nothing more to do.
|
||||||
|
outstanding.decrementAndGet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerSession(session)
|
||||||
|
try {
|
||||||
|
ws.incoming.consumeEach { frame ->
|
||||||
|
if (droppedForBackpressure) return@consumeEach
|
||||||
|
if (frame is Frame.Text) {
|
||||||
|
session.receive(frame.readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
outQueue.close()
|
||||||
|
writerJob.cancel()
|
||||||
|
unregisterSession(session)
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Per-session outbound backlog cap. When a slow client falls
|
||||||
|
* this many frames behind, we close their connection rather
|
||||||
|
* than silently dropping further frames (which would corrupt
|
||||||
|
* NIP-01 by missing EVENT/EOSE messages).
|
||||||
|
*
|
||||||
|
* Sized to hold fan-out for a connection holding several
|
||||||
|
* thousand subscriptions when one event matches all of them
|
||||||
|
* — the realistic upper bound for a relay client. At ~250 B
|
||||||
|
* per frame this caps per-session worst-case memory at
|
||||||
|
* ~2 MiB before we drop the connection. Idle connections
|
||||||
|
* pay only the small head-segment cost of an unlimited
|
||||||
|
* channel (≈ a few hundred bytes), not the full cap.
|
||||||
|
*/
|
||||||
|
const val MAX_OUTGOING_BUFFER: Int = 8192
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/*
|
||||||
|
* 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.geode
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests [LocalRelayServer.stop] honours the graceful-shutdown contract:
|
||||||
|
* 1. Active clients receive a `NOTICE` warning of imminent shutdown.
|
||||||
|
* 2. The active session counter accurately tracks open WS sessions.
|
||||||
|
* 3. After `stop()` returns, no sessions remain registered.
|
||||||
|
*/
|
||||||
|
class GracefulShutdownTest {
|
||||||
|
private lateinit var relay: Relay
|
||||||
|
private lateinit var server: LocalRelayServer
|
||||||
|
private lateinit var scope: CoroutineScope
|
||||||
|
private lateinit var client: NostrClient
|
||||||
|
|
||||||
|
private val httpClient = OkHttpClient.Builder().build()
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
|
||||||
|
relay = Relay(url = placeholder)
|
||||||
|
server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start()
|
||||||
|
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }
|
||||||
|
client = NostrClient(builder, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardown() {
|
||||||
|
client.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
// server may already be stopped by the test; calling stop()
|
||||||
|
// again is a no-op.
|
||||||
|
server.stop(gracePeriodMillis = 200, timeoutMillis = 500)
|
||||||
|
relay.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun activeSessionCountTracksConnectAndDisconnect() =
|
||||||
|
runBlocking {
|
||||||
|
assertEquals(0, server.activeSessionCount, "no clients yet")
|
||||||
|
|
||||||
|
// Open a connection by subscribing — wait for EOSE so we
|
||||||
|
// know the WebSocket handshake completed and the relay
|
||||||
|
// session has registered.
|
||||||
|
val gotEose = Channel<Unit>(UNLIMITED)
|
||||||
|
val relayUrl = server.url.normalizeRelayUrl()
|
||||||
|
client.subscribe(
|
||||||
|
"track-1",
|
||||||
|
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
|
||||||
|
object : SubscriptionListener {
|
||||||
|
override fun onEose(
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
forFilters: List<Filter>?,
|
||||||
|
) {
|
||||||
|
gotEose.trySend(Unit)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
withTimeout(5000) { gotEose.receive() }
|
||||||
|
|
||||||
|
assertEquals(1, server.activeSessionCount, "one connected session")
|
||||||
|
|
||||||
|
client.unsubscribe("track-1")
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
|
// Disconnect happens asynchronously on the relay side; allow
|
||||||
|
// a short window for the handler's `finally` block to run.
|
||||||
|
withTimeoutOrNull(2000) {
|
||||||
|
while (server.activeSessionCount > 0) kotlinx.coroutines.delay(10)
|
||||||
|
}
|
||||||
|
assertEquals(0, server.activeSessionCount, "session must be removed after disconnect")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stopSendsShutdownNoticeToActiveClients() =
|
||||||
|
runBlocking {
|
||||||
|
val noticeChannel = Channel<NoticeMessage>(UNLIMITED)
|
||||||
|
val gotEose = Channel<Unit>(UNLIMITED)
|
||||||
|
val listener =
|
||||||
|
object : RelayConnectionListener {
|
||||||
|
override fun onIncomingMessage(
|
||||||
|
relay: IRelayClient,
|
||||||
|
msgStr: String,
|
||||||
|
msg: Message,
|
||||||
|
) {
|
||||||
|
if (msg is NoticeMessage) noticeChannel.trySend(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.addConnectionListener(listener)
|
||||||
|
|
||||||
|
val relayUrl = server.url.normalizeRelayUrl()
|
||||||
|
client.subscribe(
|
||||||
|
"notice-watch",
|
||||||
|
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
|
||||||
|
object : SubscriptionListener {
|
||||||
|
override fun onEose(
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
forFilters: List<Filter>?,
|
||||||
|
) {
|
||||||
|
gotEose.trySend(Unit)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
withTimeout(5000) { gotEose.receive() }
|
||||||
|
assertEquals(1, server.activeSessionCount)
|
||||||
|
|
||||||
|
// Trigger graceful shutdown.
|
||||||
|
server.stop(gracePeriodMillis = 1_000, timeoutMillis = 2_000)
|
||||||
|
|
||||||
|
val notice = withTimeout(5000) { noticeChannel.receive() }
|
||||||
|
assertNotNull(notice)
|
||||||
|
assertTrue(
|
||||||
|
notice.message.startsWith("closing:"),
|
||||||
|
"expected NOTICE to start with 'closing:', got '${notice.message}'",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stopIsIdempotent() {
|
||||||
|
// First call shuts the engine down.
|
||||||
|
server.stop(gracePeriodMillis = 100, timeoutMillis = 500)
|
||||||
|
// Second call must be a safe no-op (no exception).
|
||||||
|
server.stop(gracePeriodMillis = 100, timeoutMillis = 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanity check on the grace window: a bare-bones ws client that
|
||||||
|
* connects and never sends anything should *receive* the shutdown
|
||||||
|
* NOTICE before the server fully closes the socket. Uses Ktor's
|
||||||
|
* client-agnostic OkHttp transport directly so we can observe the
|
||||||
|
* raw frames.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun rawWsClientObservesNoticeBeforeServerCloses() =
|
||||||
|
runBlocking {
|
||||||
|
val httpUrl =
|
||||||
|
server.url
|
||||||
|
.replace("ws://", "http://")
|
||||||
|
val request =
|
||||||
|
okhttp3.Request
|
||||||
|
.Builder()
|
||||||
|
.url(httpUrl)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val frames = Channel<String>(UNLIMITED)
|
||||||
|
val socket =
|
||||||
|
httpClient.newWebSocket(
|
||||||
|
request,
|
||||||
|
object : okhttp3.WebSocketListener() {
|
||||||
|
override fun onMessage(
|
||||||
|
ws: okhttp3.WebSocket,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
frames.trySend(text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait until the relay sees the connection.
|
||||||
|
withTimeoutOrNull(2000) {
|
||||||
|
while (server.activeSessionCount == 0) kotlinx.coroutines.delay(10)
|
||||||
|
}
|
||||||
|
assertEquals(1, server.activeSessionCount)
|
||||||
|
|
||||||
|
server.stop(gracePeriodMillis = 1_000, timeoutMillis = 2_000)
|
||||||
|
|
||||||
|
val text = withTimeout(3000) { frames.receive() }
|
||||||
|
assertTrue(
|
||||||
|
text.contains("\"NOTICE\"") && text.contains("closing"),
|
||||||
|
"expected a NOTICE frame, got: $text",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
socket.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
/*
|
||||||
|
* 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.geode
|
||||||
|
|
||||||
|
import com.vitorpamplona.geode.fixtures.SyntheticEvents
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||||
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end tests that drive a real `ws://` connection between the
|
||||||
|
* production [NostrClient] (over OkHttp) and the [LocalRelayServer]
|
||||||
|
* (Ktor + CIO). These prove the relay implements:
|
||||||
|
*
|
||||||
|
* - NIP-01 wire protocol (REQ/EVENT/EOSE) over real WebSockets
|
||||||
|
* - NIP-11 relay info doc on HTTP GET with `Accept: application/nostr+json`
|
||||||
|
* - NIP-42 AUTH (when [FullAuthPolicy] is enabled, REQ is rejected
|
||||||
|
* until the client authenticates)
|
||||||
|
* - NIP-45 COUNT
|
||||||
|
* - NIP-50 search via the SQLite FTS index
|
||||||
|
*
|
||||||
|
* Tests use port 0 for autobind to avoid conflicts when multiple suites
|
||||||
|
* run in parallel.
|
||||||
|
*/
|
||||||
|
class LocalRelayServerTest {
|
||||||
|
private lateinit var relay: Relay
|
||||||
|
private lateinit var server: LocalRelayServer
|
||||||
|
private lateinit var scope: CoroutineScope
|
||||||
|
private lateinit var client: NostrClient
|
||||||
|
|
||||||
|
private val httpClient = OkHttpClient.Builder().build()
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
// Bind to 127.0.0.1:0 — the OS picks a free port. Note: the URL
|
||||||
|
// must be resolvable by the Nostr URL normalizer, which only
|
||||||
|
// accepts loopback addresses. 127.0.0.1 qualifies.
|
||||||
|
val placeholderUrl = "ws://127.0.0.1:7771/".normalizeRelayUrl()
|
||||||
|
relay = Relay(url = placeholderUrl)
|
||||||
|
server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start()
|
||||||
|
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }
|
||||||
|
client = NostrClient(builder, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardown() {
|
||||||
|
client.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
server.stop()
|
||||||
|
relay.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nip01_realWebSocketRoundtrip() =
|
||||||
|
runBlocking {
|
||||||
|
val pubkey = SyntheticEvents.hexId(1)
|
||||||
|
relay.preload(
|
||||||
|
SyntheticEvents.fakeEvent(
|
||||||
|
idSeed = 42,
|
||||||
|
kind = MetadataEvent.KIND,
|
||||||
|
pubKey = pubkey,
|
||||||
|
content = """{"name":"vitor"}""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val event =
|
||||||
|
client.fetchFirst(
|
||||||
|
relay = server.url,
|
||||||
|
filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubkey)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(event)
|
||||||
|
assertEquals(MetadataEvent.KIND, event.kind)
|
||||||
|
assertEquals(pubkey, event.pubKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nip11_returnsInfoDocOnHttpGetWithNostrAcceptHeader() {
|
||||||
|
val httpUrl = server.url.replace("ws://", "http://")
|
||||||
|
val response =
|
||||||
|
httpClient
|
||||||
|
.newCall(
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(httpUrl)
|
||||||
|
.header("Accept", "application/nostr+json")
|
||||||
|
.build(),
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
response.use {
|
||||||
|
assertEquals(200, it.code)
|
||||||
|
val body = it.body.string()
|
||||||
|
val info = Nip11RelayInformation.fromJson(body)
|
||||||
|
assertEquals("geode", info.name)
|
||||||
|
assertTrue(info.supported_nips!!.contains("11"), "NIP-11 must be advertised")
|
||||||
|
assertTrue(info.supported_nips!!.contains("1"), "NIP-01 must be advertised")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nip45_countOverRealWebSocket() =
|
||||||
|
runBlocking {
|
||||||
|
// Each event needs a unique pubkey so kind-0 (replaceable)
|
||||||
|
// doesn't collapse them all to one row.
|
||||||
|
relay.preload(
|
||||||
|
(1..7).map {
|
||||||
|
SyntheticEvents.fakeEvent(
|
||||||
|
idSeed = it,
|
||||||
|
kind = MetadataEvent.KIND,
|
||||||
|
pubKey = SyntheticEvents.hexId(1000 + it),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val result =
|
||||||
|
client.count(
|
||||||
|
relay = server.url.normalizeRelayUrl(),
|
||||||
|
filter = Filter(kinds = listOf(MetadataEvent.KIND)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(7, result?.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nip50_searchHitsFtsIndex() =
|
||||||
|
runBlocking {
|
||||||
|
val signer =
|
||||||
|
com.vitorpamplona.quartz.nip01Core.signers
|
||||||
|
.NostrSignerSync(KeyPair())
|
||||||
|
relay.preload(
|
||||||
|
signer.sign(TextNoteEvent.build("How do I write a kotlin coroutine?")),
|
||||||
|
signer.sign(TextNoteEvent.build("My favorite recipe for pancakes")),
|
||||||
|
signer.sign(TextNoteEvent.build("Another note about kotlin")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val matches =
|
||||||
|
client
|
||||||
|
.count(
|
||||||
|
relay = server.url.normalizeRelayUrl(),
|
||||||
|
filter = Filter(search = "kotlin"),
|
||||||
|
)?.count
|
||||||
|
|
||||||
|
// Two of the three notes mention "kotlin".
|
||||||
|
assertEquals(2, matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nip42_authRejectsReqUntilClientAuthenticates() =
|
||||||
|
runBlocking {
|
||||||
|
// Spin up a second relay that requires AUTH. Bind on a
|
||||||
|
// separate port so it doesn't collide with [setup]'s server.
|
||||||
|
val authUrl = "ws://127.0.0.1:7772/".normalizeRelayUrl()
|
||||||
|
val authRelay = Relay(authUrl, policyBuilder = { FullAuthPolicy(authUrl) })
|
||||||
|
val authServer = LocalRelayServer(authRelay, host = "127.0.0.1", port = 0).start()
|
||||||
|
try {
|
||||||
|
val signer =
|
||||||
|
com.vitorpamplona.quartz.nip01Core.signers
|
||||||
|
.NostrSignerSync(KeyPair())
|
||||||
|
authRelay.preload(signer.sign(TextNoteEvent.build("hello")))
|
||||||
|
|
||||||
|
// Without AUTH, publishAndConfirm should fail (relay
|
||||||
|
// returns OK false / "auth-required").
|
||||||
|
val noAuthEvent = signer.sign(TextNoteEvent.build("denied"))
|
||||||
|
val ok =
|
||||||
|
client.publishAndConfirm(
|
||||||
|
event = noAuthEvent,
|
||||||
|
relayList = setOf(authServer.url.normalizeRelayUrl()),
|
||||||
|
)
|
||||||
|
assertEquals(false, ok, "FullAuthPolicy must reject EVENT before AUTH")
|
||||||
|
} finally {
|
||||||
|
authServer.stop()
|
||||||
|
authRelay.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successful NIP-42 AUTH must unlock REQ/EVENT/COUNT. We can't bind
|
||||||
|
* the server on a known port AND configure the policy with that URL
|
||||||
|
* unless we discover the port first — so reserve a free TCP port
|
||||||
|
* before constructing the relay, then bind to that exact port so
|
||||||
|
* the policy's `relay` field matches what `RelayAuthenticator`
|
||||||
|
* sends in the AUTH event's `relay` tag.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun nip42_successfulAuthUnlocksPublishing() =
|
||||||
|
runBlocking {
|
||||||
|
val freePort =
|
||||||
|
java.net.ServerSocket(0).use { it.localPort }
|
||||||
|
val authUrl = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
|
||||||
|
val authRelay = Relay(authUrl, policyBuilder = { FullAuthPolicy(authUrl) })
|
||||||
|
val authServer = LocalRelayServer(authRelay, host = "127.0.0.1", port = freePort).start()
|
||||||
|
try {
|
||||||
|
val signer =
|
||||||
|
com.vitorpamplona.quartz.nip01Core.signers
|
||||||
|
.NostrSignerSync(KeyPair())
|
||||||
|
|
||||||
|
// RelayAuthenticator hooks the client: when the relay
|
||||||
|
// sends the AUTH challenge, it auto-signs and replies.
|
||||||
|
val authenticator =
|
||||||
|
com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator(
|
||||||
|
client = client,
|
||||||
|
scope = scope,
|
||||||
|
) { template ->
|
||||||
|
listOf(signer.sign(template))
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Trigger the AUTH dance: subscribe to anything,
|
||||||
|
// which the relay rejects with `auth-required:` →
|
||||||
|
// [RelayAuthenticator] catches the challenge, signs
|
||||||
|
// and sends the AUTH event, the relay's OK true
|
||||||
|
// makes the client re-sync filters, and the second
|
||||||
|
// REQ succeeds and EOSEs.
|
||||||
|
val gotEose =
|
||||||
|
kotlinx.coroutines.channels.Channel<Unit>(
|
||||||
|
kotlinx.coroutines.channels.Channel.UNLIMITED,
|
||||||
|
)
|
||||||
|
client.subscribe(
|
||||||
|
"auth-warmup",
|
||||||
|
mapOf(authUrl to listOf(Filter(kinds = listOf(1)))),
|
||||||
|
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
|
||||||
|
override fun onEose(
|
||||||
|
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
|
||||||
|
forFilters: List<Filter>?,
|
||||||
|
) {
|
||||||
|
gotEose.trySend(Unit)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
kotlinx.coroutines.withTimeout(5000) { gotEose.receive() }
|
||||||
|
client.unsubscribe("auth-warmup")
|
||||||
|
|
||||||
|
val event = signer.sign(TextNoteEvent.build("after-auth"))
|
||||||
|
val ok =
|
||||||
|
client.publishAndConfirm(
|
||||||
|
event = event,
|
||||||
|
relayList = setOf(authUrl),
|
||||||
|
)
|
||||||
|
assertEquals(true, ok, "after AUTH succeeds, publishing must work")
|
||||||
|
} finally {
|
||||||
|
authenticator.destroy()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
authServer.stop()
|
||||||
|
authRelay.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression for the OkMessage wire format (the Jackson serializer
|
||||||
|
* was writing `success` as a JSON string). `publishAndConfirm`
|
||||||
|
* relies on parsing the OK response — if the relay's serialization
|
||||||
|
* regresses, this test catches it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun nip01_okMessageRoundtripWithEmptyAndNonEmptyMessage() =
|
||||||
|
runBlocking {
|
||||||
|
val signer =
|
||||||
|
com.vitorpamplona.quartz.nip01Core.signers
|
||||||
|
.NostrSignerSync(KeyPair())
|
||||||
|
val event = signer.sign(TextNoteEvent.build("ok-roundtrip"))
|
||||||
|
val ok =
|
||||||
|
client.publishAndConfirm(
|
||||||
|
event = event,
|
||||||
|
relayList = setOf(server.url.normalizeRelayUrl()),
|
||||||
|
)
|
||||||
|
assertEquals(true, ok, "successful insert must round-trip OK true on the wire")
|
||||||
|
|
||||||
|
// Duplicate insert returns OK false; this also exercises the
|
||||||
|
// "non-empty message" branch of the serializer.
|
||||||
|
val ok2 =
|
||||||
|
client.publishAndConfirm(
|
||||||
|
event = event,
|
||||||
|
relayList = setOf(server.url.normalizeRelayUrl()),
|
||||||
|
)
|
||||||
|
assertEquals(false, ok2, "duplicate insert must round-trip OK false")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom config's `[info]` section flows through to the NIP-11
|
||||||
|
* doc returned on the HTTP endpoint.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun nip11_servesConfigDrivenInfoDoc() {
|
||||||
|
val freePort =
|
||||||
|
java.net.ServerSocket(0).use { it.localPort }
|
||||||
|
val customUrl = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
|
||||||
|
val customInfo =
|
||||||
|
RelayInfo(
|
||||||
|
Nip11RelayInformation(
|
||||||
|
name = "custom-relay-name",
|
||||||
|
contact = "ops@example.com",
|
||||||
|
description = "Custom from config",
|
||||||
|
supported_nips = listOf("1", "11", "42"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val customRelay = Relay(customUrl, info = customInfo)
|
||||||
|
val customServer =
|
||||||
|
LocalRelayServer(customRelay, host = "127.0.0.1", port = freePort).start()
|
||||||
|
try {
|
||||||
|
val httpUrl = customServer.url.replace("ws://", "http://")
|
||||||
|
val response =
|
||||||
|
httpClient
|
||||||
|
.newCall(
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(httpUrl)
|
||||||
|
.header("Accept", "application/nostr+json")
|
||||||
|
.build(),
|
||||||
|
).execute()
|
||||||
|
response.use {
|
||||||
|
assertEquals(200, it.code)
|
||||||
|
val info = Nip11RelayInformation.fromJson(it.body.string())
|
||||||
|
assertEquals("custom-relay-name", info.name)
|
||||||
|
assertEquals("ops@example.com", info.contact)
|
||||||
|
assertEquals(listOf("1", "11", "42"), info.supported_nips)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
customServer.stop()
|
||||||
|
customRelay.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nip01_closeStopsLiveSubscription() =
|
||||||
|
runBlocking {
|
||||||
|
val ch =
|
||||||
|
kotlinx.coroutines.channels.Channel<com.vitorpamplona.quartz.nip01Core.core.Event>(
|
||||||
|
kotlinx.coroutines.channels.Channel.UNLIMITED,
|
||||||
|
)
|
||||||
|
val gotEose =
|
||||||
|
kotlinx.coroutines.channels.Channel<Unit>(
|
||||||
|
kotlinx.coroutines.channels.Channel.UNLIMITED,
|
||||||
|
)
|
||||||
|
client.subscribe(
|
||||||
|
"close-test",
|
||||||
|
mapOf(server.url.normalizeRelayUrl() to listOf(Filter(kinds = listOf(1)))),
|
||||||
|
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
|
||||||
|
override fun onEvent(
|
||||||
|
event: com.vitorpamplona.quartz.nip01Core.core.Event,
|
||||||
|
isLive: Boolean,
|
||||||
|
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
|
||||||
|
forFilters: List<Filter>?,
|
||||||
|
) {
|
||||||
|
ch.trySend(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEose(
|
||||||
|
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
|
||||||
|
forFilters: List<Filter>?,
|
||||||
|
) {
|
||||||
|
gotEose.trySend(Unit)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
kotlinx.coroutines.withTimeout(5000) { gotEose.receive() }
|
||||||
|
|
||||||
|
// CLOSE the subscription, then publish a matching event over
|
||||||
|
// the wire. The unsubscribed client must NOT receive it.
|
||||||
|
client.unsubscribe("close-test")
|
||||||
|
|
||||||
|
val signer =
|
||||||
|
com.vitorpamplona.quartz.nip01Core.signers
|
||||||
|
.NostrSignerSync(KeyPair())
|
||||||
|
val late = signer.sign(TextNoteEvent.build("post-close"))
|
||||||
|
relay.publish(late)
|
||||||
|
|
||||||
|
val seen = kotlinx.coroutines.withTimeoutOrNull(500) { ch.receive() }
|
||||||
|
assertEquals(null, seen, "events arriving after CLOSE must not reach the unsubscribed client")
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user