Merge branch 'main' into claude/add-blossom-cache-support-ts8mK

This commit is contained in:
Vitor Pamplona
2026-05-08 08:15:44 -04:00
committed by GitHub
315 changed files with 46606 additions and 941 deletions
+4
View File
@@ -176,3 +176,7 @@ packaging/appimage/squashfs-root/
benchmark/src/main/jniLibs/ benchmark/src/main/jniLibs/
/tools/marmot-interop/state /tools/marmot-interop/state
# Cargo build artifacts for the cross-stack interop sidecars at
# nestsClient/tests/hang-interop/. Cargo.lock is committed (binary workspace).
/nestsClient/tests/hang-interop/target/
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst
import android.app.Application import android.app.Application
import com.vitorpamplona.amethyst.service.logging.Logging import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel import com.vitorpamplona.quartz.utils.LogLevel
@@ -41,6 +42,17 @@ class Amethyst : Application() {
Log.d("AmethystApp") { "onCreate $this" } Log.d("AmethystApp") { "onCreate $this" }
instance = AppModules(this) instance = AppModules(this)
// After-background foreground recycle: when the app returns to
// the foreground after spending more than ~5 s in the
// background, publish a network-change event so every active
// NestViewModel recycles its underlying QUIC session. Covers
// the case where Android reclaims our UDP socket FD while
// backgrounded — the connectivity callback in
// `NestForegroundService` doesn't fire there because the
// network itself is still up. See `AppForegroundRecycleHook`'s
// kdoc for the threshold rationale.
registerActivityLifecycleCallbacks(AppForegroundRecycleHook())
if (isDebug) { if (isDebug) {
Logging.setup() Logging.setup()
// Auto-enable the Nests session-trace recorder in debug // Auto-enable the Nests session-trace recorder in debug
@@ -72,6 +72,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.safeCacheDir import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
@@ -469,6 +471,11 @@ class AppModules(
// subscriptions, and NotificationRelayService. // subscriptions, and NotificationRelayService.
val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope) val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope)
// Local store for posts the user has scheduled to publish later. Backed by a
// single JSON file under the app's private filesDir; read by ScheduledPostWorker.
val scheduledPostStore =
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME))
// Organizes cache clearing // Organizes cache clearing
val trimmingService by val trimmingService by
lazy { lazy {
@@ -587,6 +594,12 @@ class AppModules(
// starts observing LocalCache for notification-worthy events // starts observing LocalCache for notification-worthy events
notificationDispatcher.start() notificationDispatcher.start()
// Schedule the scheduled-posts worker (periodic + one-time catch-up).
// Runs independently of the always-on notification setting so scheduled
// posts still fire when always-on notifications are disabled.
ScheduledPostWorker.schedule(appContext)
ScheduledPostWorker.scheduleCatchUp(appContext)
// Watch for account login and start/stop always-on notification service // Watch for account login and start/stop always-on notification service
applicationIOScope.launch { applicationIOScope.launch {
sessionManager.accountContent.collectLatest { state -> sessionManager.accountContent.collectLatest { state ->
@@ -139,7 +139,7 @@ fun debugState(context: Context) {
Log.d( Log.d(
STATE_DUMP_TAG, STATE_DUMP_TAG,
"Observables: " + "Observables: " +
LocalCache.observables.size, LocalCache.observables.size(),
) )
Log.d( Log.d(
@@ -529,6 +529,14 @@ class Account(
return false return false
} }
suspend fun updateDisableClientTag(disable: Boolean): Boolean {
if (settings.updateDisableClientTag(disable)) {
sendNewAppSpecificData()
return true
}
return false
}
suspend fun updateFilterSpam(filterSpam: Boolean): Boolean { suspend fun updateFilterSpam(filterSpam: Boolean): Boolean {
if (settings.updateFilterSpam(filterSpam)) { if (settings.updateFilterSpam(filterSpam)) {
if (!settings.syncedSettings.security.filterSpamFromStrangers.value) { if (!settings.syncedSettings.security.filterSpamFromStrangers.value) {
@@ -419,6 +419,14 @@ class AccountSettings(
} }
} }
fun updateDisableClientTag(disable: Boolean): Boolean =
if (syncedSettings.security.updateDisableClientTag(disable)) {
saveAccountSettings()
true
} else {
false
}
// --- // ---
// list names // list names
// --- // ---
@@ -55,6 +55,7 @@ class AccountSyncedSettings(
MutableStateFlow(internalSettings.security.filterSpamFromStrangers), MutableStateFlow(internalSettings.security.filterSpamFromStrangers),
MutableStateFlow(internalSettings.security.maxHashtagLimit), MutableStateFlow(internalSettings.security.maxHashtagLimit),
MutableStateFlow(internalSettings.security.sendKind0EventsToLocalRelay), MutableStateFlow(internalSettings.security.sendKind0EventsToLocalRelay),
MutableStateFlow(internalSettings.security.disableClientTag),
) )
val videoPlayer = val videoPlayer =
AccountVideoPlayerPreferences( AccountVideoPlayerPreferences(
@@ -82,6 +83,7 @@ class AccountSyncedSettings(
security.filterSpamFromStrangers.value, security.filterSpamFromStrangers.value,
security.maxHashtagLimit.value, security.maxHashtagLimit.value,
security.sendKind0EventsToLocalRelay.value, security.sendKind0EventsToLocalRelay.value,
security.disableClientTag.value,
), ),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value), videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
) )
@@ -138,6 +140,10 @@ class AccountSyncedSettings(
security.sendKind0EventsToLocalRelay.tryEmit(syncedSettingsInternal.security.sendKind0EventsToLocalRelay) security.sendKind0EventsToLocalRelay.tryEmit(syncedSettingsInternal.security.sendKind0EventsToLocalRelay)
} }
if (security.disableClientTag.value != syncedSettingsInternal.security.disableClientTag) {
security.disableClientTag.tryEmit(syncedSettingsInternal.security.disableClientTag)
}
val newVideoPlayerButtonItems = syncedSettingsInternal.videoPlayer.buttonItems.toImmutableList() val newVideoPlayerButtonItems = syncedSettingsInternal.videoPlayer.buttonItems.toImmutableList()
if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) { if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) {
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems) videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
@@ -233,6 +239,7 @@ class AccountSecurityPreferences(
var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true), var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true),
val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(5), val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(5),
var sendKind0EventsToLocalRelay: MutableStateFlow<Boolean> = MutableStateFlow(false), var sendKind0EventsToLocalRelay: MutableStateFlow<Boolean> = MutableStateFlow(false),
val disableClientTag: MutableStateFlow<Boolean> = MutableStateFlow(false),
) { ) {
fun updateShowSensitiveContent(show: Boolean?): Boolean { fun updateShowSensitiveContent(show: Boolean?): Boolean {
if (showSensitiveContent.value != show) { if (showSensitiveContent.value != show) {
@@ -273,4 +280,12 @@ class AccountSecurityPreferences(
} else { } else {
false false
} }
fun updateDisableClientTag(disable: Boolean): Boolean =
if (disable != disableClientTag.value) {
disableClientTag.tryEmit(disable)
true
} else {
false
}
} }
@@ -147,4 +147,5 @@ class AccountSecurityPreferencesInternal(
var filterSpamFromStrangers: Boolean = true, var filterSpamFromStrangers: Boolean = true,
val maxHashtagLimit: Int = 5, val maxHashtagLimit: Int = 5,
var sendKind0EventsToLocalRelay: Boolean = false, var sendKind0EventsToLocalRelay: Boolean = false,
var disableClientTag: Boolean = false,
) )
@@ -88,6 +88,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterIndex
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
@@ -261,7 +262,6 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.SortedSet import java.util.SortedSet
import java.util.concurrent.ConcurrentHashMap
interface ILocalCache { interface ILocalCache {
fun markAsSeen( fun markAsSeen(
@@ -290,7 +290,15 @@ object LocalCache : ILocalCache, ICacheProvider {
val deletionIndex = DeletionIndex() val deletionIndex = DeletionIndex()
val observables = ConcurrentHashMap<Observable, Observable>(10) /**
* Inverted index over the active [Observable]s. New events fan
* out only to observers whose filter actually narrows on a field
* the event carries (author, kind, single-letter tag), instead
* of waking every observer per event. Predicate-only observers
* (no underlying [Filter]) live in the unindexed pool and still
* see every event.
*/
val observables = FilterIndex<Observable>()
fun Filter.match(note: Note): Boolean { fun Filter.match(note: Note): Boolean {
val event = note.event val event = note.event
@@ -361,10 +369,10 @@ object LocalCache : ILocalCache, ICacheProvider {
newFilter.init() newFilter.init()
observables[newFilter] = newFilter observables.register(filter, newFilter)
awaitClose { awaitClose {
observables.remove(newFilter) observables.unregister(newFilter)
} }
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED) }.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
@@ -377,10 +385,10 @@ object LocalCache : ILocalCache, ICacheProvider {
cachedFilter.init() cachedFilter.init()
observables.put(cachedFilter, cachedFilter) observables.register(filter, cachedFilter)
awaitClose { awaitClose {
observables.remove(cachedFilter) observables.unregister(cachedFilter)
} }
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED) }.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
@@ -402,14 +410,28 @@ object LocalCache : ILocalCache, ICacheProvider {
trySend(it) trySend(it)
} }
observables.put(newFilter, newFilter) // Unindexed: predicate is opaque, the index can't narrow.
// Caller is delivered every event and runs the predicate.
observables.registerUnindexed(newFilter)
awaitClose { awaitClose {
observables.remove(newFilter) observables.unregister(newFilter)
} }
} }
fun <T : Event> observeNewEvents(filter: Filter): Flow<T> = observeNewEvents(filter::match) fun <T : Event> observeNewEvents(filter: Filter): Flow<T> =
callbackFlow {
val newFilter =
NewEventMatchingFilter<T>(filter::match) {
trySend(it)
}
observables.register(filter, newFilter)
awaitClose {
observables.unregister(newFilter)
}
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() } fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() }
@@ -2514,22 +2536,21 @@ object LocalCache : ILocalCache, ICacheProvider {
private fun refreshNewNoteObservers(newNote: Note) { private fun refreshNewNoteObservers(newNote: Note) {
val event = newNote.event as Event val event = newNote.event as Event
val observableBiConsumer = // Index-driven fanout: only observers whose filter narrows
java.util.function.BiConsumer<Observable, Observable> { _, u -> // on a field this event carries (or that registered as
u.new(event, newNote) // unindexed) get woken up. The match check inside each
// observer's `new()` still enforces negative constraints.
for (observer in observables.candidatesFor(event)) {
observer.new(event, newNote)
} }
observables.forEach(observableBiConsumer)
live.newNote(newNote) live.newNote(newNote)
} }
private fun refreshDeletedNoteObservers(newNote: Note) { private fun refreshDeletedNoteObservers(newNote: Note) {
val observableBiConsumer = // Deletes don't have a filterable shape — every observer
java.util.function.BiConsumer<Observable, Observable> { _, u -> // might hold this note in its result set, so iterate them
u.remove(newNote) // all. The index doesn't help here.
} observables.forEach { it.remove(newNote) }
observables.forEach(observableBiConsumer)
live.removedNote(newNote) live.removedNote(newNote)
} }
@@ -135,7 +135,12 @@ class AccountCacheState(
val cached = accounts.value[signer.pubKey] val cached = accounts.value[signer.pubKey]
if (cached != null) return cached if (cached != null) return cached
val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME) val signerWithClientTag =
NostrSignerWithClientTag(
inner = signer,
clientName = CLIENT_TAG_NAME,
disabled = { accountSettings.syncedSettings.security.disableClientTag.value },
)
val accountDir = File(rootFilesDir(), "accounts/${signer.pubKey}").apply { mkdirs() } val accountDir = File(rootFilesDir(), "accounts/${signer.pubKey}").apply { mkdirs() }
@@ -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
}
}
@@ -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(),
)
@@ -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()
}
@@ -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
}
}
@@ -0,0 +1,211 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.scheduledposts
import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import java.util.concurrent.TimeUnit
/**
* Scans the scheduled-post store and publishes posts whose publish time has arrived.
*
* - schedule(context): periodic, every 15 min (WorkManager minimum).
* - scheduleCatchUp(context): one-time, on app start, to flush posts that came
* due while the device was off or while WorkManager
* was deferred by Doze.
*/
class ScheduledPostWorker(
appContext: Context,
workerParams: WorkerParameters,
) : CoroutineWorker(appContext, workerParams) {
init {
// Logs every worker instantiation. Without this, "doWork never ran" looks
// identical to "constructor never invoked" — and the latter means the OS
// (Doze, battery-opt, JobScheduler quotas) never woke us at all.
Log.d(TAG) { "Worker instantiated (runAttempt=${workerParams.runAttemptCount}, tags=${workerParams.tags})" }
}
companion object {
private const val TAG = "ScheduledPostWorker"
private const val WORK_NAME = "scheduled_post_worker"
private const val WORK_NAME_CATCH_UP = "scheduled_post_worker_catch_up"
private const val OK_TIMEOUT_SEC = 30L
private const val OK_POLL_MS = 500L
fun schedule(context: Context) {
val constraints =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request =
PeriodicWorkRequestBuilder<ScheduledPostWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
Log.d(TAG) {
"schedule(): enqueueUniquePeriodicWork($WORK_NAME, 15 MIN, KEEP) — KEEP policy preserves any existing schedule"
}
}
fun scheduleCatchUp(context: Context) {
val constraints =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request =
OneTimeWorkRequestBuilder<ScheduledPostWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME_CATCH_UP,
ExistingWorkPolicy.KEEP,
request,
)
Log.d(TAG) { "scheduleCatchUp(): enqueueUniqueWork($WORK_NAME_CATCH_UP, KEEP)" }
}
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_CATCH_UP)
Log.d(TAG) { "cancel(): cancelled both periodic and catch-up workers" }
}
}
override suspend fun doWork(): Result {
val nowSec = System.currentTimeMillis() / 1000
Log.d(TAG) { "doWork() ENTER nowSec=$nowSec runAttempt=$runAttemptCount tags=$tags" }
return try {
val appModules = Amethyst.instance
val store = appModules.scheduledPostStore
val all = store.list()
val pending = all.count { it.status == ScheduledPostStatus.PENDING }
Log.d(TAG) { "doWork() store has ${all.size} total, $pending PENDING" }
val claimed = store.claimDuePosts(nowSec)
if (claimed.isEmpty()) {
Log.d(TAG) { "doWork() EXIT no posts due" }
return Result.success()
}
Log.d(TAG) { "doWork() claimed ${claimed.size} due post(s)" }
for (post in claimed) {
val ageSec = nowSec - post.publishAtSec
Log.d(TAG) {
"Publishing post id=${post.id} publishAtSec=${post.publishAtSec} ageSec=$ageSec relays=${post.relayUrls.size}"
}
val account = appModules.accountsCache.accounts.value[post.accountPubkey]
if (account == null) {
Log.w(TAG, "Account ${post.accountPubkey} not loaded; releasing ${post.id} for retry")
store.releaseClaim(post.id)
continue
}
try {
val event = Event.fromJson(post.signedEventJson)
val relays = post.relayUrls.map { NormalizedRelayUrl(it) }.toSet()
val extras = post.extraEventsJson.map { Event.fromJson(it) }
Log.d(TAG) { "client.publish(${post.id}) starting on ${relays.size} relay(s)" }
account.client.publish(event, relays)
account.consumePostEvent(event, relays, extras)
val acks = waitForOk(account.client, event.id, relays.size)
if (acks > 0) {
store.markSent(post.id)
ScheduledPostNotifier.notifySent(applicationContext, post)
Log.d(TAG) { "client.publish(${post.id}) acked by $acks/${relays.size}; marked SENT" }
} else {
val msg = "no relay acknowledged within ${OK_TIMEOUT_SEC}s"
store.markFailed(post.id, msg)
ScheduledPostNotifier.notifyFailed(applicationContext, post, msg)
Log.w(TAG, "client.publish(${post.id}) failed: $msg")
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Failed to publish scheduled post ${post.id}", e)
store.markFailed(post.id, e.message)
ScheduledPostNotifier.notifyFailed(applicationContext, post, e.message)
}
}
Log.d(TAG) { "doWork() EXIT success" }
Result.success()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "doWork() unexpected failure", e)
Result.retry()
}
}
/**
* Polls [INostrClient.pendingPublishRelaysFor] until at least one relay sends
* an OK ack or [OK_TIMEOUT_SEC] elapses. Returns the number of relays that
* acked. The publish call itself is fire-and-forget; without this wait the
* worker can be torn down before the websocket finishes delivery.
*/
private suspend fun waitForOk(
client: INostrClient,
eventId: String,
totalRelays: Int,
): Int {
val deadline = System.currentTimeMillis() + OK_TIMEOUT_SEC * 1000
while (System.currentTimeMillis() < deadline) {
// null means the outbox dropped the entry — every relay either OK'd
// or hit the discard cap (replaced/pow/deleted/invalid). Treat as full ack.
val pending = client.pendingPublishRelaysFor(eventId) ?: return totalRelays
val acked = totalRelays - pending.size
if (acked > 0) return acked
delay(OK_POLL_MS)
}
val pending = client.pendingPublishRelaysFor(eventId) ?: return totalRelays
return totalRelays - pending.size
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui
import android.content.Context import android.content.Context
import android.util.LruCache import android.util.LruCache
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.PluralsRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
@@ -131,6 +132,15 @@ fun stringRes(
) )
} }
// Plural resolver for non-composable scope (e.g. onClick callbacks). Not cached:
// the resolved string varies by `count` quantity and the resourceCache is keyed by id.
fun pluralStringRes(
ctx: Context,
@PluralsRes id: Int,
count: Int,
vararg formatArgs: Any?,
): String = ctx.resources.getQuantityString(id, count, *formatArgs)
/** /**
* This cache can only be used if the painter is the only copy on the screen * This cache can only be used if the painter is the only copy on the screen
* It should store a separate Painter for each size. It's safe to just assume * It should store a separate Painter for each size. It's safe to just assume
@@ -81,6 +81,7 @@ fun SwipeToDeleteContainer(
fun SwipeToDeleteWithConfirmation( fun SwipeToDeleteWithConfirmation(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onDelete: () -> Unit, onDelete: () -> Unit,
confirmLabelRes: Int = R.string.request_deletion,
content: @Composable (RowScope.() -> Unit), content: @Composable (RowScope.() -> Unit),
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -103,6 +104,7 @@ fun SwipeToDeleteWithConfirmation(
onCancel = { onCancel = {
scope.launch { dismissState.reset() } scope.launch { dismissState.reset() }
}, },
confirmLabelRes = confirmLabelRes,
) )
}, },
enableDismissFromEndToStart = true, enableDismissFromEndToStart = true,
@@ -156,6 +158,7 @@ fun ConfirmDeleteBackground(
dismissState: SwipeToDismissBoxState, dismissState: SwipeToDismissBoxState,
onConfirmDelete: () -> Unit, onConfirmDelete: () -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
confirmLabelRes: Int = R.string.request_deletion,
) { ) {
val settled = dismissState.currentValue == Settled && dismissState.targetValue == Settled val settled = dismissState.currentValue == Settled && dismissState.targetValue == Settled
@@ -163,7 +166,7 @@ fun ConfirmDeleteBackground(
if (!settled) { if (!settled) {
Color(0xFFFF1744) Color(0xFFFF1744)
} else { } else {
MaterialTheme.colorScheme.surfaceVariant Color.Transparent
}, },
label = "ConfirmDeleteBackground", label = "ConfirmDeleteBackground",
) )
@@ -195,12 +198,12 @@ fun ConfirmDeleteBackground(
) { ) {
Icon( Icon(
MaterialSymbols.Delete, MaterialSymbols.Delete,
contentDescription = stringRes(id = R.string.request_deletion), contentDescription = stringRes(id = confirmLabelRes),
tint = Color.White, tint = Color.White,
) )
Spacer(modifier = Modifier.padding(horizontal = 4.dp)) Spacer(modifier = Modifier.padding(horizontal = 4.dp))
Text( Text(
text = stringRes(id = R.string.request_deletion), text = stringRes(id = confirmLabelRes),
color = Color.White, color = Color.White,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
@@ -151,6 +151,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip43.RelayMembersSc
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
@@ -304,6 +305,7 @@ fun BuildNavigation(
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) } composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) } composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) } composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.ScheduledPosts> { ScheduledPostsScreen(accountViewModel, nav) }
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) } composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) } composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) } composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
@@ -44,6 +44,7 @@ enum class NavBarItem {
BOOKMARKS, BOOKMARKS,
WEB_BOOKMARKS, WEB_BOOKMARKS,
DRAFTS, DRAFTS,
SCHEDULED_POSTS,
INTEREST_SETS, INTEREST_SETS,
EMOJI_PACKS, EMOJI_PACKS,
WALLET, WALLET,
@@ -142,6 +143,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Drafts, icon = MaterialSymbols.Drafts,
resolveRoute = { Route.Drafts }, resolveRoute = { Route.Drafts },
), ),
NavBarItem.SCHEDULED_POSTS to
NavBarItemDef(
id = NavBarItem.SCHEDULED_POSTS,
labelRes = R.string.scheduled_posts,
icon = MaterialSymbols.Schedule,
resolveRoute = { Route.ScheduledPosts },
),
NavBarItem.INTEREST_SETS to NavBarItem.INTEREST_SETS to
NavBarItemDef( NavBarItemDef(
id = NavBarItem.INTEREST_SETS, id = NavBarItem.INTEREST_SETS,
@@ -291,6 +299,7 @@ val DrawerYouItems: List<NavBarItem> =
NavBarItem.BOOKMARKS, NavBarItem.BOOKMARKS,
NavBarItem.WEB_BOOKMARKS, NavBarItem.WEB_BOOKMARKS,
NavBarItem.DRAFTS, NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS, NavBarItem.INTEREST_SETS,
NavBarItem.EMOJI_PACKS, NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET, NavBarItem.WALLET,
@@ -46,11 +46,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -58,9 +61,11 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.note.toShortDisplay
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
@@ -261,16 +266,70 @@ private fun LogoutButton(
accountSessionManager: AccountSessionManager, accountSessionManager: AccountSessionManager,
) { ) {
var logoutDialog by remember { mutableStateOf(false) } var logoutDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
if (logoutDialog) { if (logoutDialog) {
val accountHex = remember(acc) { decodePublicKeyAsHexOrNull(acc.npub) }
val allPosts by Amethyst.instance.scheduledPostStore.flow
.collectAsStateWithLifecycle()
val unpublishedCount by remember(accountHex) {
derivedStateOf {
if (accountHex == null) {
0
} else {
allPosts.count {
it.accountPubkey == accountHex &&
(
it.status == ScheduledPostStatus.PENDING ||
it.status == ScheduledPostStatus.PUBLISHING ||
it.status == ScheduledPostStatus.FAILED
)
}
}
}
}
AlertDialog( AlertDialog(
title = { Text(text = stringRes(R.string.log_out)) }, title = { Text(text = stringRes(R.string.log_out)) },
text = { Text(text = stringRes(R.string.are_you_sure_you_want_to_log_out)) }, text = {
if (unpublishedCount > 0) {
Text(
text =
pluralStringResource(
id = R.plurals.scheduled_posts_logout_warning,
count = unpublishedCount,
unpublishedCount,
),
)
} else {
Text(text = stringRes(R.string.are_you_sure_you_want_to_log_out))
}
},
onDismissRequest = { logoutDialog = false }, onDismissRequest = { logoutDialog = false },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
// Snapshot the count *now* so the user-facing Toast matches what
// the dialog displayed, even if the store mutates between this
// tap and the cleanup completing.
val confirmedCount = unpublishedCount
logoutDialog = false logoutDialog = false
// Guard against a malformed npub: skip the Toast so we don't
// claim "Logged out" when logOff's coroutine bails early.
if (accountHex == null) return@TextButton
accountSessionManager.logOff(acc) accountSessionManager.logOff(acc)
val toastMessage =
if (confirmedCount > 0) {
pluralStringRes(
context,
R.plurals.scheduled_posts_logout_toast,
confirmedCount,
confirmedCount,
)
} else {
stringRes(context, R.string.scheduled_posts_logout_toast_zero)
}
android.widget.Toast
.makeText(context, toastMessage, android.widget.Toast.LENGTH_SHORT)
.show()
}, },
) { ) {
Text(text = stringRes(R.string.log_out)) Text(text = stringRes(R.string.log_out))
@@ -49,6 +49,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Badge
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -84,6 +85,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -97,6 +99,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
@@ -604,11 +607,85 @@ fun CatalogSection(
ids.forEach { id -> ids.forEach { id ->
NavBarCatalog[id]?.let { def -> NavBarCatalog[id]?.let { def ->
val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground
if (def.id == NavBarItem.SCHEDULED_POSTS) {
ScheduledPostsNavigationRow(def, tint, accountViewModel, nav)
} else {
CatalogNavigationRow(def, tint, accountViewModel, nav) CatalogNavigationRow(def, tint, accountViewModel, nav)
} }
} }
} }
} }
}
@Composable
private fun ScheduledPostsNavigationRow(
def: NavBarItemDef,
tint: Color,
accountViewModel: AccountViewModel,
nav: INav,
) {
val accountHex = accountViewModel.account.signer.pubKey
val allPosts by Amethyst.instance.scheduledPostStore.flow
.collectAsStateWithLifecycle()
val pendingCount by remember(accountHex) {
derivedStateOf {
allPosts.count {
it.accountPubkey == accountHex &&
(
it.status == ScheduledPostStatus.PENDING ||
it.status == ScheduledPostStatus.PUBLISHING ||
it.status == ScheduledPostStatus.FAILED
)
}
}
}
IconRowWithBadge(
title = def.labelRes,
icon = def.icon,
tint = tint,
badgeCount = pendingCount,
onClick = {
nav.closeDrawer()
nav.nav { def.resolveRoute(accountViewModel) }
},
)
}
@Composable
private fun IconRowWithBadge(
title: Int,
icon: MaterialSymbol,
tint: Color,
badgeCount: Int,
onClick: () -> Unit,
) {
val titleStr = stringRes(title)
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(
onClick = onClick,
onClickLabel = titleStr,
).padding(vertical = 15.dp, horizontal = 25.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = icon,
contentDescription = titleStr,
modifier = Size22ModifierWith4Padding,
tint = tint,
)
Text(
modifier = IconRowTextModifier,
text = titleStr,
fontSize = Font18SP,
)
if (badgeCount > 0) {
Badge { Text(badgeCount.toString()) }
}
}
}
@Composable @Composable
fun CatalogNavigationRow( fun CatalogNavigationRow(
@@ -205,6 +205,8 @@ sealed class Route {
@Serializable object Drafts : Route() @Serializable object Drafts : Route()
@Serializable object ScheduledPosts : Route()
@Serializable object AllSettings : Route() @Serializable object AllSettings : Route()
@Serializable object AccountBackup : Route() @Serializable object AccountBackup : Route()
@@ -82,6 +82,7 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
@@ -128,6 +129,7 @@ import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo
@@ -346,6 +348,8 @@ fun PayReaction(
val authorPubkey = baseNote.author?.pubkeyHex ?: return val authorPubkey = baseNote.author?.pubkeyHex ?: return
val address = remember(authorPubkey) { PaymentTargetsEvent.createAddress(authorPubkey) } val address = remember(authorPubkey) { PaymentTargetsEvent.createAddress(authorPubkey) }
val context = LocalContext.current val context = LocalContext.current
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
LoadAddressableNote(address, accountViewModel) { note -> LoadAddressableNote(address, accountViewModel) { note ->
val targets = remember(note) { (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() } val targets = remember(note) { (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() }
@@ -395,6 +399,16 @@ fun PayReaction(
} }
}, },
) )
M3ActionRow(
icon = MaterialSymbols.ContentCopy,
text = stringRes(R.string.copy_to_clipboard),
onClick = {
expanded = false
scope.launch {
clipboardManager.setText(target.authority)
}
},
)
} }
} }
} }
@@ -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,
)
}
}
@@ -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
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
@@ -29,14 +30,14 @@ import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
@@ -140,8 +141,11 @@ class AccountSessionManager(
externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" }, externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" },
) )
} else if (key.startsWith("nsec")) { } else if (key.startsWith("nsec")) {
val privHex =
decodePrivateKeyAsHexOrNull(key)
?: throw Exception("Invalid nsec key")
AccountSettings( AccountSettings(
keyPair = KeyPair(privKey = key.bechToBytes()), keyPair = KeyPair(privKey = privHex.hexToByteArray()),
transientAccount = transientAccount, transientAccount = transientAccount,
) )
} else if (key.contains(" ") && Nip06().isValidMnemonic(key)) { } else if (key.contains(" ") && Nip06().isValidMnemonic(key)) {
@@ -356,6 +360,11 @@ class AccountSessionManager(
fun logOff(accountInfo: AccountInfo) { fun logOff(accountInfo: AccountInfo) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val hex = decodePublicKeyAsHexOrNull(accountInfo.npub)
if (hex == null) {
Log.e("Logoff", "Cannot decode npub for account being logged off; aborting cleanup")
return@launch
}
if (accountInfo.npub == currentAccountNPub()) { if (accountInfo.npub == currentAccountNPub()) {
// Drop the Nest bridge ref before tearing down the // Drop the Nest bridge ref before tearing down the
// current account so the audio-room activity can't // current account so the audio-room activity can't
@@ -364,12 +373,14 @@ class AccountSessionManager(
.clear() .clear()
// log off and relogin with the 0 account // log off and relogin with the 0 account
localPreferences.deleteAccount(accountInfo) localPreferences.deleteAccount(accountInfo)
accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) accountsCache.removeAccount(hex)
Amethyst.instance.scheduledPostStore.removeForAccount(hex)
loginWithDefaultAccount() loginWithDefaultAccount()
} else { } else {
// delete without switching logins // delete without switching logins
localPreferences.deleteAccount(accountInfo) localPreferences.deleteAccount(accountInfo)
accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) accountsCache.removeAccount(hex)
Amethyst.instance.scheduledPostStore.removeForAccount(hex)
} }
} }
} }
@@ -1152,6 +1152,8 @@ class AccountViewModel(
fun updateWarnReports(warnReports: Boolean) = launchSigner { account.updateWarnReports(warnReports) } fun updateWarnReports(warnReports: Boolean) = launchSigner { account.updateWarnReports(warnReports) }
fun updateDisableClientTag(disable: Boolean) = launchSigner { account.updateDisableClientTag(disable) }
fun updateFilterSpam(filterSpam: Boolean) = fun updateFilterSpam(filterSpam: Boolean) =
launchSigner { launchSigner {
if (account.updateFilterSpam(filterSpam)) { if (account.updateFilterSpam(filterSpam)) {
@@ -114,6 +114,7 @@ private fun PreloadFor(
NavBarItem.BOOKMARKS, NavBarItem.BOOKMARKS,
NavBarItem.WEB_BOOKMARKS, NavBarItem.WEB_BOOKMARKS,
NavBarItem.DRAFTS, NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS, NavBarItem.INTEREST_SETS,
NavBarItem.EMOJI_PACKS, NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET, NavBarItem.WALLET,
@@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -48,11 +49,16 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -61,6 +67,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -79,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -97,6 +105,9 @@ import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying
import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField
import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtButton
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtPicker
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.roundUpToNextQuarterHour
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
@@ -404,6 +415,26 @@ private fun NewPostScreenBody(
} }
} }
val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService
.collectAsStateWithLifecycle()
val savedAccounts by com.vitorpamplona.amethyst.LocalPreferences
.accountsFlow()
.collectAsStateWithLifecycle()
val hasMultipleAccounts = (savedAccounts?.size ?: 0) > 1
postViewModel.scheduledForSec?.let { current ->
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ScheduleAtPicker(
scheduledForSec = current,
onChanged = { postViewModel.scheduledForSec = it },
alwaysOnEnabled = alwaysOnEnabled,
hasMultipleAccounts = hasMultipleAccounts,
)
}
}
if (postViewModel.wantsToAddGeoHash) { if (postViewModel.wantsToAddGeoHash) {
Row( Row(
verticalAlignment = CenterVertically, verticalAlignment = CenterVertically,
@@ -574,12 +605,63 @@ private fun NewPostScreenBody(
onDismiss = postViewModel::dismissAiResult, onDismiss = postViewModel::dismissAiResult,
) )
BottomRowActions(postViewModel) val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService
.collectAsStateWithLifecycle()
var showAlwaysOnPrompt by remember { mutableStateOf(false) }
BottomRowActions(
postViewModel = postViewModel,
onScheduleClicked = {
if (postViewModel.scheduledForSec != null) {
postViewModel.scheduledForSec = null
} else if (!alwaysOnEnabled) {
showAlwaysOnPrompt = true
} else {
postViewModel.scheduledForSec =
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
}
},
)
if (showAlwaysOnPrompt) {
AlertDialog(
onDismissRequest = { showAlwaysOnPrompt = false },
title = { Text(stringRes(R.string.schedule_post_always_on_prompt_title)) },
text = { Text(stringRes(R.string.schedule_post_always_on_prompt_message)) },
confirmButton = {
TextButton(onClick = {
showAlwaysOnPrompt = false
nav.nav(Route.Settings)
}) {
Text(stringRes(R.string.schedule_post_always_on_prompt_open_settings))
}
},
dismissButton = {
TextButton(onClick = {
showAlwaysOnPrompt = false
postViewModel.scheduledForSec =
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
}) {
Text(stringRes(R.string.schedule_post_always_on_prompt_continue))
}
},
)
}
} }
} }
@Composable @Composable
private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { private fun BottomRowActions(
postViewModel: ShortNotePostViewModel,
onScheduleClicked: () -> Unit = {
postViewModel.scheduledForSec =
if (postViewModel.scheduledForSec != null) {
null
} else {
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
}
},
) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Row( Row(
modifier = modifier =
@@ -656,6 +738,8 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
postViewModel.toggleExpirationDate() postViewModel.toggleExpirationDate()
} }
ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked)
AddGeoHashButton(postViewModel.wantsToAddGeoHash) { AddGeoHashButton(postViewModel.wantsToAddGeoHash) {
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
} }
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.service.ai.WritingAssistantStatus
import com.vitorpamplona.amethyst.service.ai.WritingResult import com.vitorpamplona.amethyst.service.ai.WritingResult
import com.vitorpamplona.amethyst.service.ai.WritingTone import com.vitorpamplona.amethyst.service.ai.WritingTone
import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
@@ -305,6 +306,10 @@ open class ShortNotePostViewModel :
// Anonymous Reply // Anonymous Reply
var wantsAnonymousPost by mutableStateOf(false) var wantsAnonymousPost by mutableStateOf(false)
// Scheduled posting: epoch seconds (UTC) when the post should be published.
// Null = post immediately on Send (existing behavior).
var scheduledForSec by mutableStateOf<Long?>(null)
// AI Writing Help for testing // AI Writing Help for testing
private val useMockAi = false private val useMockAi = false
@@ -829,8 +834,41 @@ open class ShortNotePostViewModel :
val version = draftTag.current val version = draftTag.current
val anonymous = wantsAnonymousPost val anonymous = wantsAnonymousPost
val scheduledFor = scheduledForSec
cancel() cancel()
if (scheduledFor != null && !anonymous) {
// Re-stamp the template with created_at = scheduled time so the post,
// when published later, shows up at its scheduled moment in feeds
// rather than as N minutes/hours old (= compose time).
val rescheduledTemplate =
EventTemplate<Event>(
createdAt = scheduledFor,
kind = template.kind,
tags = template.tags,
content = template.content,
)
val (event, relays, extras) = accountViewModel.account.createPostEvent(rescheduledTemplate, extraNotesToBroadcast)
Amethyst.instance.scheduledPostStore.add(
ScheduledPost(
id =
java.util.UUID
.randomUUID()
.toString(),
accountPubkey = event.pubKey,
signedEventJson = event.toJson(),
relayUrls = relays.map { it.url },
extraEventsJson = extras.map { it.toJson() },
publishAtSec = scheduledFor,
createdAtSec = System.currentTimeMillis() / 1000,
),
)
accountViewModel.launchSigner {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
return
}
if (anonymous) { if (anonymous) {
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast) accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
} else if (accountViewModel.settings.useTrackedBroadcasts()) { } else if (accountViewModel.settings.useTrackedBroadcasts()) {
@@ -1197,6 +1235,7 @@ open class ShortNotePostViewModel :
wantsExclusiveGeoPost = false wantsExclusiveGeoPost = false
wantsSecretEmoji = false wantsSecretEmoji = false
wantsAnonymousPost = false wantsAnonymousPost = false
scheduledForSec = null
forwardZapTo.value = SplitBuilder() forwardZapTo.value = SplitBuilder()
forwardZapToEditting.value = TextFieldValue("") forwardZapToEditting.value = TextFieldValue("")
@@ -329,7 +329,7 @@ private fun OnStageIdleControls(
TalkButton( TalkButton(
onClick = { onClick = {
if (context.hasMicPermission()) { if (context.hasMicPermission()) {
viewModel.startBroadcast(speakerPubkeyHex, initialMuted = true) viewModel.startBroadcast(speakerPubkeyHex, initialMuted = false)
} else { } else {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
} }
@@ -28,8 +28,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
@@ -40,6 +42,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -47,6 +50,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import kotlinx.coroutines.launch
@Composable @Composable
fun PaymentButton( fun PaymentButton(
@@ -72,6 +76,8 @@ fun PaymentButton(
@Composable @Composable
fun PaymentButtonWithTargets(targets: List<PaymentTarget>) { fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
val context = LocalContext.current val context = LocalContext.current
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) } var errorMessage by remember { mutableStateOf<String?>(null) }
@@ -111,6 +117,16 @@ fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
} }
}, },
) )
M3ActionRow(
icon = MaterialSymbols.ContentCopy,
text = stringRes(R.string.copy_to_clipboard),
onClick = {
expanded = false
scope.launch {
clipboardManager.setText(target.authority)
}
},
)
} }
} }
} }
@@ -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),
)
}
}
}
@@ -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)
}
@@ -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,
)
}
}
@@ -371,6 +371,21 @@ private fun HeaderOptions(accountViewModel: AccountViewModel) {
) )
} }
SettingsRow(
R.string.disable_client_tag_title,
R.string.disable_client_tag_explainer,
) {
var disableClientTag by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.disableClientTag.value) }
Switch(
checked = disableClientTag,
onCheckedChange = {
disableClientTag = it
accountViewModel.updateDisableClientTag(disableClientTag)
},
)
}
SettingsRow( SettingsRow(
R.string.show_sensitive_content_title, R.string.show_sensitive_content_title,
R.string.show_sensitive_content_explainer, R.string.show_sensitive_content_explainer,
@@ -396,6 +396,75 @@
<string name="migrate_bookmarks_button">Přesunout vše do nových záložek</string> <string name="migrate_bookmarks_button">Přesunout vše do nových záložek</string>
<string name="migrate_bookmarks_success">Záložky úspěšně přesunuty</string> <string name="migrate_bookmarks_success">Záložky úspěšně přesunuty</string>
<string name="drafts">Koncepty</string> <string name="drafts">Koncepty</string>
<string name="scheduled_posts">Naplánované příspěvky</string>
<string name="schedule_post">Naplánovat</string>
<string name="schedule_post_time_label">Naplánovaný čas</string>
<string name="schedule_post_helper">Příspěvky se publikují přibližně do 15 minut od naplánovaného času.</string>
<string name="schedule_post_pick_time">Vyberte naplánovaný čas</string>
<string name="schedule_post_pick_label">Naplánovat na…</string>
<string name="schedule_post_publishes_in">Publikováno za %1$s</string>
<string name="schedule_post_was_due">Mělo být před %1$s</string>
<string name="schedule_post_picker_time_title">Čas</string>
<string name="schedule_post_button_add">Naplánovat příspěvek</string>
<string name="schedule_post_button_remove">Zrušit plánování</string>
<string name="schedule_post_warning_title">Trvalá oznámení vypnuta</string>
<string name="schedule_post_warning_single">Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI.</string>
<string name="schedule_post_warning_multi">Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Naplánované příspěvky jiných účtů se nespustí, dokud je aktivní tento účet. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI.</string>
<string name="schedule_post_preset_in_one_hour">Za 1 hodinu</string>
<string name="schedule_post_preset_tomorrow_morning">Zítra v 9:00</string>
<string name="schedule_post_preset_next_monday_morning">Příští pondělí v 9:00</string>
<string name="schedule_post_always_on_prompt_title">Povolit trvalá oznámení?</string>
<string name="schedule_post_always_on_prompt_message">Naplánované příspěvky se spolehlivě publikují pouze tehdy, když jsou povolena trvalá oznámení. Jinak se nemusí spustit, dokud aplikaci znovu neotevřete.</string>
<string name="schedule_post_always_on_prompt_open_settings">Otevřít nastavení</string>
<string name="schedule_post_always_on_prompt_continue">Přesto pokračovat</string>
<string name="scheduled_posts_at_time">%1$s · za %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · před %2$s</string>
<string name="scheduled_posts_day_tomorrow">Zítra</string>
<string name="scheduled_posts_logout_toast_zero">Odhlášeno</string>
<plurals name="scheduled_posts_logout_toast">
<item quantity="one">Odhlášeno · smazán %d naplánovaný příspěvek</item>
<item quantity="few">Odhlášeno · smazány %d naplánované příspěvky</item>
<item quantity="many">Odhlášeno · smazáno %d naplánovaných příspěvků</item>
<item quantity="other">Odhlášeno · smazáno %d naplánovaných příspěvků</item>
</plurals>
<string name="scheduled_posts_notification_sent_title">Naplánovaný příspěvek publikován</string>
<string name="scheduled_posts_notification_failed_title">Naplánovaný příspěvek selhal</string>
<string name="app_notification_scheduled_posts_channel_name">Naplánované příspěvky</string>
<string name="app_notification_scheduled_posts_channel_description">Oznámení, když je naplánovaný příspěvek publikován nebo selže při publikaci.</string>
<string name="scheduled_posts_action_send_now">Odeslat hned</string>
<string name="scheduled_posts_empty_title">Žádné naplánované příspěvky</string>
<string name="scheduled_posts_empty_hint">Napište poznámku a klepněte na ikonu hodin pro naplánování na později.</string>
<string name="scheduled_posts_error_prefix">Chyba: %1$s</string>
<string name="scheduled_posts_status_pending">Naplánováno</string>
<string name="scheduled_posts_status_publishing">Odesílá se…</string>
<string name="scheduled_posts_status_failed">Selhalo</string>
<string name="scheduled_posts_status_sent">Odesláno</string>
<string name="scheduled_posts_status_cancelled">Zrušeno</string>
<plurals name="scheduled_posts_logout_warning">
<item quantity="one">Máte %d naplánovaný příspěvek, který ještě nebyl publikován. Odhlášením bude trvale smazán.</item>
<item quantity="few">Máte %d naplánované příspěvky, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</item>
<item quantity="many">Máte %d naplánovaných příspěvků, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</item>
<item quantity="other">Máte %d naplánovaných příspěvků, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</item>
</plurals>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d ve frontě</item>
<item quantity="few">%d ve frontě</item>
<item quantity="many">%d ve frontě</item>
<item quantity="other">%d ve frontě</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · %d do 1 h</item>
<item quantity="few"> · %d do 1 h</item>
<item quantity="many"> · %d do 1 h</item>
<item quantity="other"> · %d do 1 h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">na %d relay</item>
<item quantity="few">na %d relaye</item>
<item quantity="many">na %d relayů</item>
<item quantity="other">na %d relayů</item>
</plurals>
<string name="scheduled_posts_event_id_copied">ID příspěvku zkopírováno</string>
<string name="polls">Ankety</string> <string name="polls">Ankety</string>
<string name="open_polls">Otevřené</string> <string name="open_polls">Otevřené</string>
<string name="closed_polls">Uzavřené</string> <string name="closed_polls">Uzavřené</string>
@@ -402,6 +402,64 @@ anz der Bedingungen ist erforderlich</string>
<string name="migrate_bookmarks_button">Alle in neue Lesezeichen verschieben</string> <string name="migrate_bookmarks_button">Alle in neue Lesezeichen verschieben</string>
<string name="migrate_bookmarks_success">Lesezeichen erfolgreich migriert</string> <string name="migrate_bookmarks_success">Lesezeichen erfolgreich migriert</string>
<string name="drafts">Entwürfe</string> <string name="drafts">Entwürfe</string>
<string name="scheduled_posts">Geplante Beiträge</string>
<string name="schedule_post">Planen</string>
<string name="schedule_post_time_label">Geplante Zeit</string>
<string name="schedule_post_helper">Beiträge werden innerhalb von ~15 Minuten nach der geplanten Zeit veröffentlicht.</string>
<string name="schedule_post_pick_time">Geplante Zeit auswählen</string>
<string name="schedule_post_pick_label">Planen für…</string>
<string name="schedule_post_publishes_in">Veröffentlicht in %1$s</string>
<string name="schedule_post_was_due">Fällig vor %1$s</string>
<string name="schedule_post_picker_time_title">Zeit</string>
<string name="schedule_post_button_add">Beitrag planen</string>
<string name="schedule_post_button_remove">Planung abbrechen</string>
<string name="schedule_post_warning_title">Dauerbenachrichtigungen deaktiviert</string>
<string name="schedule_post_warning_single">Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App das nächste Mal öffnest. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung.</string>
<string name="schedule_post_warning_multi">Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App wieder öffnest. Geplante Beiträge anderer Konten werden nicht ausgelöst, solange dieses Konto aktiv ist. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung.</string>
<string name="schedule_post_preset_in_one_hour">In 1 Stunde</string>
<string name="schedule_post_preset_tomorrow_morning">Morgen 9 Uhr</string>
<string name="schedule_post_preset_next_monday_morning">Nächsten Montag 9 Uhr</string>
<string name="schedule_post_always_on_prompt_title">Dauerbenachrichtigungen aktivieren?</string>
<string name="schedule_post_always_on_prompt_message">Geplante Beiträge werden zuverlässig nur veröffentlicht, wenn Dauerbenachrichtigungen aktiviert sind. Andernfalls werden sie möglicherweise erst beim nächsten Öffnen der App ausgelöst.</string>
<string name="schedule_post_always_on_prompt_open_settings">Einstellungen öffnen</string>
<string name="schedule_post_always_on_prompt_continue">Trotzdem fortfahren</string>
<string name="scheduled_posts_at_time_past">%1$s · vor %2$s</string>
<string name="scheduled_posts_day_tomorrow">Morgen</string>
<string name="scheduled_posts_logout_toast_zero">Abgemeldet</string>
<plurals name="scheduled_posts_logout_toast">
<item quantity="one">Abgemeldet · %d geplanter Beitrag gelöscht</item>
<item quantity="other">Abgemeldet · %d geplante Beiträge gelöscht</item>
</plurals>
<string name="scheduled_posts_notification_sent_title">Geplanter Beitrag veröffentlicht</string>
<string name="scheduled_posts_notification_failed_title">Geplanter Beitrag fehlgeschlagen</string>
<string name="app_notification_scheduled_posts_channel_name">Geplante Beiträge</string>
<string name="app_notification_scheduled_posts_channel_description">Benachrichtigungen, wenn ein geplanter Beitrag veröffentlicht wird oder die Veröffentlichung fehlschlägt.</string>
<string name="scheduled_posts_action_send_now">Jetzt senden</string>
<string name="scheduled_posts_empty_title">Keine geplanten Beiträge</string>
<string name="scheduled_posts_empty_hint">Verfasse eine Notiz und tippe auf das Uhr-Symbol, um sie für später zu planen.</string>
<string name="scheduled_posts_error_prefix">Fehler: %1$s</string>
<string name="scheduled_posts_status_pending">Geplant</string>
<string name="scheduled_posts_status_publishing">Wird gesendet…</string>
<string name="scheduled_posts_status_failed">Fehlgeschlagen</string>
<string name="scheduled_posts_status_sent">Gesendet</string>
<string name="scheduled_posts_status_cancelled">Abgebrochen</string>
<plurals name="scheduled_posts_logout_warning">
<item quantity="one">Du hast %d geplanten Beitrag, der noch nicht veröffentlicht wurde. Beim Abmelden wird er dauerhaft gelöscht.</item>
<item quantity="other">Du hast %d geplante Beiträge, die noch nicht veröffentlicht wurden. Beim Abmelden werden sie dauerhaft gelöscht.</item>
</plurals>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d in Warteschlange</item>
<item quantity="other">%d in Warteschlange</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · %d fällig in 1 Std.</item>
<item quantity="other"> · %d fällig in 1 Std.</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">an %d Relay</item>
<item quantity="other">an %d Relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Beitrags-ID kopiert</string>
<string name="polls">Umfragen</string> <string name="polls">Umfragen</string>
<string name="open_polls">Offen</string> <string name="open_polls">Offen</string>
<string name="closed_polls">Geschlossen</string> <string name="closed_polls">Geschlossen</string>
@@ -398,6 +398,59 @@
<string name="migrate_bookmarks_button">Összes átköltöztetése az új könyvjelzőkbe</string> <string name="migrate_bookmarks_button">Összes átköltöztetése az új könyvjelzőkbe</string>
<string name="migrate_bookmarks_success">A könyvjelzők átköltöztetése sikeresen befejeződött</string> <string name="migrate_bookmarks_success">A könyvjelzők átköltöztetése sikeresen befejeződött</string>
<string name="drafts">Piszkozatok</string> <string name="drafts">Piszkozatok</string>
<string name="scheduled_posts">Ütemezett bejegyzések</string>
<string name="schedule_post">Ütemezés</string>
<string name="schedule_post_time_label">Ütemezés ideje</string>
<string name="schedule_post_helper">A bejegyzések a tervezett időponttól számított körülbelül 15 percen belül jelennek meg.</string>
<string name="schedule_post_pick_time">Válasszon ki egy időpontot</string>
<string name="schedule_post_pick_label">Ütemezés…</string>
<string name="schedule_post_publishes_in">Közzététel: %1$s</string>
<string name="schedule_post_was_due">Már %1$s ezelőtt esedékes volt</string>
<string name="schedule_post_picker_time_title">Időpont</string>
<string name="schedule_post_button_add">Bejegyzés ütemezése</string>
<string name="schedule_post_button_remove">Ütemezés visszavonása</string>
<string name="schedule_post_warning_title">Folyamatos értesítési szolgáltatás letiltva</string>
<string name="schedule_post_warning_single">Az ütemezett bejegyzések csak akkor jelennek meg, ha legközelebb újra megnyitja az alkalmazást. A megbízható háttérbeli ütemezés érdekében engedélyezze a „Folyamatos értesítési szolgáltatás” beállítását a Beállítások → Felhasználói felület beállítása menüpontban.</string>
<string name="schedule_post_warning_multi">Az ütemezett bejegyzések csak akkor jelennek meg, ha újra megnyitja az alkalmazást. Más fiókok ütemezett bejegyzései nem jelennek meg, amíg ez a fiók aktív. A megbízható háttérbeli ütemezés érdekében engedélyezze a „Folyamatos értesítési szolgáltatás” beállítását a Beállítások → Felhasználói felület beállítása menüpontban.</string>
<string name="schedule_post_preset_in_one_hour">1 órán belül</string>
<string name="schedule_post_preset_tomorrow_morning">Holnap délelőtt 9 órakor</string>
<string name="schedule_post_preset_next_monday_morning">Következő hétfő délelőtt 9 órakor</string>
<string name="schedule_post_always_on_prompt_title">Bekapcsolja a folyamatos értesítési szolgáltatást?</string>
<string name="schedule_post_always_on_prompt_message">Az ütemezett bejegyzések csak akkor jelennek meg biztosan, ha a „Folyamatos értesítési szolgáltatás” engedélyezve van. Ellenkező esetben előfordulhat, hogy csak az alkalmazás következő megnyitásakor jelennek meg.</string>
<string name="schedule_post_always_on_prompt_open_settings">Beállítások menyitása</string>
<string name="schedule_post_always_on_prompt_continue">Folytatás mindenképpen</string>
<string name="scheduled_posts_at_time">%1$s → %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · %2$s ezelőtt</string>
<string name="scheduled_posts_day_tomorrow">Holnap</string>
<string name="scheduled_posts_logout_toast_zero">Kijelentkezve</string>
<string name="scheduled_posts_logout_toast">Ön kijelentkezett · %1$d ütemezett bejegyzés törölve</string>
<string name="scheduled_posts_notification_sent_title">Ütemezett bejegyzés közzétéve</string>
<string name="scheduled_posts_notification_failed_title">Nem sikerült közzétenni egy ütemezett bejegyzést</string>
<string name="app_notification_scheduled_posts_channel_name">Ütemezett bejegyzések</string>
<string name="app_notification_scheduled_posts_channel_description">Értesítések, amikor egy ütemezett bejegyzés megjelenik vagy nem sikerül közzététenni.</string>
<string name="scheduled_posts_action_send_now">Küldés most</string>
<string name="scheduled_posts_empty_title">Nincsenek ütemezett bejegyzések</string>
<string name="scheduled_posts_empty_hint">Írja meg az üzenetet, majd koppintson az óraikonra, hogy a közzétételét későbbre ütemezhesse.</string>
<string name="scheduled_posts_error_prefix">Hiba: %1$s</string>
<string name="scheduled_posts_status_pending">Ütemezve</string>
<string name="scheduled_posts_status_publishing">Küldés…</string>
<string name="scheduled_posts_status_failed">Sikertelen</string>
<string name="scheduled_posts_status_sent">Elküldve</string>
<string name="scheduled_posts_status_cancelled">Megszakitva</string>
<string name="scheduled_posts_logout_warning">Önnek %1$d ütemezett bejegyzése van, amelyeket még nem tett közzé. Kijelentkezés esetén ezek véglegesen törlődnek.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d sorbaállítva</item>
<item quantity="other">%d sorbaállítva</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 bejegyzés 1 órán belül</item>
<item quantity="other"> · %d bejegyzés 1 órán belül</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">1 átjátszóhoz</item>
<item quantity="other">%d átjátszóhoz</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Bejegyzés azonosítója másolva</string>
<string name="polls">Szavazások</string> <string name="polls">Szavazások</string>
<string name="open_polls">Megnyitás</string> <string name="open_polls">Megnyitás</string>
<string name="closed_polls">Lezárva</string> <string name="closed_polls">Lezárva</string>
@@ -396,6 +396,67 @@
<string name="migrate_bookmarks_button">Mover Tudo para Novos Favoritos</string> <string name="migrate_bookmarks_button">Mover Tudo para Novos Favoritos</string>
<string name="migrate_bookmarks_success">Favoritos migrados com sucesso</string> <string name="migrate_bookmarks_success">Favoritos migrados com sucesso</string>
<string name="drafts">Rascunhos</string> <string name="drafts">Rascunhos</string>
<string name="scheduled_posts">Posts agendados</string>
<string name="schedule_post">Agendar</string>
<string name="schedule_post_time_label">Hora agendada</string>
<string name="schedule_post_helper">Posts são publicados em até ~15 minutos após o horário agendado.</string>
<string name="schedule_post_pick_time">Escolher horário agendado</string>
<string name="schedule_post_pick_label">Agendar para…</string>
<string name="schedule_post_publishes_in">Publica em %1$s</string>
<string name="schedule_post_was_due">Devia ter sido publicado há %1$s</string>
<string name="schedule_post_picker_time_title">Hora</string>
<string name="schedule_post_button_add">Agendar post</string>
<string name="schedule_post_button_remove">Cancelar agendamento</string>
<string name="schedule_post_warning_title">Notificações sempre ativas desativadas</string>
<string name="schedule_post_warning_single">Posts agendados podem não ser publicados até você reabrir o app. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano.</string>
<string name="schedule_post_warning_multi">Posts agendados podem não ser publicados até você reabrir o app. Posts agendados de outras contas não serão disparados enquanto esta conta estiver ativa. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano.</string>
<string name="schedule_post_preset_in_one_hour">Em 1 hora</string>
<string name="schedule_post_preset_tomorrow_morning">Amanhã às 9h</string>
<string name="schedule_post_preset_next_monday_morning">Próxima segunda às 9h</string>
<string name="schedule_post_always_on_prompt_title">Ativar notificações sempre ativas?</string>
<string name="schedule_post_always_on_prompt_message">Posts agendados publicam de forma confiável apenas quando notificações sempre ativas estão ativadas. Caso contrário, podem não disparar até você reabrir o app.</string>
<string name="schedule_post_always_on_prompt_open_settings">Abrir configurações</string>
<string name="schedule_post_always_on_prompt_continue">Continuar mesmo assim</string>
<string name="scheduled_posts_at_time">%1$s · em %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · há %2$s</string>
<string name="scheduled_posts_day_tomorrow">Amanhã</string>
<string name="scheduled_posts_logout_toast_zero">Desconectado</string>
<plurals name="scheduled_posts_logout_toast">
<item quantity="one">Desconectado · %d post agendado excluído</item>
<item quantity="many">Desconectado · %d posts agendados excluídos</item>
<item quantity="other">Desconectado · %d posts agendados excluídos</item>
</plurals>
<string name="scheduled_posts_notification_sent_title">Post agendado publicado</string>
<string name="scheduled_posts_notification_failed_title">Post agendado falhou</string>
<string name="app_notification_scheduled_posts_channel_name">Posts agendados</string>
<string name="app_notification_scheduled_posts_channel_description">Notificações quando um post agendado é publicado ou falha ao publicar.</string>
<string name="scheduled_posts_action_send_now">Enviar agora</string>
<string name="scheduled_posts_empty_title">Sem posts agendados</string>
<string name="scheduled_posts_empty_hint">Componha uma nota e toque no ícone do relógio para agendá-la.</string>
<string name="scheduled_posts_error_prefix">Erro: %1$s</string>
<string name="scheduled_posts_status_pending">Agendado</string>
<string name="scheduled_posts_status_publishing">Enviando…</string>
<string name="scheduled_posts_status_failed">Falhou</string>
<string name="scheduled_posts_status_sent">Enviado</string>
<string name="scheduled_posts_status_cancelled">Cancelado</string>
<plurals name="scheduled_posts_logout_warning">
<item quantity="one">Você tem %d post agendado que ainda não foi publicado. Sair excluirá esse post permanentemente.</item>
<item quantity="many">Você tem %d posts agendados que ainda não foram publicados. Sair excluirá esses posts permanentemente.</item>
<item quantity="other">Você tem %d posts agendados que ainda não foram publicados. Sair excluirá esses posts permanentemente.</item>
</plurals>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d na fila</item>
<item quantity="other">%d na fila</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · %d em 1h</item>
<item quantity="other"> · %d em 1h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">para %d relay</item>
<item quantity="other">para %d relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">ID do post copiado</string>
<string name="polls">Enquetes</string> <string name="polls">Enquetes</string>
<string name="open_polls">Abertas</string> <string name="open_polls">Abertas</string>
<string name="closed_polls">Encerradas</string> <string name="closed_polls">Encerradas</string>
@@ -396,6 +396,65 @@
<string name="migrate_bookmarks_button">Flytta allt till nya bokmärken</string> <string name="migrate_bookmarks_button">Flytta allt till nya bokmärken</string>
<string name="migrate_bookmarks_success">Bokmärken migrerade</string> <string name="migrate_bookmarks_success">Bokmärken migrerade</string>
<string name="drafts">Utkast</string> <string name="drafts">Utkast</string>
<string name="scheduled_posts">Schemalagda inlägg</string>
<string name="schedule_post">Schemalägg</string>
<string name="schedule_post_time_label">Schemalagd tid</string>
<string name="schedule_post_helper">Inlägg publiceras inom ~15 minuter från den schemalagda tiden.</string>
<string name="schedule_post_pick_time">Välj schemalagd tid</string>
<string name="schedule_post_pick_label">Schemalägg för…</string>
<string name="schedule_post_publishes_in">Publiceras om %1$s</string>
<string name="schedule_post_was_due">Skulle ha publicerats för %1$s sedan</string>
<string name="schedule_post_picker_time_title">Tid</string>
<string name="schedule_post_button_add">Schemalägg inlägg</string>
<string name="schedule_post_button_remove">Avbryt schemaläggning</string>
<string name="schedule_post_warning_title">Alltid-på-aviseringar avstängda</string>
<string name="schedule_post_warning_single">Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden.</string>
<string name="schedule_post_warning_multi">Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Andra kontons schemalagda inlägg utlöses inte medan detta konto är aktivt. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden.</string>
<string name="schedule_post_preset_in_one_hour">Om 1 timme</string>
<string name="schedule_post_preset_tomorrow_morning">Imorgon kl. 09:00</string>
<string name="schedule_post_preset_next_monday_morning">Nästa måndag kl. 09:00</string>
<string name="schedule_post_always_on_prompt_title">Aktivera alltid-på-aviseringar?</string>
<string name="schedule_post_always_on_prompt_message">Schemalagda inlägg publiceras pålitligt endast när alltid-på-aviseringar är aktiverade. Annars kanske de inte utlöses förrän du öppnar appen igen.</string>
<string name="schedule_post_always_on_prompt_open_settings">Öppna inställningar</string>
<string name="schedule_post_always_on_prompt_continue">Fortsätt ändå</string>
<string name="scheduled_posts_at_time">%1$s · om %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · för %2$s sedan</string>
<string name="scheduled_posts_day_tomorrow">Imorgon</string>
<string name="scheduled_posts_logout_toast_zero">Utloggad</string>
<plurals name="scheduled_posts_logout_toast">
<item quantity="one">Utloggad · %d schemalagt inlägg raderat</item>
<item quantity="other">Utloggad · %d schemalagda inlägg raderade</item>
</plurals>
<string name="scheduled_posts_notification_sent_title">Schemalagt inlägg publicerat</string>
<string name="scheduled_posts_notification_failed_title">Schemalagt inlägg misslyckades</string>
<string name="app_notification_scheduled_posts_channel_name">Schemalagda inlägg</string>
<string name="app_notification_scheduled_posts_channel_description">Aviseringar när ett schemalagt inlägg publiceras eller misslyckas att publicera.</string>
<string name="scheduled_posts_action_send_now">Skicka nu</string>
<string name="scheduled_posts_empty_title">Inga schemalagda inlägg</string>
<string name="scheduled_posts_empty_hint">Skriv en anteckning och tryck på klockikonen för att schemalägga den.</string>
<string name="scheduled_posts_error_prefix">Fel: %1$s</string>
<string name="scheduled_posts_status_pending">Schemalagd</string>
<string name="scheduled_posts_status_publishing">Skickar…</string>
<string name="scheduled_posts_status_failed">Misslyckades</string>
<string name="scheduled_posts_status_sent">Skickat</string>
<string name="scheduled_posts_status_cancelled">Avbrutet</string>
<plurals name="scheduled_posts_logout_warning">
<item quantity="one">Du har %d schemalagt inlägg som inte har publicerats än. Att logga ut raderar det permanent.</item>
<item quantity="other">Du har %d schemalagda inlägg som inte har publicerats än. Att logga ut raderar dem permanent.</item>
</plurals>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d i kö</item>
<item quantity="other">%d i kö</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · %d inom 1 h</item>
<item quantity="other"> · %d inom 1 h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">till %d relä</item>
<item quantity="other">till %d reläer</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Inläggs-ID kopierat</string>
<string name="polls">Omröstningar</string> <string name="polls">Omröstningar</string>
<string name="open_polls">Öppna</string> <string name="open_polls">Öppna</string>
<string name="closed_polls">Stängda</string> <string name="closed_polls">Stängda</string>
@@ -14,7 +14,7 @@
<string name="referenced_event_not_found">未找到相关事件</string> <string name="referenced_event_not_found">未找到相关事件</string>
<string name="could_not_decrypt_the_message">无法解密消息</string> <string name="could_not_decrypt_the_message">无法解密消息</string>
<string name="group_picture">群聊图片</string> <string name="group_picture">群聊图片</string>
<string name="explicit_content">明确内容</string> <string name="explicit_content">露骨内容</string>
<string name="relay_notice">中继通知</string> <string name="relay_notice">中继通知</string>
<string name="duplicated_post">重复的贴文</string> <string name="duplicated_post">重复的贴文</string>
<string name="spam">垃圾信息</string> <string name="spam">垃圾信息</string>
@@ -39,7 +39,7 @@
<string name="block_hide_user"><![CDATA[阻止并隐藏用户]]></string> <string name="block_hide_user"><![CDATA[阻止并隐藏用户]]></string>
<string name="report_spam_scam">举报垃圾邮件/诈骗</string> <string name="report_spam_scam">举报垃圾邮件/诈骗</string>
<string name="report_impersonation">举报冒充</string> <string name="report_impersonation">举报冒充</string>
<string name="report_explicit_content">举报明确内容</string> <string name="report_explicit_content">举报露骨内容</string>
<string name="report_illegal_behaviour">举报非法行为</string> <string name="report_illegal_behaviour">举报非法行为</string>
<string name="report_malware">举报恶意软件</string> <string name="report_malware">举报恶意软件</string>
<string name="report_mod">举报 Mod</string> <string name="report_mod">举报 Mod</string>
@@ -398,6 +398,56 @@
<string name="migrate_bookmarks_button">移动全部到新书签</string> <string name="migrate_bookmarks_button">移动全部到新书签</string>
<string name="migrate_bookmarks_success">书签迁移成功</string> <string name="migrate_bookmarks_success">书签迁移成功</string>
<string name="drafts">草稿</string> <string name="drafts">草稿</string>
<string name="scheduled_posts">定时帖子</string>
<string name="schedule_post">计划</string>
<string name="schedule_post_time_label">计划的时间</string>
<string name="schedule_post_helper">帖子发布时间计划时间的 ~15 分钟内。</string>
<string name="schedule_post_pick_time">选择计划时间</string>
<string name="schedule_post_pick_label">选择计划项目…</string>
<string name="schedule_post_publishes_in">在 %1$s 内发布</string>
<string name="schedule_post_was_due">%1$s 前到期</string>
<string name="schedule_post_picker_time_title">时间</string>
<string name="schedule_post_button_add">定时帖</string>
<string name="schedule_post_button_remove">取消时间安排</string>
<string name="schedule_post_warning_title">禁用了始终显示的通知</string>
<string name="schedule_post_warning_single">下次重新打开应用前可能不会发布定时贴。要获得稳定的后台定时功能在“设置” → “用户界面偏好” 中启用“始终显示”。</string>
<string name="schedule_post_warning_multi">在重新打开应用前可能不会发布定时帖。此账户活跃时,其他账户的定时帖不会发布。要获得稳定的后台定时功能,在“设置” →\"用户界面首选项“ 中开启”始终显示“。</string>
<string name="schedule_post_preset_in_one_hour">1小时内</string>
<string name="schedule_post_preset_tomorrow_morning">明天上午9点</string>
<string name="schedule_post_preset_next_monday_morning">下周一上午9点</string>
<string name="schedule_post_always_on_prompt_title">开启”始终显示的通知“?</string>
<string name="schedule_post_always_on_prompt_message">只有在启用了”始终显示的通知“时才能可靠地发布定时帖。否则,在下次重新打开应用前定时帖可能不会发布。</string>
<string name="schedule_post_always_on_prompt_open_settings">打开设置</string>
<string name="schedule_post_always_on_prompt_continue">仍然继续</string>
<string name="scheduled_posts_at_time">%1$s ·在 %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · %2$s 前</string>
<string name="scheduled_posts_day_tomorrow">明天</string>
<string name="scheduled_posts_logout_toast_zero">已退出登录</string>
<string name="scheduled_posts_logout_toast">已退出登录 删除了 %1$d 个定时帖</string>
<string name="scheduled_posts_notification_sent_title">发布了定时帖</string>
<string name="scheduled_posts_notification_failed_title">未能发布定时帖</string>
<string name="app_notification_scheduled_posts_channel_name">定时帖</string>
<string name="app_notification_scheduled_posts_channel_description">当成功发布了定时帖或未能发布定时帖时发出通知。</string>
<string name="scheduled_posts_action_send_now">立即发送</string>
<string name="scheduled_posts_empty_title">无定时帖</string>
<string name="scheduled_posts_empty_hint">撰写笔记并轻触时钟图标稍后安排时间。</string>
<string name="scheduled_posts_error_prefix">错误:%1$s</string>
<string name="scheduled_posts_status_pending">已安排时间</string>
<string name="scheduled_posts_status_publishing">正在发送…</string>
<string name="scheduled_posts_status_failed">失败</string>
<string name="scheduled_posts_status_sent">已发送</string>
<string name="scheduled_posts_status_cancelled">已取消</string>
<string name="scheduled_posts_logout_warning">您有尚未发布的 %1$d 个定时帖。退出登录将永久删除它们。</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="other">%d 个已加入队列</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="other"> • %d 个于1小时内到期</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="other">到 %d 个中继</item>
</plurals>
<string name="scheduled_posts_event_id_copied">已复制笔记 ID</string>
<string name="polls">投票</string> <string name="polls">投票</string>
<string name="open_polls">开启</string> <string name="open_polls">开启</string>
<string name="closed_polls">已关闭</string> <string name="closed_polls">已关闭</string>
@@ -739,7 +789,7 @@
<string name="zap_type_nonzap">非打闪</string> <string name="zap_type_nonzap">非打闪</string>
<string name="zap_type_nonzap_explainer">Nostr 上没有痕迹,仅在闪电上</string> <string name="zap_type_nonzap_explainer">Nostr 上没有痕迹,仅在闪电上</string>
<string name="post_anonymously">匿名</string> <string name="post_anonymously">匿名</string>
<string name="post_anonymously_explainer">使用新的一次性身份发。您的帐户将不会被链接到这个回复。</string> <string name="post_anonymously_explainer">使用新的一次性身份发。您的帐户将不会被链接到这个回复。</string>
<string name="anonymous_reply_warning">此回复将从新的匿名身份发布</string> <string name="anonymous_reply_warning">此回复将从新的匿名身份发布</string>
<string name="file_server">文件服务器</string> <string name="file_server">文件服务器</string>
<string name="file_server_description">选择上传文件时使用的服务器</string> <string name="file_server_description">选择上传文件时使用的服务器</string>
+62
View File
@@ -428,6 +428,66 @@
<string name="migrate_bookmarks_button">Move All to New Bookmarks</string> <string name="migrate_bookmarks_button">Move All to New Bookmarks</string>
<string name="migrate_bookmarks_success">Bookmarks migrated successfully</string> <string name="migrate_bookmarks_success">Bookmarks migrated successfully</string>
<string name="drafts">Drafts</string> <string name="drafts">Drafts</string>
<string name="scheduled_posts">Scheduled posts</string>
<string name="schedule_post">Schedule</string>
<string name="schedule_post_time_label">Scheduled time</string>
<string name="schedule_post_helper">Posts publish within ~15 minutes of the scheduled time.</string>
<string name="schedule_post_pick_time">Pick scheduled time</string>
<string name="schedule_post_pick_label">Schedule for…</string>
<string name="schedule_post_publishes_in">Publishes in %1$s</string>
<string name="schedule_post_was_due">Was due %1$s ago</string>
<string name="schedule_post_picker_time_title">Time</string>
<string name="schedule_post_button_add">Schedule post</string>
<string name="schedule_post_button_remove">Cancel scheduling</string>
<string name="schedule_post_warning_title">Always-on notifications disabled</string>
<string name="schedule_post_warning_single">Scheduled posts may not publish until you next reopen the app. Enable always-on in Settings → UI Preferences for reliable background scheduling.</string>
<string name="schedule_post_warning_multi">Scheduled posts may not publish until you reopen the app. Other accounts\' scheduled posts won\'t fire while this account is active. Enable always-on in Settings → UI Preferences for reliable background scheduling.</string>
<string name="schedule_post_preset_in_one_hour">In 1 hour</string>
<string name="schedule_post_preset_tomorrow_morning">Tomorrow 9 AM</string>
<string name="schedule_post_preset_next_monday_morning">Next Monday 9 AM</string>
<string name="schedule_post_always_on_prompt_title">Enable always-on notifications?</string>
<string name="schedule_post_always_on_prompt_message">Scheduled posts publish reliably only when always-on notifications are enabled. Otherwise, they may not fire until you next reopen the app.</string>
<string name="schedule_post_always_on_prompt_open_settings">Open settings</string>
<string name="schedule_post_always_on_prompt_continue">Continue anyway</string>
<string name="scheduled_posts_at_time">%1$s · in %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · %2$s ago</string>
<string name="scheduled_posts_day_tomorrow">Tomorrow</string>
<string name="scheduled_posts_logout_toast_zero">Logged out</string>
<plurals name="scheduled_posts_logout_toast">
<item quantity="one">Logged out · %d scheduled post deleted</item>
<item quantity="other">Logged out · %d scheduled posts deleted</item>
</plurals>
<string name="scheduled_posts_notification_sent_title">Scheduled post published</string>
<string name="scheduled_posts_notification_failed_title">Scheduled post failed</string>
<string name="app_notification_scheduled_posts_channel_id" translatable="false">ScheduledPostsID</string>
<string name="app_notification_scheduled_posts_channel_name">Scheduled posts</string>
<string name="app_notification_scheduled_posts_channel_description">Notifications when a scheduled post is published or fails to publish.</string>
<string name="scheduled_posts_action_send_now">Send now</string>
<string name="scheduled_posts_empty_title">No scheduled posts</string>
<string name="scheduled_posts_empty_hint">Compose a note and tap the clock icon to schedule it for later.</string>
<string name="scheduled_posts_error_prefix">Error: %1$s</string>
<string name="scheduled_posts_status_pending">Scheduled</string>
<string name="scheduled_posts_status_publishing">Sending…</string>
<string name="scheduled_posts_status_failed">Failed</string>
<string name="scheduled_posts_status_sent">Sent</string>
<string name="scheduled_posts_status_cancelled">Cancelled</string>
<plurals name="scheduled_posts_logout_warning">
<item quantity="one">You have %d scheduled post that hasn\'t been published yet. Logging out will permanently delete it.</item>
<item quantity="other">You have %d scheduled posts that haven\'t been published yet. Logging out will permanently delete them.</item>
</plurals>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d queued</item>
<item quantity="other">%d queued</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · %d due in 1h</item>
<item quantity="other"> · %d due in 1h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">to %d relay</item>
<item quantity="other">to %d relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Note ID copied</string>
<string name="polls">Polls</string> <string name="polls">Polls</string>
<string name="open_polls">Open</string> <string name="open_polls">Open</string>
<string name="closed_polls">Closed</string> <string name="closed_polls">Closed</string>
@@ -1125,6 +1185,8 @@
<string name="filter_spam_from_strangers_title">Filter spam</string> <string name="filter_spam_from_strangers_title">Filter spam</string>
<string name="filter_spam_from_strangers_explainer">Hides posts from strangers that were exactly the same for 5 or more times</string> <string name="filter_spam_from_strangers_explainer">Hides posts from strangers that were exactly the same for 5 or more times</string>
<string name="disable_client_tag_title">Don\'t add client tag to my events</string>
<string name="disable_client_tag_explainer">When enabled, Amethyst will not append a NIP-89 client tag to events you publish.</string>
<string name="warn_when_posts_have_reports_from_your_follows_title">Warn on reports</string> <string name="warn_when_posts_have_reports_from_your_follows_title">Warn on reports</string>
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Shows a warning message when posts have 5 or more reports from your follows</string> <string name="warn_when_posts_have_reports_from_your_follows_explainer">Shows a warning message when posts have 5 or more reports from your follows</string>
<string name="show_sensitive_content_title">Show sensitive content</string> <string name="show_sensitive_content_title">Show sensitive content</string>
@@ -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)
}
}
@@ -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)
}
}
@@ -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
@@ -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,
)
}
}
@@ -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,
}
@@ -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,
),
)
@@ -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())
}
}
@@ -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
}
}
}
}
@@ -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()
@@ -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
},
)
}
}
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -66,6 +67,11 @@ fun LoadingState(
/** /**
* A centered empty state with title, optional description, and optional refresh action. * A centered empty state with title, optional description, and optional refresh action.
*
* The optional `description` is wrapped in a [SelectionContainer] so users can
* select and copy it. `EmptyState` is reused as the in-feed error renderer
* (e.g. "Error loading feed" with the underlying error in `description`), so
* making the description selectable lets users copy error text for reporting.
*/ */
@Composable @Composable
fun EmptyState( fun EmptyState(
@@ -89,12 +95,14 @@ fun EmptyState(
) )
if (description != null) { if (description != null) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
SelectionContainer {
Text( Text(
description, description,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
) )
} }
}
if (onRefresh != null) { if (onRefresh != null) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = onRefresh) { OutlinedButton(onClick = onRefresh) {
@@ -106,6 +114,9 @@ fun EmptyState(
/** /**
* A centered error state with message and optional retry action. * A centered error state with message and optional retry action.
*
* The error `message` is wrapped in a [SelectionContainer] so users can select
* and copy it useful for reporting bugs or pasting error text into a search.
*/ */
@Composable @Composable
fun ErrorState( fun ErrorState(
@@ -121,11 +132,13 @@ fun ErrorState(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
SelectionContainer {
Text( Text(
message, message,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
}
if (onRetry != null) { if (onRetry != null) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Button(onClick = onRetry) { Button(onClick = onRetry) {
@@ -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)
}
}
@@ -0,0 +1,103 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.feeds.custom
import com.vitorpamplona.amethyst.commons.search.SearchQuery
import kotlinx.collections.immutable.persistentListOf
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SearchQueryToFeedTest {
@Test
fun convertsHashtagsToFeedFilter() {
val query =
SearchQuery(
hashtags = persistentListOf("bitcoin", "nostr"),
)
val feed = query.toFeedDefinition(name = "BTC + Nostr", emoji = "\u20BF")
assertEquals("BTC + Nostr", feed.name)
assertEquals("\u20BF", feed.emoji)
val source = feed.source as FeedSource.Filter
assertEquals(listOf("bitcoin", "nostr"), source.hashtags)
}
@Test
fun convertsAuthorsToFeedFilter() {
val query =
SearchQuery(
authors = persistentListOf("abc123", "def456"),
)
val feed = query.toFeedDefinition(name = "Devs")
val source = feed.source as FeedSource.Filter
assertEquals(listOf("abc123", "def456"), source.authors)
}
@Test
fun convertsExcludeTerms() {
val query =
SearchQuery(
hashtags = persistentListOf("bitcoin"),
excludeTerms = persistentListOf("scam", "spam"),
)
val feed = query.toFeedDefinition(name = "Clean BTC")
val source = feed.source as FeedSource.Filter
assertEquals(listOf("scam", "spam"), source.excludeKeywords)
}
@Test
fun convertsKinds() {
val query =
SearchQuery(
kinds = persistentListOf(1, 30023),
hashtags = persistentListOf("dev"),
)
val feed = query.toFeedDefinition(name = "Dev Articles")
val source = feed.source as FeedSource.Filter
assertEquals(listOf(1, 30023), source.kinds)
}
@Test
fun canBecomeFeedWithHashtags() {
assertTrue(SearchQuery(hashtags = persistentListOf("btc")).canBecomeFeed())
}
@Test
fun canBecomeFeedWithAuthors() {
assertTrue(SearchQuery(authors = persistentListOf("abc")).canBecomeFeed())
}
@Test
fun canNotBecomeFeedEmpty() {
assertFalse(SearchQuery.EMPTY.canBecomeFeed())
}
@Test
fun canNotBecomeFeedTextOnly() {
assertFalse(SearchQuery(text = "hello").canBecomeFeed())
}
}
@@ -690,6 +690,10 @@ fun App(
return // Nothing below runs until Tor is Active return // Nothing below runs until Tor is Active
} }
var appDrawerInitialTab by remember {
mutableStateOf<com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab?>(null)
}
val localCache = remember { DesktopLocalCache() } val localCache = remember { DesktopLocalCache() }
val accountState by accountManager.accountState.collectAsState() val accountState by accountManager.accountState.collectAsState()
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
@@ -871,6 +875,10 @@ fun App(
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
) {
CompositionLocalProvider(
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager,
) { ) {
when (accountState) { when (accountState) {
is AccountState.LoggedOut -> { is AccountState.LoggedOut -> {
@@ -943,6 +951,11 @@ fun App(
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
onShowAppDrawer = onShowAppDrawer, onShowAppDrawer = onShowAppDrawer,
onOpenFeedsDrawer = {
appDrawerInitialTab =
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
onShowAppDrawer()
},
) )
} }
@@ -960,6 +973,7 @@ fun App(
if (showAppDrawer) { if (showAppDrawer) {
val openColumns by deckState.columns.collectAsState() val openColumns by deckState.columns.collectAsState()
AppDrawer( AppDrawer(
initialTab = appDrawerInitialTab,
openColumnTypes = openColumnTypes =
if (layoutMode == LayoutMode.DECK) { if (layoutMode == LayoutMode.DECK) {
openColumns.map { it.type.typeKey() }.toSet() openColumns.map { it.type.typeKey() }.toSet()
@@ -1002,7 +1016,10 @@ fun App(
} }
} }
}, },
onDismiss = onDismissAppDrawer, onDismiss = {
appDrawerInitialTab = null
onDismissAppDrawer()
},
) )
} }
} }
@@ -1020,6 +1037,7 @@ fun App(
} }
} }
} }
}
@Composable @Composable
fun MainContent( fun MainContent(
@@ -1040,6 +1058,7 @@ fun MainContent(
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onShowAppDrawer: () -> Unit, onShowAppDrawer: () -> Unit,
onOpenFeedsDrawer: () -> Unit = onShowAppDrawer,
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -1230,6 +1249,8 @@ fun MainContent(
CompositionLocalProvider( CompositionLocalProvider(
LocalRelayCategories provides relayCategories, LocalRelayCategories provides relayCategories,
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager,
) { ) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
@@ -1252,6 +1273,7 @@ fun MainContent(
singlePaneState = singlePaneState, singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState, pinnedNavBarState = pinnedNavBarState,
onOpenAppDrawer = onShowAppDrawer, onOpenAppDrawer = onShowAppDrawer,
onOpenFeedsDrawer = onOpenFeedsDrawer,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.desktop.feeds package com.vitorpamplona.amethyst.desktop.feeds
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder
@@ -102,6 +103,60 @@ class DesktopFollowingFeedFilter(
override fun limit(): Int = 2500 override fun limit(): Int = 2500
} }
/**
* Custom feed filter: matches events based on FeedSource.Filter criteria.
* Excludes are applied client-side (relay can't express NOT filters).
*/
class DesktopCustomFeedFilter(
private val cache: DesktopLocalCache,
private val feedId: String,
private val source: FeedSource.Filter,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "custom-$feedId"
private fun matchesSource(note: Note): Boolean {
val event = note.event ?: return false
if (!isFeedNote(event)) return false
// Kind filter
if (source.kinds.isNotEmpty() && event.kind !in source.kinds) return false
// Author filter (if specified, note must be from one of these authors)
if (source.authors.isNotEmpty() && note.author?.pubkeyHex !in source.authors) return false
// Hashtag filter (if specified, event must contain at least one)
if (source.hashtags.isNotEmpty()) {
val eventTags =
event.tags
.filter { it.size >= 2 && it[0] == "t" }
.map { it[1].lowercase() }
if (source.hashtags.none { it.lowercase() in eventTags }) return false
}
// Exclusions
if (source.excludeAuthors.isNotEmpty() && note.author?.pubkeyHex in source.excludeAuthors) return false
if (source.excludeKeywords.isNotEmpty()) {
val content = event.content.lowercase()
if (source.excludeKeywords.any { content.contains(it.lowercase()) }) return false
}
return true
}
override fun feed(): List<Note> =
cache.notes
.filterIntoSet { _, note -> matchesSource(note) }
.sortedWith(DefaultFeedOrder)
.deduplicateReposts()
.take(limit())
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { matchesSource(it) }
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
override fun limit(): Int = 2500
}
/** /**
* Thread feed: root note + all replies (graph walk via Note.replies). * Thread feed: root note + all replies (graph walk via Note.replies).
*/ */
@@ -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()
}
}
@@ -20,9 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.desktop.subscriptions package com.vitorpamplona.amethyst.desktop.subscriptions
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/** /**
* Feed mode for feed subscriptions. * Feed mode for feed subscriptions.
@@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
enum class FeedMode { enum class FeedMode {
GLOBAL, GLOBAL,
FOLLOWING, FOLLOWING,
CUSTOM,
} }
/** /**
@@ -362,3 +365,77 @@ fun createChessSubscription(
onEvent = onEvent, onEvent = onEvent,
onEose = onEose, onEose = onEose,
) )
/**
* Creates a subscription config for a custom feed based on FeedSource.Filter.
*/
fun createCustomFeedSubscription(
source: FeedSource.Filter,
relays: Set<NormalizedRelayUrl>,
limit: Int = 200,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
val filters = mutableListOf<Filter>()
val kinds = source.kinds.ifEmpty { listOf(1, 6, 16) }
when {
source.authors.isNotEmpty() && source.hashtags.isNotEmpty() -> {
// Authors + hashtags: two separate filters (relay does OR between filters)
filters.add(
Filter(
kinds = kinds,
authors = source.authors.toList(),
limit = limit,
),
)
filters.add(
Filter(
kinds = kinds,
tags = mapOf("t" to source.hashtags.toList()),
limit = limit,
),
)
}
source.authors.isNotEmpty() -> {
filters.add(
Filter(
kinds = kinds,
authors = source.authors.toList(),
limit = limit,
),
)
}
source.hashtags.isNotEmpty() -> {
filters.add(
Filter(
kinds = kinds,
tags = mapOf("t" to source.hashtags.toList()),
limit = limit,
),
)
}
else -> {
return null
}
}
if (filters.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("custom-feed"),
filters = filters,
relays =
if (source.relays.isNotEmpty()) {
source.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
} else {
relays
},
onEvent = onEvent,
onEose = onEose,
)
}
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -251,21 +252,25 @@ fun ComposeNoteDialog(
errorMessage?.let { error -> errorMessage?.let { error ->
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
SelectionContainer {
Text( Text(
error, error,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
}
uploadState.error?.let { error -> uploadState.error?.let { error ->
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
SelectionContainer {
Text( Text(
"Upload error: $error", "Upload error: $error",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -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)
}
}
}
}
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopCustomFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
@@ -75,6 +76,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createCustomFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
@@ -274,6 +276,9 @@ fun FeedScreen(
localCache: DesktopLocalCache, localCache: DesktopLocalCache,
account: AccountState.LoggedIn? = null, account: AccountState.LoggedIn? = null,
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount? = null, iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount? = null,
customFeedId: String? = null,
customFeedSource: com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter? = null,
onOpenFeedsDrawer: () -> Unit = {},
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
initialFeedMode: FeedMode? = null, initialFeedMode: FeedMode? = null,
@@ -297,7 +302,15 @@ fun FeedScreen(
var replyToEvent by remember { mutableStateOf<Event?>(null) } var replyToEvent by remember { mutableStateOf<Event?>(null) }
var lightboxState by remember { mutableStateOf<LightboxState?>(null) } var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
var showRelayPicker by remember { mutableStateOf(false) } var showRelayPicker by remember { mutableStateOf(false) }
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } var activeFeedId by remember { mutableStateOf(customFeedId) }
var activeFeedSource by remember {
mutableStateOf(customFeedSource)
}
var feedMode by remember {
mutableStateOf(
if (customFeedSource != null) FeedMode.CUSTOM else (initialFeedMode ?: DesktopPreferences.feedMode),
)
}
// Subscribe to contact list (kind 3) — populates localCache.followedUsers // Subscribe to contact list (kind 3) — populates localCache.followedUsers
rememberSubscription(allRelayUrls, account, relayManager = relayManager) { rememberSubscription(allRelayUrls, account, relayManager = relayManager) {
@@ -315,7 +328,7 @@ fun FeedScreen(
} }
// Subscribe to feed events (kind 1) — populates cache via coordinator // Subscribe to feed events (kind 1) — populates cache via coordinator
rememberSubscription(feedRelays, feedMode, followedUsers, relayManager = relayManager) { rememberSubscription(feedRelays, feedMode, followedUsers, activeFeedSource, relayManager = relayManager) {
if (feedRelays.isEmpty()) return@rememberSubscription null if (feedRelays.isEmpty()) return@rememberSubscription null
when (feedMode) { when (feedMode) {
@@ -342,12 +355,27 @@ fun FeedScreen(
null null
} }
} }
FeedMode.CUSTOM -> {
val src = activeFeedSource
if (src != null) {
createCustomFeedSubscription(
source = src,
relays = feedRelays,
onEvent = { event, _, relay, _ ->
subscriptionsCoordinator?.consumeEvent(event, relay)
},
)
} else {
null
}
}
} }
} }
// DesktopFeedViewModel keyed on feedMode — recreated on mode switch // DesktopFeedViewModel keyed on feedMode — recreated on mode switch
val viewModel = val viewModel =
remember(feedMode) { remember(feedMode, activeFeedId) {
val filter = val filter =
when (feedMode) { when (feedMode) {
FeedMode.GLOBAL -> { FeedMode.GLOBAL -> {
@@ -359,6 +387,15 @@ fun FeedScreen(
localCache.followedUsers.value localCache.followedUsers.value
} }
} }
FeedMode.CUSTOM -> {
DesktopCustomFeedFilter(
localCache,
activeFeedId ?: "custom",
activeFeedSource ?: com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
.Filter(),
)
}
} }
DesktopFeedViewModel(filter, localCache) DesktopFeedViewModel(filter, localCache)
} }
@@ -489,20 +526,28 @@ fun FeedScreen(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
ReadingColumn { ReadingColumn {
// Header with compose button // Header: pinned feed tabs + "Show More +"
FeedHeader( FeedTabsHeader(
feedMode = feedMode, feedMode = feedMode,
account = account, activeFeedId = activeFeedId,
feedRelays = feedRelays,
followedUsersCount = followedUsers.size,
onFeedModeChange = { mode -> onFeedModeChange = { mode ->
feedMode = mode feedMode = mode
activeFeedId = null
activeFeedSource = null
if (mode != FeedMode.CUSTOM) {
DesktopPreferences.feedMode = mode DesktopPreferences.feedMode = mode
}
}, },
onRefresh = { relayManager.connect() }, onNavigateToFeed = { feed ->
val source = feed.source
if (source is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter) {
activeFeedId = feed.id
activeFeedSource = source
feedMode = FeedMode.CUSTOM
}
},
onOpenFeedsDrawer = onOpenFeedsDrawer,
onCompose = onCompose, onCompose = onCompose,
onNavigateToRelays = onNavigateToRelays,
onOpenRelayPicker = { showRelayPicker = true },
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -665,6 +710,83 @@ fun FeedScreen(
* the relays icon (desktop convention \u2014 the at-a-glance info is preserved * the relays icon (desktop convention \u2014 the at-a-glance info is preserved
* without stealing header real estate). * without stealing header real estate).
*/ */
@Composable
private fun FeedTabsHeader(
feedMode: FeedMode,
activeFeedId: String? = null,
onFeedModeChange: (FeedMode) -> Unit,
onNavigateToFeed: (com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition) -> Unit = {},
onOpenFeedsDrawer: () -> Unit,
onCompose: () -> Unit,
) {
val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current
val pinnedFeeds by feedRepo.pinnedFeeds.collectAsState()
val sidePadding = LocalReadingSidePadding.current
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = sidePadding + 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
// Pinned feed tabs
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
pinnedFeeds.forEach { feed ->
val isSelected =
when (feed.source) {
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> {
feedMode == FeedMode.FOLLOWING
}
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> {
feedMode == FeedMode.GLOBAL
}
else -> {
activeFeedId == feed.id
}
}
FilterChip(
selected = isSelected,
onClick = {
when (feed.source) {
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> {
onFeedModeChange(FeedMode.FOLLOWING)
}
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> {
onFeedModeChange(FeedMode.GLOBAL)
}
else -> {
onNavigateToFeed(feed)
}
}
},
label = { Text("${feed.emoji} ${feed.name}") },
)
}
// "Show More +" button
FilterChip(
selected = false,
onClick = onOpenFeedsDrawer,
label = { Text("+ More") },
)
}
// Compose button
IconButton(onClick = onCompose) {
Icon(
MaterialSymbols.Edit,
contentDescription = "Compose",
modifier = Modifier.size(20.dp),
)
}
}
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun FeedHeader( private fun FeedHeader(
@@ -281,6 +281,10 @@ fun ReadsScreen(
null null
} }
} }
FeedMode.CUSTOM -> {
null
}
} }
} }
@@ -74,6 +74,8 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
import com.vitorpamplona.amethyst.commons.feeds.custom.canBecomeFeed
import com.vitorpamplona.amethyst.commons.feeds.custom.toFeedDefinition
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
@@ -102,6 +104,7 @@ import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.launch
@Composable @Composable
fun SearchScreen( fun SearchScreen(
@@ -459,6 +462,29 @@ fun SearchScreen(
}, },
) )
} }
// "Save as Feed" button — visible when query has feed-able criteria
if (query.canBecomeFeed()) {
val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current
var showFeedBuilder by remember { mutableStateOf(false) }
IconButton(onClick = { showFeedBuilder = true }) {
Icon(
MaterialSymbols.Bookmark,
contentDescription = "Save as Feed",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (showFeedBuilder) {
com.vitorpamplona.amethyst.desktop.ui.deck.FeedBuilderDialog(
initial = query.toFeedDefinition(name = ""),
localCache = localCache,
onSave = { feed ->
scope.launch { feedRepo.add(feed) }
showFeedBuilder = false
},
onDismiss = { showFeedBuilder = false },
)
}
}
} }
// Search relay picker dialog // Search relay picker dialog
@@ -510,7 +536,7 @@ fun SearchScreen(
onExcludeRemoved = { state.removeExcludeTerm(it) }, onExcludeRemoved = { state.removeExcludeTerm(it) },
onLanguageChanged = { state.updateLanguage(it) }, onLanguageChanged = { state.updateLanguage(it) },
onClear = { state.clearSearch() }, onClear = { state.clearSearch() },
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp, start = sidePadding, end = sidePadding),
) )
} }
@@ -522,7 +548,10 @@ fun SearchScreen(
if (bech32Results.isNotEmpty()) { if (bech32Results.isNotEmpty()) {
// Show bech32 results (exact lookup) // Show bech32 results (exact lookup)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = sidePadding),
) {
Text( Text(
"Direct lookup", "Direct lookup",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
@@ -544,12 +573,14 @@ fun SearchScreen(
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread, onNavigateToThread = onNavigateToThread,
localCache = localCache, localCache = localCache,
modifier = Modifier.padding(horizontal = sidePadding),
) )
} else if (!debouncedQuery.isEmpty && !isSearching) { } else if (!debouncedQuery.isEmpty && !isSearching) {
Text( Text(
"No results found. Try broader terms or fewer filters.", "No results found. Try broader terms or fewer filters.",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = sidePadding),
) )
} else if (!isSearching) { } else if (!isSearching) {
// Empty state: show history + saved searches + operator hints // Empty state: show history + saved searches + operator hints
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui.auth
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -85,7 +86,11 @@ fun KeyInputField(
isError = errorMessage != null, isError = errorMessage != null,
supportingText = supportingText =
errorMessage?.let { errorMessage?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) } {
SelectionContainer {
Text(it, color = MaterialTheme.colorScheme.error)
}
}
}, },
) )
} }
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -254,11 +255,13 @@ private fun NostrConnectContent(
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
if (errorMessage != null) { if (errorMessage != null) {
SelectionContainer {
Text( Text(
errorMessage!!, errorMessage!!,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
}
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Button(onClick = { Button(onClick = {
errorMessage = null errorMessage = null
@@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -137,6 +138,7 @@ fun DeckColumnType.category(): ScreenCategory =
is DeckColumnType.Profile, is DeckColumnType.Profile,
is DeckColumnType.Thread, is DeckColumnType.Thread,
is DeckColumnType.Article, is DeckColumnType.Article,
is DeckColumnType.CustomFeed,
-> ScreenCategory.SOCIAL -> ScreenCategory.SOCIAL
} }
@@ -153,6 +155,7 @@ fun DeckColumnType.param(): String? =
is DeckColumnType.Hashtag -> tag is DeckColumnType.Hashtag -> tag
is DeckColumnType.Editor -> draftSlug is DeckColumnType.Editor -> draftSlug
is DeckColumnType.Article -> addressTag is DeckColumnType.Article -> addressTag
is DeckColumnType.CustomFeed -> feedId
else -> null else -> null
} }
@@ -180,6 +183,7 @@ val LAUNCHABLE_SCREENS: List<DeckColumnType> =
enum class AppDrawerTab { enum class AppDrawerTab {
SCREENS, SCREENS,
WORKSPACES, WORKSPACES,
FEEDS,
} }
// -- State -- // -- State --
@@ -284,6 +288,10 @@ private class AppDrawerState {
onDismiss() onDismiss()
} }
} }
AppDrawerTab.FEEDS -> {
// Feed selection handled by FeedsDrawerTab's own click handlers
}
} }
} }
} }
@@ -309,6 +317,7 @@ fun AppDrawer(
onSwitchWorkspace: (Workspace) -> Unit, onSwitchWorkspace: (Workspace) -> Unit,
onSelectScreen: (DeckColumnType) -> Unit, onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
initialTab: AppDrawerTab? = null,
) { ) {
val state = remember { AppDrawerState() } val state = remember { AppDrawerState() }
val searchFocusRequester = remember { FocusRequester() } val searchFocusRequester = remember { FocusRequester() }
@@ -317,6 +326,11 @@ fun AppDrawer(
derivedStateOf { state.filteredWorkspaces(allWorkspaces) } derivedStateOf { state.filteredWorkspaces(allWorkspaces) }
} }
// Set initial tab if specified
LaunchedEffect(initialTab) {
if (initialTab != null) state.switchTab(initialTab)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(50) delay(50)
searchFocusRequester.requestFocus() searchFocusRequester.requestFocus()
@@ -454,6 +468,22 @@ fun AppDrawer(
onDismiss = onDismiss, onDismiss = onDismiss,
) )
} }
AppDrawerTab.FEEDS -> {
FeedsDrawerTab(
onSelectFeed = { feedDef ->
onSelectScreen(
DeckColumnType.CustomFeed(
feedId = feedDef.id,
feedName = feedDef.name,
feedEmoji = feedDef.emoji,
),
)
onDismiss()
},
onDismiss = onDismiss,
)
}
} }
} }
} }
@@ -1099,6 +1129,14 @@ private fun UnifiedSearchResults(
val activeIndex by workspaceManager.activeIndex.collectAsState() val activeIndex by workspaceManager.activeIndex.collectAsState()
val allWorkspaces by workspaceManager.workspaces.collectAsState() val allWorkspaces by workspaceManager.workspaces.collectAsState()
val feedRepo = LocalFeedRepository.current
val allFeeds by feedRepo.feeds.collectAsState()
val filteredFeeds =
allFeeds.filter { feed ->
feed.name.contains(state.searchQuery, ignoreCase = true) ||
feed.emoji.contains(state.searchQuery)
}
LazyColumn(Modifier.padding(8.dp)) { LazyColumn(Modifier.padding(8.dp)) {
// Workspace results first // Workspace results first
if (filteredWs.isNotEmpty()) { if (filteredWs.isNotEmpty()) {
@@ -1147,6 +1185,46 @@ private fun UnifiedSearchResults(
} }
} }
} }
// Feed results
if (filteredFeeds.isNotEmpty()) {
stickyHeader {
Text(
"Feeds",
style = MaterialTheme.typography.titleSmall,
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
items(filteredFeeds, key = { "feed-${it.id}" }) { feed ->
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.clickable {
onSelectScreen(
DeckColumnType.CustomFeed(feed.id, feed.name, feed.emoji),
)
onDismiss()
},
tonalElevation = 0.dp,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(feed.emoji.ifEmpty { "\uD83D\uDCCB" }, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.width(12.dp))
Text(feed.name, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
// Screen results below // Screen results below
if (state.filteredScreens.isNotEmpty()) { if (state.filteredScreens.isNotEmpty()) {
stickyHeader { stickyHeader {
@@ -128,4 +128,5 @@ fun DeckColumnType.icon(): MaterialSymbol =
is DeckColumnType.Profile -> MaterialSymbols.Person is DeckColumnType.Profile -> MaterialSymbols.Person
is DeckColumnType.Thread -> MaterialSymbols.AutoMirrored.Article is DeckColumnType.Thread -> MaterialSymbols.AutoMirrored.Article
is DeckColumnType.Hashtag -> MaterialSymbols.Tag is DeckColumnType.Hashtag -> MaterialSymbols.Tag
is DeckColumnType.CustomFeed -> MaterialSymbols.Tune
} }
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
import com.vitorpamplona.amethyst.desktop.ui.CustomFeedScreen
import com.vitorpamplona.amethyst.desktop.ui.DraftsScreen import com.vitorpamplona.amethyst.desktop.ui.DraftsScreen
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
@@ -208,6 +209,7 @@ internal fun RootContent(
onNavigateToArticle: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {},
onNavigateToEditor: (String?) -> Unit = {}, onNavigateToEditor: (String?) -> Unit = {},
onNavigateToRelays: () -> Unit = {}, onNavigateToRelays: () -> Unit = {},
onOpenFeedsDrawer: () -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -226,6 +228,7 @@ internal fun RootContent(
onNavigateToThread = onNavigateToThread, onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
onNavigateToRelays = onNavigateToRelays, onNavigateToRelays = onNavigateToRelays,
onOpenFeedsDrawer = onOpenFeedsDrawer,
) )
} }
@@ -429,6 +432,18 @@ internal fun RootContent(
onNavigateToThread = onNavigateToThread, onNavigateToThread = onNavigateToThread,
) )
} }
is DeckColumnType.CustomFeed -> {
CustomFeedScreen(
feedId = columnType.feedId,
relayManager = relayManager,
localCache = localCache,
subscriptionsCoordinator = subscriptionsCoordinator,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
)
}
} }
} }
@@ -69,6 +69,12 @@ sealed class DeckColumnType {
val tag: String, val tag: String,
) : DeckColumnType() ) : DeckColumnType()
data class CustomFeed(
val feedId: String,
val feedName: String = "",
val feedEmoji: String = "",
) : DeckColumnType()
fun title(): String = fun title(): String =
when (this) { when (this) {
HomeFeed -> "Home" HomeFeed -> "Home"
@@ -89,6 +95,7 @@ sealed class DeckColumnType {
is Profile -> "Profile" is Profile -> "Profile"
is Thread -> "Thread" is Thread -> "Thread"
is Hashtag -> "#$tag" is Hashtag -> "#$tag"
is CustomFeed -> feedName.ifEmpty { "Feed" }
} }
fun typeKey(): String = fun typeKey(): String =
@@ -111,6 +118,7 @@ sealed class DeckColumnType {
is Profile -> "profile" is Profile -> "profile"
is Thread -> "thread" is Thread -> "thread"
is Hashtag -> "hashtag" is Hashtag -> "hashtag"
is CustomFeed -> "custom_feed"
} }
} }
@@ -306,6 +306,7 @@ class DeckState(
"profile" -> param?.let { DeckColumnType.Profile(it) } "profile" -> param?.let { DeckColumnType.Profile(it) }
"thread" -> param?.let { DeckColumnType.Thread(it) } "thread" -> param?.let { DeckColumnType.Thread(it) }
"hashtag" -> param?.let { DeckColumnType.Hashtag(it) } "hashtag" -> param?.let { DeckColumnType.Hashtag(it) }
"custom_feed" -> param?.let { DeckColumnType.CustomFeed(feedId = it) }
else -> null else -> null
} }
} }
@@ -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")
}
}
},
)
}
}
@@ -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)
}
}
}
}
}
@@ -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
}
@@ -87,6 +87,7 @@ fun SinglePaneLayout(
singlePaneState: SinglePaneState, singlePaneState: SinglePaneState,
pinnedNavBarState: PinnedNavBarState, pinnedNavBarState: PinnedNavBarState,
onOpenAppDrawer: () -> Unit, onOpenAppDrawer: () -> Unit,
onOpenFeedsDrawer: () -> Unit = onOpenAppDrawer,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit, onZapFeedback: (ZapFeedback) -> Unit,
@@ -112,6 +113,8 @@ fun SinglePaneLayout(
) { ) {
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState() val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
pinnedScreens.forEach { screenType -> pinnedScreens.forEach { screenType ->
// Rename "Home" to "Feeds" in the nav rail
val label = if (screenType == DeckColumnType.HomeFeed) "Feeds" else screenType.title()
NavigationRailItem( NavigationRailItem(
selected = currentColumnType == screenType && navStack.isEmpty(), selected = currentColumnType == screenType && navStack.isEmpty(),
onClick = { onClick = {
@@ -121,13 +124,13 @@ fun SinglePaneLayout(
icon = { icon = {
Icon( Icon(
screenType.icon(), screenType.icon(),
contentDescription = screenType.title(), contentDescription = label,
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
) )
}, },
label = { label = {
Text( Text(
screenType.title(), label,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -249,6 +252,7 @@ fun SinglePaneLayout(
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) }, onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) },
onOpenFeedsDrawer = onOpenFeedsDrawer,
) )
if (currentOverlay != null) { if (currentOverlay != null) {
Surface( Surface(
@@ -88,23 +88,26 @@ fun AnimatedGifImage(
val data = gifFrames val data = gifFrames
when { when {
data != null && data.frames.size > 1 -> { data != null && data.frames.size > 1 -> {
val safeFrame = currentFrame.coerceIn(0, data.frames.size - 1)
LaunchedEffect(data) { LaunchedEffect(data) {
while (isActive) { while (isActive) {
val duration = data.durations[currentFrame].coerceAtLeast(MIN_FRAME_DURATION_MS) val frameIdx = currentFrame.coerceIn(0, data.frames.size - 1)
val duration = data.durations[frameIdx].coerceAtLeast(MIN_FRAME_DURATION_MS)
delay(duration.toLong()) delay(duration.toLong())
currentFrame = (currentFrame + 1) % data.frames.size currentFrame = (currentFrame + 1) % data.frames.size
} }
} }
Image( Image(
bitmap = data.frames[currentFrame], bitmap = data.frames[safeFrame],
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier, modifier = modifier,
contentScale = contentScale, contentScale = contentScale,
) )
} }
data != null -> { data != null && data.frames.isNotEmpty() -> {
Image( Image(
bitmap = data.frames[0], bitmap = data.frames[0],
contentDescription = contentDescription, contentDescription = contentDescription,
@@ -112,6 +112,8 @@ class CoordinatorPipelineTest {
relayList: Set<NormalizedRelayUrl>, relayList: Set<NormalizedRelayUrl>,
) {} ) {}
override fun pendingPublishRelaysFor(eventId: HexKey): Set<NormalizedRelayUrl>? = null
override fun addConnectionListener(listener: RelayConnectionListener) {} override fun addConnectionListener(listener: RelayConnectionListener) {}
override fun removeConnectionListener(listener: RelayConnectionListener) {} override fun removeConnectionListener(listener: RelayConnectionListener) {}
@@ -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",
)
}
}
+89
View File
@@ -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)
}
+102
View File
@@ -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 1030k. 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 110 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: **~510× 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,
~10k20k 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 ~80150 ms;
we expect a 1.52× 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.
+19
View File
@@ -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) | 510× 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