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/
|
||||
|
||||
/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 com.vitorpamplona.amethyst.service.logging.Logging
|
||||
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.LogLevel
|
||||
|
||||
@@ -41,6 +42,17 @@ class Amethyst : Application() {
|
||||
Log.d("AmethystApp") { "onCreate $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) {
|
||||
Logging.setup()
|
||||
// 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.speedLogger.RelaySpeedLogger
|
||||
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.LocalBlossomCacheProbe
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
|
||||
@@ -469,6 +471,11 @@ class AppModules(
|
||||
// subscriptions, and NotificationRelayService.
|
||||
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
|
||||
val trimmingService by
|
||||
lazy {
|
||||
@@ -587,6 +594,12 @@ class AppModules(
|
||||
// starts observing LocalCache for notification-worthy events
|
||||
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
|
||||
applicationIOScope.launch {
|
||||
sessionManager.accountContent.collectLatest { state ->
|
||||
|
||||
@@ -139,7 +139,7 @@ fun debugState(context: Context) {
|
||||
Log.d(
|
||||
STATE_DUMP_TAG,
|
||||
"Observables: " +
|
||||
LocalCache.observables.size,
|
||||
LocalCache.observables.size(),
|
||||
)
|
||||
|
||||
Log.d(
|
||||
|
||||
@@ -529,6 +529,14 @@ class Account(
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun updateDisableClientTag(disable: Boolean): Boolean {
|
||||
if (settings.updateDisableClientTag(disable)) {
|
||||
sendNewAppSpecificData()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun updateFilterSpam(filterSpam: Boolean): Boolean {
|
||||
if (settings.updateFilterSpam(filterSpam)) {
|
||||
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
|
||||
// ---
|
||||
|
||||
@@ -55,6 +55,7 @@ class AccountSyncedSettings(
|
||||
MutableStateFlow(internalSettings.security.filterSpamFromStrangers),
|
||||
MutableStateFlow(internalSettings.security.maxHashtagLimit),
|
||||
MutableStateFlow(internalSettings.security.sendKind0EventsToLocalRelay),
|
||||
MutableStateFlow(internalSettings.security.disableClientTag),
|
||||
)
|
||||
val videoPlayer =
|
||||
AccountVideoPlayerPreferences(
|
||||
@@ -82,6 +83,7 @@ class AccountSyncedSettings(
|
||||
security.filterSpamFromStrangers.value,
|
||||
security.maxHashtagLimit.value,
|
||||
security.sendKind0EventsToLocalRelay.value,
|
||||
security.disableClientTag.value,
|
||||
),
|
||||
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
|
||||
)
|
||||
@@ -138,6 +140,10 @@ class AccountSyncedSettings(
|
||||
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()
|
||||
if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) {
|
||||
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
|
||||
@@ -233,6 +239,7 @@ class AccountSecurityPreferences(
|
||||
var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(5),
|
||||
var sendKind0EventsToLocalRelay: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val disableClientTag: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
) {
|
||||
fun updateShowSensitiveContent(show: Boolean?): Boolean {
|
||||
if (showSensitiveContent.value != show) {
|
||||
@@ -273,4 +280,12 @@ class AccountSecurityPreferences(
|
||||
} else {
|
||||
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,
|
||||
val maxHashtagLimit: Int = 5,
|
||||
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.commands.toRelay.EventCmd
|
||||
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.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
|
||||
@@ -261,7 +262,6 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.SortedSet
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
interface ILocalCache {
|
||||
fun markAsSeen(
|
||||
@@ -290,7 +290,15 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
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 {
|
||||
val event = note.event
|
||||
@@ -361,10 +369,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
newFilter.init()
|
||||
|
||||
observables[newFilter] = newFilter
|
||||
observables.register(filter, newFilter)
|
||||
|
||||
awaitClose {
|
||||
observables.remove(newFilter)
|
||||
observables.unregister(newFilter)
|
||||
}
|
||||
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||
|
||||
@@ -377,10 +385,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
cachedFilter.init()
|
||||
|
||||
observables.put(cachedFilter, cachedFilter)
|
||||
observables.register(filter, cachedFilter)
|
||||
|
||||
awaitClose {
|
||||
observables.remove(cachedFilter)
|
||||
observables.unregister(cachedFilter)
|
||||
}
|
||||
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||
|
||||
@@ -402,14 +410,28 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
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 {
|
||||
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")
|
||||
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) {
|
||||
val event = newNote.event as Event
|
||||
|
||||
val observableBiConsumer =
|
||||
java.util.function.BiConsumer<Observable, Observable> { _, u ->
|
||||
u.new(event, newNote)
|
||||
// Index-driven fanout: only observers whose filter narrows
|
||||
// on a field this event carries (or that registered as
|
||||
// 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)
|
||||
}
|
||||
|
||||
private fun refreshDeletedNoteObservers(newNote: Note) {
|
||||
val observableBiConsumer =
|
||||
java.util.function.BiConsumer<Observable, Observable> { _, u ->
|
||||
u.remove(newNote)
|
||||
}
|
||||
|
||||
observables.forEach(observableBiConsumer)
|
||||
// Deletes don't have a filterable shape — every observer
|
||||
// might hold this note in its result set, so iterate them
|
||||
// all. The index doesn't help here.
|
||||
observables.forEach { it.remove(newNote) }
|
||||
live.removedNote(newNote)
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -135,7 +135,12 @@ class AccountCacheState(
|
||||
val cached = accounts.value[signer.pubKey]
|
||||
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() }
|
||||
|
||||
|
||||
+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.util.LruCache
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
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
|
||||
* It should store a separate Painter for each size. It's safe to just assume
|
||||
|
||||
@@ -81,6 +81,7 @@ fun SwipeToDeleteContainer(
|
||||
fun SwipeToDeleteWithConfirmation(
|
||||
modifier: Modifier = Modifier,
|
||||
onDelete: () -> Unit,
|
||||
confirmLabelRes: Int = R.string.request_deletion,
|
||||
content: @Composable (RowScope.() -> Unit),
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -103,6 +104,7 @@ fun SwipeToDeleteWithConfirmation(
|
||||
onCancel = {
|
||||
scope.launch { dismissState.reset() }
|
||||
},
|
||||
confirmLabelRes = confirmLabelRes,
|
||||
)
|
||||
},
|
||||
enableDismissFromEndToStart = true,
|
||||
@@ -156,6 +158,7 @@ fun ConfirmDeleteBackground(
|
||||
dismissState: SwipeToDismissBoxState,
|
||||
onConfirmDelete: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
confirmLabelRes: Int = R.string.request_deletion,
|
||||
) {
|
||||
val settled = dismissState.currentValue == Settled && dismissState.targetValue == Settled
|
||||
|
||||
@@ -163,7 +166,7 @@ fun ConfirmDeleteBackground(
|
||||
if (!settled) {
|
||||
Color(0xFFFF1744)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
Color.Transparent
|
||||
},
|
||||
label = "ConfirmDeleteBackground",
|
||||
)
|
||||
@@ -195,12 +198,12 @@ fun ConfirmDeleteBackground(
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.Delete,
|
||||
contentDescription = stringRes(id = R.string.request_deletion),
|
||||
contentDescription = stringRes(id = confirmLabelRes),
|
||||
tint = Color.White,
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
text = stringRes(id = R.string.request_deletion),
|
||||
text = stringRes(id = confirmLabelRes),
|
||||
color = Color.White,
|
||||
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.vanish.RequestToVanishScreen
|
||||
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.settings.AllSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
|
||||
@@ -304,6 +305,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ScheduledPosts> { ScheduledPostsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
|
||||
|
||||
+9
@@ -44,6 +44,7 @@ enum class NavBarItem {
|
||||
BOOKMARKS,
|
||||
WEB_BOOKMARKS,
|
||||
DRAFTS,
|
||||
SCHEDULED_POSTS,
|
||||
INTEREST_SETS,
|
||||
EMOJI_PACKS,
|
||||
WALLET,
|
||||
@@ -142,6 +143,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.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
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.INTEREST_SETS,
|
||||
@@ -291,6 +299,7 @@ val DrawerYouItems: List<NavBarItem> =
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
|
||||
+60
-1
@@ -46,11 +46,14 @@ 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.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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.User
|
||||
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.RobohashFallbackAsyncImage
|
||||
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.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
|
||||
@@ -261,16 +266,70 @@ private fun LogoutButton(
|
||||
accountSessionManager: AccountSessionManager,
|
||||
) {
|
||||
var logoutDialog by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
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(
|
||||
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 },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
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
|
||||
// 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)
|
||||
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))
|
||||
|
||||
+77
@@ -49,6 +49,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -84,6 +85,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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.observeUserInfo
|
||||
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.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
|
||||
@@ -604,11 +607,85 @@ fun CatalogSection(
|
||||
ids.forEach { id ->
|
||||
NavBarCatalog[id]?.let { def ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
fun CatalogNavigationRow(
|
||||
|
||||
@@ -205,6 +205,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object Drafts : Route()
|
||||
|
||||
@Serializable object ScheduledPosts : Route()
|
||||
|
||||
@Serializable object AllSettings : 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.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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.M3ActionSection
|
||||
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.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo
|
||||
@@ -346,6 +348,8 @@ fun PayReaction(
|
||||
val authorPubkey = baseNote.author?.pubkeyHex ?: return
|
||||
val address = remember(authorPubkey) { PaymentTargetsEvent.createAddress(authorPubkey) }
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
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 com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
|
||||
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.ui.navigation.routes.Route
|
||||
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.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
|
||||
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
|
||||
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.NEmbed
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
@@ -140,8 +141,11 @@ class AccountSessionManager(
|
||||
externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" },
|
||||
)
|
||||
} else if (key.startsWith("nsec")) {
|
||||
val privHex =
|
||||
decodePrivateKeyAsHexOrNull(key)
|
||||
?: throw Exception("Invalid nsec key")
|
||||
AccountSettings(
|
||||
keyPair = KeyPair(privKey = key.bechToBytes()),
|
||||
keyPair = KeyPair(privKey = privHex.hexToByteArray()),
|
||||
transientAccount = transientAccount,
|
||||
)
|
||||
} else if (key.contains(" ") && Nip06().isValidMnemonic(key)) {
|
||||
@@ -356,6 +360,11 @@ class AccountSessionManager(
|
||||
|
||||
fun logOff(accountInfo: AccountInfo) {
|
||||
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()) {
|
||||
// Drop the Nest bridge ref before tearing down the
|
||||
// current account so the audio-room activity can't
|
||||
@@ -364,12 +373,14 @@ class AccountSessionManager(
|
||||
.clear()
|
||||
// log off and relogin with the 0 account
|
||||
localPreferences.deleteAccount(accountInfo)
|
||||
accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey())
|
||||
accountsCache.removeAccount(hex)
|
||||
Amethyst.instance.scheduledPostStore.removeForAccount(hex)
|
||||
loginWithDefaultAccount()
|
||||
} else {
|
||||
// delete without switching logins
|
||||
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 updateDisableClientTag(disable: Boolean) = launchSigner { account.updateDisableClientTag(disable) }
|
||||
|
||||
fun updateFilterSpam(filterSpam: Boolean) =
|
||||
launchSigner {
|
||||
if (account.updateFilterSpam(filterSpam)) {
|
||||
|
||||
+1
@@ -114,6 +114,7 @@ private fun PreloadFor(
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
|
||||
+86
-2
@@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -48,11 +49,16 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -61,6 +67,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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.components.getActivity
|
||||
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.note.BaseUserPicture
|
||||
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.polls.PollOptionsField
|
||||
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.SecretEmojiRequest
|
||||
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) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
@@ -574,12 +605,63 @@ private fun NewPostScreenBody(
|
||||
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
|
||||
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()
|
||||
Row(
|
||||
modifier =
|
||||
@@ -656,6 +738,8 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
|
||||
postViewModel.toggleExpirationDate()
|
||||
}
|
||||
|
||||
ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked)
|
||||
|
||||
AddGeoHashButton(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.WritingTone
|
||||
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.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
@@ -305,6 +306,10 @@ open class ShortNotePostViewModel :
|
||||
// Anonymous Reply
|
||||
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
|
||||
private val useMockAi = false
|
||||
|
||||
@@ -829,8 +834,41 @@ open class ShortNotePostViewModel :
|
||||
|
||||
val version = draftTag.current
|
||||
val anonymous = wantsAnonymousPost
|
||||
val scheduledFor = scheduledForSec
|
||||
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) {
|
||||
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
|
||||
} else if (accountViewModel.settings.useTrackedBroadcasts()) {
|
||||
@@ -1197,6 +1235,7 @@ open class ShortNotePostViewModel :
|
||||
wantsExclusiveGeoPost = false
|
||||
wantsSecretEmoji = false
|
||||
wantsAnonymousPost = false
|
||||
scheduledForSec = null
|
||||
|
||||
forwardZapTo.value = SplitBuilder()
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
|
||||
+1
-1
@@ -329,7 +329,7 @@ private fun OnStageIdleControls(
|
||||
TalkButton(
|
||||
onClick = {
|
||||
if (context.hasMicPermission()) {
|
||||
viewModel.startBroadcast(speakerPubkeyHex, initialMuted = true)
|
||||
viewModel.startBroadcast(speakerPubkeyHex, initialMuted = false)
|
||||
} else {
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.M3ActionRow
|
||||
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.LoadAddressableNote
|
||||
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.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun PaymentButton(
|
||||
@@ -72,6 +76,8 @@ fun PaymentButton(
|
||||
@Composable
|
||||
fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
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(
|
||||
R.string.show_sensitive_content_title,
|
||||
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_success">Záložky úspěšně přesunuty</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="open_polls">Otevř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_success">Lesezeichen erfolgreich migriert</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="open_polls">Offen</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_success">A könyvjelzők átköltöztetése sikeresen befejeződött</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="open_polls">Megnyitás</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_success">Favoritos migrados com sucesso</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="open_polls">Abertas</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_success">Bokmärken migrerade</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="open_polls">Öppna</string>
|
||||
<string name="closed_polls">Stängda</string>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<string name="referenced_event_not_found">未找到相关事件</string>
|
||||
<string name="could_not_decrypt_the_message">无法解密消息</string>
|
||||
<string name="group_picture">群聊图片</string>
|
||||
<string name="explicit_content">明确内容</string>
|
||||
<string name="explicit_content">露骨内容</string>
|
||||
<string name="relay_notice">中继通知</string>
|
||||
<string name="duplicated_post">重复的贴文</string>
|
||||
<string name="spam">垃圾信息</string>
|
||||
@@ -39,7 +39,7 @@
|
||||
<string name="block_hide_user"><![CDATA[阻止并隐藏用户]]></string>
|
||||
<string name="report_spam_scam">举报垃圾邮件/诈骗</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_malware">举报恶意软件</string>
|
||||
<string name="report_mod">举报 Mod</string>
|
||||
@@ -398,6 +398,56 @@
|
||||
<string name="migrate_bookmarks_button">移动全部到新书签</string>
|
||||
<string name="migrate_bookmarks_success">书签迁移成功</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="open_polls">开启</string>
|
||||
<string name="closed_polls">已关闭</string>
|
||||
@@ -739,7 +789,7 @@
|
||||
<string name="zap_type_nonzap">非打闪</string>
|
||||
<string name="zap_type_nonzap_explainer">Nostr 上没有痕迹,仅在闪电上</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="file_server">文件服务器</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_success">Bookmarks migrated successfully</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="open_polls">Open</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_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_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>
|
||||
|
||||
+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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -66,6 +67,11 @@ fun LoadingState(
|
||||
|
||||
/**
|
||||
* 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
|
||||
fun EmptyState(
|
||||
@@ -89,12 +95,14 @@ fun EmptyState(
|
||||
)
|
||||
if (description != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onRefresh != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = onRefresh) {
|
||||
@@ -106,6 +114,9 @@ fun EmptyState(
|
||||
|
||||
/**
|
||||
* 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
|
||||
fun ErrorState(
|
||||
@@ -121,11 +132,13 @@ fun ErrorState(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
if (onRetry != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
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
|
||||
}
|
||||
|
||||
var appDrawerInitialTab by remember {
|
||||
mutableStateOf<com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab?>(null)
|
||||
}
|
||||
|
||||
val localCache = remember { DesktopLocalCache() }
|
||||
val accountState by accountManager.accountState.collectAsState()
|
||||
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
||||
@@ -871,6 +875,10 @@ fun App(
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
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) {
|
||||
is AccountState.LoggedOut -> {
|
||||
@@ -943,6 +951,11 @@ fun App(
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onShowAppDrawer = onShowAppDrawer,
|
||||
onOpenFeedsDrawer = {
|
||||
appDrawerInitialTab =
|
||||
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
|
||||
onShowAppDrawer()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -960,6 +973,7 @@ fun App(
|
||||
if (showAppDrawer) {
|
||||
val openColumns by deckState.columns.collectAsState()
|
||||
AppDrawer(
|
||||
initialTab = appDrawerInitialTab,
|
||||
openColumnTypes =
|
||||
if (layoutMode == LayoutMode.DECK) {
|
||||
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
|
||||
fun MainContent(
|
||||
@@ -1040,6 +1058,7 @@ fun MainContent(
|
||||
onShowComposeDialog: () -> Unit,
|
||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||
onShowAppDrawer: () -> Unit,
|
||||
onOpenFeedsDrawer: () -> Unit = onShowAppDrawer,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -1230,6 +1249,8 @@ fun MainContent(
|
||||
CompositionLocalProvider(
|
||||
LocalRelayCategories provides relayCategories,
|
||||
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()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
@@ -1252,6 +1273,7 @@ fun MainContent(
|
||||
singlePaneState = singlePaneState,
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
onOpenAppDrawer = onShowAppDrawer,
|
||||
onOpenFeedsDrawer = onOpenFeedsDrawer,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
|
||||
+55
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
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.ui.feeds.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder
|
||||
@@ -102,6 +103,60 @@ class DesktopFollowingFeedFilter(
|
||||
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).
|
||||
*/
|
||||
|
||||
+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
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
|
||||
/**
|
||||
* Feed mode for feed subscriptions.
|
||||
@@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
enum class FeedMode {
|
||||
GLOBAL,
|
||||
FOLLOWING,
|
||||
CUSTOM,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -362,3 +365,77 @@ fun createChessSubscription(
|
||||
onEvent = onEvent,
|
||||
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.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -251,21 +252,25 @@ fun ComposeNoteDialog(
|
||||
|
||||
errorMessage?.let { error ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
uploadState.error?.let { error ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
"Upload error: $error",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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.account.AccountState
|
||||
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.DesktopGlobalFeedFilter
|
||||
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.SubscriptionConfig
|
||||
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.createGlobalFeedSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
@@ -274,6 +276,9 @@ fun FeedScreen(
|
||||
localCache: DesktopLocalCache,
|
||||
account: AccountState.LoggedIn? = 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,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
initialFeedMode: FeedMode? = null,
|
||||
@@ -297,7 +302,15 @@ fun FeedScreen(
|
||||
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||
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
|
||||
rememberSubscription(allRelayUrls, account, relayManager = relayManager) {
|
||||
@@ -315,7 +328,7 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
when (feedMode) {
|
||||
@@ -342,12 +355,27 @@ fun FeedScreen(
|
||||
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
|
||||
val viewModel =
|
||||
remember(feedMode) {
|
||||
remember(feedMode, activeFeedId) {
|
||||
val filter =
|
||||
when (feedMode) {
|
||||
FeedMode.GLOBAL -> {
|
||||
@@ -359,6 +387,15 @@ fun FeedScreen(
|
||||
localCache.followedUsers.value
|
||||
}
|
||||
}
|
||||
|
||||
FeedMode.CUSTOM -> {
|
||||
DesktopCustomFeedFilter(
|
||||
localCache,
|
||||
activeFeedId ?: "custom",
|
||||
activeFeedSource ?: com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
|
||||
.Filter(),
|
||||
)
|
||||
}
|
||||
}
|
||||
DesktopFeedViewModel(filter, localCache)
|
||||
}
|
||||
@@ -489,20 +526,28 @@ fun FeedScreen(
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ReadingColumn {
|
||||
// Header with compose button
|
||||
FeedHeader(
|
||||
// Header: pinned feed tabs + "Show More +"
|
||||
FeedTabsHeader(
|
||||
feedMode = feedMode,
|
||||
account = account,
|
||||
feedRelays = feedRelays,
|
||||
followedUsersCount = followedUsers.size,
|
||||
activeFeedId = activeFeedId,
|
||||
onFeedModeChange = { mode ->
|
||||
feedMode = mode
|
||||
activeFeedId = null
|
||||
activeFeedSource = null
|
||||
if (mode != FeedMode.CUSTOM) {
|
||||
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,
|
||||
onNavigateToRelays = onNavigateToRelays,
|
||||
onOpenRelayPicker = { showRelayPicker = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -665,6 +710,83 @@ fun FeedScreen(
|
||||
* the relays icon (desktop convention \u2014 the at-a-glance info is preserved
|
||||
* 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)
|
||||
@Composable
|
||||
private fun FeedHeader(
|
||||
|
||||
@@ -281,6 +281,10 @@ fun ReadsScreen(
|
||||
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.unit.dp
|
||||
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.MaterialSymbols
|
||||
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.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
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
|
||||
@@ -510,7 +536,7 @@ fun SearchScreen(
|
||||
onExcludeRemoved = { state.removeExcludeTerm(it) },
|
||||
onLanguageChanged = { state.updateLanguage(it) },
|
||||
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()) {
|
||||
// Show bech32 results (exact lookup)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = sidePadding),
|
||||
) {
|
||||
Text(
|
||||
"Direct lookup",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
@@ -544,12 +573,14 @@ fun SearchScreen(
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
localCache = localCache,
|
||||
modifier = Modifier.padding(horizontal = sidePadding),
|
||||
)
|
||||
} else if (!debouncedQuery.isEmpty && !isSearching) {
|
||||
Text(
|
||||
"No results found. Try broader terms or fewer filters.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = sidePadding),
|
||||
)
|
||||
} else if (!isSearching) {
|
||||
// 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.padding
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -85,7 +86,11 @@ fun KeyInputField(
|
||||
isError = errorMessage != null,
|
||||
supportingText =
|
||||
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.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -254,11 +255,13 @@ private fun NostrConnectContent(
|
||||
val clipboardManager = LocalClipboard.current
|
||||
|
||||
if (errorMessage != null) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
errorMessage!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = {
|
||||
errorMessage = null
|
||||
|
||||
@@ -38,6 +38,7 @@ 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.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -137,6 +138,7 @@ fun DeckColumnType.category(): ScreenCategory =
|
||||
is DeckColumnType.Profile,
|
||||
is DeckColumnType.Thread,
|
||||
is DeckColumnType.Article,
|
||||
is DeckColumnType.CustomFeed,
|
||||
-> ScreenCategory.SOCIAL
|
||||
}
|
||||
|
||||
@@ -153,6 +155,7 @@ fun DeckColumnType.param(): String? =
|
||||
is DeckColumnType.Hashtag -> tag
|
||||
is DeckColumnType.Editor -> draftSlug
|
||||
is DeckColumnType.Article -> addressTag
|
||||
is DeckColumnType.CustomFeed -> feedId
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -180,6 +183,7 @@ val LAUNCHABLE_SCREENS: List<DeckColumnType> =
|
||||
enum class AppDrawerTab {
|
||||
SCREENS,
|
||||
WORKSPACES,
|
||||
FEEDS,
|
||||
}
|
||||
|
||||
// -- State --
|
||||
@@ -284,6 +288,10 @@ private class AppDrawerState {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
AppDrawerTab.FEEDS -> {
|
||||
// Feed selection handled by FeedsDrawerTab's own click handlers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,6 +317,7 @@ fun AppDrawer(
|
||||
onSwitchWorkspace: (Workspace) -> Unit,
|
||||
onSelectScreen: (DeckColumnType) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
initialTab: AppDrawerTab? = null,
|
||||
) {
|
||||
val state = remember { AppDrawerState() }
|
||||
val searchFocusRequester = remember { FocusRequester() }
|
||||
@@ -317,6 +326,11 @@ fun AppDrawer(
|
||||
derivedStateOf { state.filteredWorkspaces(allWorkspaces) }
|
||||
}
|
||||
|
||||
// Set initial tab if specified
|
||||
LaunchedEffect(initialTab) {
|
||||
if (initialTab != null) state.switchTab(initialTab)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(50)
|
||||
searchFocusRequester.requestFocus()
|
||||
@@ -454,6 +468,22 @@ fun AppDrawer(
|
||||
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 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)) {
|
||||
// Workspace results first
|
||||
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
|
||||
if (state.filteredScreens.isNotEmpty()) {
|
||||
stickyHeader {
|
||||
|
||||
+1
@@ -128,4 +128,5 @@ fun DeckColumnType.icon(): MaterialSymbol =
|
||||
is DeckColumnType.Profile -> MaterialSymbols.Person
|
||||
is DeckColumnType.Thread -> MaterialSymbols.AutoMirrored.Article
|
||||
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.ArticleReaderScreen
|
||||
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.FeedScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
|
||||
@@ -208,6 +209,7 @@ internal fun RootContent(
|
||||
onNavigateToArticle: (String) -> Unit = {},
|
||||
onNavigateToEditor: (String?) -> Unit = {},
|
||||
onNavigateToRelays: () -> Unit = {},
|
||||
onOpenFeedsDrawer: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -226,6 +228,7 @@ internal fun RootContent(
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToRelays = onNavigateToRelays,
|
||||
onOpenFeedsDrawer = onOpenFeedsDrawer,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -429,6 +432,18 @@ internal fun RootContent(
|
||||
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,
|
||||
) : DeckColumnType()
|
||||
|
||||
data class CustomFeed(
|
||||
val feedId: String,
|
||||
val feedName: String = "",
|
||||
val feedEmoji: String = "",
|
||||
) : DeckColumnType()
|
||||
|
||||
fun title(): String =
|
||||
when (this) {
|
||||
HomeFeed -> "Home"
|
||||
@@ -89,6 +95,7 @@ sealed class DeckColumnType {
|
||||
is Profile -> "Profile"
|
||||
is Thread -> "Thread"
|
||||
is Hashtag -> "#$tag"
|
||||
is CustomFeed -> feedName.ifEmpty { "Feed" }
|
||||
}
|
||||
|
||||
fun typeKey(): String =
|
||||
@@ -111,6 +118,7 @@ sealed class DeckColumnType {
|
||||
is Profile -> "profile"
|
||||
is Thread -> "thread"
|
||||
is Hashtag -> "hashtag"
|
||||
is CustomFeed -> "custom_feed"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +306,7 @@ class DeckState(
|
||||
"profile" -> param?.let { DeckColumnType.Profile(it) }
|
||||
"thread" -> param?.let { DeckColumnType.Thread(it) }
|
||||
"hashtag" -> param?.let { DeckColumnType.Hashtag(it) }
|
||||
"custom_feed" -> param?.let { DeckColumnType.CustomFeed(feedId = it) }
|
||||
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,
|
||||
pinnedNavBarState: PinnedNavBarState,
|
||||
onOpenAppDrawer: () -> Unit,
|
||||
onOpenFeedsDrawer: () -> Unit = onOpenAppDrawer,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||
onZapFeedback: (ZapFeedback) -> Unit,
|
||||
@@ -112,6 +113,8 @@ fun SinglePaneLayout(
|
||||
) {
|
||||
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
|
||||
pinnedScreens.forEach { screenType ->
|
||||
// Rename "Home" to "Feeds" in the nav rail
|
||||
val label = if (screenType == DeckColumnType.HomeFeed) "Feeds" else screenType.title()
|
||||
NavigationRailItem(
|
||||
selected = currentColumnType == screenType && navStack.isEmpty(),
|
||||
onClick = {
|
||||
@@ -121,13 +124,13 @@ fun SinglePaneLayout(
|
||||
icon = {
|
||||
Icon(
|
||||
screenType.icon(),
|
||||
contentDescription = screenType.title(),
|
||||
contentDescription = label,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
screenType.title(),
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -249,6 +252,7 @@ fun SinglePaneLayout(
|
||||
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
||||
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
|
||||
onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) },
|
||||
onOpenFeedsDrawer = onOpenFeedsDrawer,
|
||||
)
|
||||
if (currentOverlay != null) {
|
||||
Surface(
|
||||
|
||||
+6
-3
@@ -88,23 +88,26 @@ fun AnimatedGifImage(
|
||||
val data = gifFrames
|
||||
when {
|
||||
data != null && data.frames.size > 1 -> {
|
||||
val safeFrame = currentFrame.coerceIn(0, data.frames.size - 1)
|
||||
|
||||
LaunchedEffect(data) {
|
||||
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())
|
||||
currentFrame = (currentFrame + 1) % data.frames.size
|
||||
}
|
||||
}
|
||||
|
||||
Image(
|
||||
bitmap = data.frames[currentFrame],
|
||||
bitmap = data.frames[safeFrame],
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale,
|
||||
)
|
||||
}
|
||||
|
||||
data != null -> {
|
||||
data != null && data.frames.isNotEmpty() -> {
|
||||
Image(
|
||||
bitmap = data.frames[0],
|
||||
contentDescription = contentDescription,
|
||||
|
||||
Vendored
+2
@@ -112,6 +112,8 @@ class CoordinatorPipelineTest {
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
) {}
|
||||
|
||||
override fun pendingPublishRelaysFor(eventId: HexKey): Set<NormalizedRelayUrl>? = null
|
||||
|
||||
override fun addConnectionListener(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