Merge branch 'main' into claude/add-blossom-cache-support-ts8mK
This commit is contained in:
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst
|
||||
|
||||
import android.app.Application
|
||||
import com.vitorpamplona.amethyst.service.logging.Logging
|
||||
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.LogLevel
|
||||
|
||||
@@ -41,6 +42,17 @@ class Amethyst : Application() {
|
||||
Log.d("AmethystApp") { "onCreate $this" }
|
||||
instance = AppModules(this)
|
||||
|
||||
// After-background foreground recycle: when the app returns to
|
||||
// the foreground after spending more than ~5 s in the
|
||||
// background, publish a network-change event so every active
|
||||
// NestViewModel recycles its underlying QUIC session. Covers
|
||||
// the case where Android reclaims our UDP socket FD while
|
||||
// backgrounded — the connectivity callback in
|
||||
// `NestForegroundService` doesn't fire there because the
|
||||
// network itself is still up. See `AppForegroundRecycleHook`'s
|
||||
// kdoc for the threshold rationale.
|
||||
registerActivityLifecycleCallbacks(AppForegroundRecycleHook())
|
||||
|
||||
if (isDebug) {
|
||||
Logging.setup()
|
||||
// Auto-enable the Nests session-trace recorder in debug
|
||||
|
||||
@@ -72,6 +72,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
|
||||
import com.vitorpamplona.amethyst.service.safeCacheDir
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
|
||||
@@ -469,6 +471,11 @@ class AppModules(
|
||||
// subscriptions, and NotificationRelayService.
|
||||
val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope)
|
||||
|
||||
// Local store for posts the user has scheduled to publish later. Backed by a
|
||||
// single JSON file under the app's private filesDir; read by ScheduledPostWorker.
|
||||
val scheduledPostStore =
|
||||
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME))
|
||||
|
||||
// Organizes cache clearing
|
||||
val trimmingService by
|
||||
lazy {
|
||||
@@ -587,6 +594,12 @@ class AppModules(
|
||||
// starts observing LocalCache for notification-worthy events
|
||||
notificationDispatcher.start()
|
||||
|
||||
// Schedule the scheduled-posts worker (periodic + one-time catch-up).
|
||||
// Runs independently of the always-on notification setting so scheduled
|
||||
// posts still fire when always-on notifications are disabled.
|
||||
ScheduledPostWorker.schedule(appContext)
|
||||
ScheduledPostWorker.scheduleCatchUp(appContext)
|
||||
|
||||
// Watch for account login and start/stop always-on notification service
|
||||
applicationIOScope.launch {
|
||||
sessionManager.accountContent.collectLatest { state ->
|
||||
|
||||
@@ -139,7 +139,7 @@ fun debugState(context: Context) {
|
||||
Log.d(
|
||||
STATE_DUMP_TAG,
|
||||
"Observables: " +
|
||||
LocalCache.observables.size,
|
||||
LocalCache.observables.size(),
|
||||
)
|
||||
|
||||
Log.d(
|
||||
|
||||
@@ -529,6 +529,14 @@ class Account(
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun updateDisableClientTag(disable: Boolean): Boolean {
|
||||
if (settings.updateDisableClientTag(disable)) {
|
||||
sendNewAppSpecificData()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun updateFilterSpam(filterSpam: Boolean): Boolean {
|
||||
if (settings.updateFilterSpam(filterSpam)) {
|
||||
if (!settings.syncedSettings.security.filterSpamFromStrangers.value) {
|
||||
|
||||
@@ -418,6 +418,14 @@ class AccountSettings(
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDisableClientTag(disable: Boolean): Boolean =
|
||||
if (syncedSettings.security.updateDisableClientTag(disable)) {
|
||||
saveAccountSettings()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
// ---
|
||||
// list names
|
||||
|
||||
@@ -55,6 +55,7 @@ class AccountSyncedSettings(
|
||||
MutableStateFlow(internalSettings.security.filterSpamFromStrangers),
|
||||
MutableStateFlow(internalSettings.security.maxHashtagLimit),
|
||||
MutableStateFlow(internalSettings.security.sendKind0EventsToLocalRelay),
|
||||
MutableStateFlow(internalSettings.security.disableClientTag),
|
||||
)
|
||||
val videoPlayer =
|
||||
AccountVideoPlayerPreferences(
|
||||
@@ -82,6 +83,7 @@ class AccountSyncedSettings(
|
||||
security.filterSpamFromStrangers.value,
|
||||
security.maxHashtagLimit.value,
|
||||
security.sendKind0EventsToLocalRelay.value,
|
||||
security.disableClientTag.value,
|
||||
),
|
||||
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
|
||||
)
|
||||
@@ -138,6 +140,10 @@ class AccountSyncedSettings(
|
||||
security.sendKind0EventsToLocalRelay.tryEmit(syncedSettingsInternal.security.sendKind0EventsToLocalRelay)
|
||||
}
|
||||
|
||||
if (security.disableClientTag.value != syncedSettingsInternal.security.disableClientTag) {
|
||||
security.disableClientTag.tryEmit(syncedSettingsInternal.security.disableClientTag)
|
||||
}
|
||||
|
||||
val newVideoPlayerButtonItems = syncedSettingsInternal.videoPlayer.buttonItems.toImmutableList()
|
||||
if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) {
|
||||
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
|
||||
@@ -233,6 +239,7 @@ class AccountSecurityPreferences(
|
||||
var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(5),
|
||||
var sendKind0EventsToLocalRelay: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val disableClientTag: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
) {
|
||||
fun updateShowSensitiveContent(show: Boolean?): Boolean {
|
||||
if (showSensitiveContent.value != show) {
|
||||
@@ -273,4 +280,12 @@ class AccountSecurityPreferences(
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
fun updateDisableClientTag(disable: Boolean): Boolean =
|
||||
if (disable != disableClientTag.value) {
|
||||
disableClientTag.tryEmit(disable)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -147,4 +147,5 @@ class AccountSecurityPreferencesInternal(
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
val maxHashtagLimit: Int = 5,
|
||||
var sendKind0EventsToLocalRelay: Boolean = false,
|
||||
var disableClientTag: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -88,6 +88,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterIndex
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
|
||||
@@ -261,7 +262,6 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.SortedSet
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
interface ILocalCache {
|
||||
fun markAsSeen(
|
||||
@@ -290,7 +290,15 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
val deletionIndex = DeletionIndex()
|
||||
|
||||
val observables = ConcurrentHashMap<Observable, Observable>(10)
|
||||
/**
|
||||
* Inverted index over the active [Observable]s. New events fan
|
||||
* out only to observers whose filter actually narrows on a field
|
||||
* the event carries (author, kind, single-letter tag), instead
|
||||
* of waking every observer per event. Predicate-only observers
|
||||
* (no underlying [Filter]) live in the unindexed pool and still
|
||||
* see every event.
|
||||
*/
|
||||
val observables = FilterIndex<Observable>()
|
||||
|
||||
fun Filter.match(note: Note): Boolean {
|
||||
val event = note.event
|
||||
@@ -361,10 +369,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
newFilter.init()
|
||||
|
||||
observables[newFilter] = newFilter
|
||||
observables.register(filter, newFilter)
|
||||
|
||||
awaitClose {
|
||||
observables.remove(newFilter)
|
||||
observables.unregister(newFilter)
|
||||
}
|
||||
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||
|
||||
@@ -377,10 +385,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
cachedFilter.init()
|
||||
|
||||
observables.put(cachedFilter, cachedFilter)
|
||||
observables.register(filter, cachedFilter)
|
||||
|
||||
awaitClose {
|
||||
observables.remove(cachedFilter)
|
||||
observables.unregister(cachedFilter)
|
||||
}
|
||||
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||
|
||||
@@ -402,14 +410,28 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
trySend(it)
|
||||
}
|
||||
|
||||
observables.put(newFilter, newFilter)
|
||||
// Unindexed: predicate is opaque, the index can't narrow.
|
||||
// Caller is delivered every event and runs the predicate.
|
||||
observables.registerUnindexed(newFilter)
|
||||
|
||||
awaitClose {
|
||||
observables.remove(newFilter)
|
||||
observables.unregister(newFilter)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : Event> observeNewEvents(filter: Filter): Flow<T> = observeNewEvents(filter::match)
|
||||
fun <T : Event> observeNewEvents(filter: Filter): Flow<T> =
|
||||
callbackFlow {
|
||||
val newFilter =
|
||||
NewEventMatchingFilter<T>(filter::match) {
|
||||
trySend(it)
|
||||
}
|
||||
|
||||
observables.register(filter, newFilter)
|
||||
|
||||
awaitClose {
|
||||
observables.unregister(newFilter)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() }
|
||||
@@ -2514,22 +2536,21 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
private fun refreshNewNoteObservers(newNote: Note) {
|
||||
val event = newNote.event as Event
|
||||
|
||||
val observableBiConsumer =
|
||||
java.util.function.BiConsumer<Observable, Observable> { _, u ->
|
||||
u.new(event, newNote)
|
||||
}
|
||||
|
||||
observables.forEach(observableBiConsumer)
|
||||
// Index-driven fanout: only observers whose filter narrows
|
||||
// on a field this event carries (or that registered as
|
||||
// unindexed) get woken up. The match check inside each
|
||||
// observer's `new()` still enforces negative constraints.
|
||||
for (observer in observables.candidatesFor(event)) {
|
||||
observer.new(event, newNote)
|
||||
}
|
||||
live.newNote(newNote)
|
||||
}
|
||||
|
||||
private fun refreshDeletedNoteObservers(newNote: Note) {
|
||||
val observableBiConsumer =
|
||||
java.util.function.BiConsumer<Observable, Observable> { _, u ->
|
||||
u.remove(newNote)
|
||||
}
|
||||
|
||||
observables.forEach(observableBiConsumer)
|
||||
// Deletes don't have a filterable shape — every observer
|
||||
// might hold this note in its result set, so iterate them
|
||||
// all. The index doesn't help here.
|
||||
observables.forEach { it.remove(newNote) }
|
||||
live.removedNote(newNote)
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -135,7 +135,12 @@ class AccountCacheState(
|
||||
val cached = accounts.value[signer.pubKey]
|
||||
if (cached != null) return cached
|
||||
|
||||
val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME)
|
||||
val signerWithClientTag =
|
||||
NostrSignerWithClientTag(
|
||||
inner = signer,
|
||||
clientName = CLIENT_TAG_NAME,
|
||||
disabled = { accountSettings.syncedSettings.security.disableClientTag.value },
|
||||
)
|
||||
|
||||
val accountDir = File(rootFilesDir(), "accounts/${signer.pubKey}").apply { mkdirs() }
|
||||
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.nests
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestNetworkChangeBus
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Pure-state foreground/background tracker, decoupled from
|
||||
* `android.app.Activity` so the unit tests can drive it without
|
||||
* Robolectric or Mockito.
|
||||
*
|
||||
* See [AppForegroundRecycleHook] for the production motivation /
|
||||
* threshold-rationale kdoc — this class is just the testable core.
|
||||
*
|
||||
* Threading: all state-mutating methods are documented to run on the
|
||||
* Android main thread (Application lifecycle callbacks fire there);
|
||||
* tests call them serially, so no synchronisation is needed.
|
||||
*/
|
||||
class AppForegroundCounter(
|
||||
private val backgroundThresholdMs: Long = AppForegroundRecycleHook.DEFAULT_BACKGROUND_THRESHOLD_MS,
|
||||
private val publishEvent: () -> Unit = { NestNetworkChangeBus.publish() },
|
||||
private val nowMillis: () -> Long = { System.currentTimeMillis() },
|
||||
) {
|
||||
private var startedActivities = 0
|
||||
private var lastBackgroundedAtMillis: Long = -1L
|
||||
|
||||
/**
|
||||
* Count of recycle events fired since construction. Diagnostic
|
||||
* surface for tests; production code observes the side-effect via
|
||||
* [NestNetworkChangeBus] instead.
|
||||
*/
|
||||
var recyclesFired: Int = 0
|
||||
private set
|
||||
|
||||
/**
|
||||
* Increment the started-activity counter and, if this is the
|
||||
* 0 → 1 transition AND the app spent ≥ [backgroundThresholdMs]
|
||||
* in the background, fire [publishEvent]. The first
|
||||
* onActivityStarted after process start is a no-op (no prior
|
||||
* background timestamp to compare against).
|
||||
*/
|
||||
fun onActivityStarted() {
|
||||
val wasBackgrounded = startedActivities == 0
|
||||
startedActivities++
|
||||
if (!wasBackgrounded) return
|
||||
val backgroundedAt = lastBackgroundedAtMillis
|
||||
if (backgroundedAt < 0L) return
|
||||
val backgroundedFor = nowMillis() - backgroundedAt
|
||||
if (backgroundedFor < backgroundThresholdMs) {
|
||||
Log.d("AppForegroundCounter") {
|
||||
"skipping recycle on resume after only ${backgroundedFor}ms background " +
|
||||
"(threshold=${backgroundThresholdMs}ms)"
|
||||
}
|
||||
return
|
||||
}
|
||||
Log.d("AppForegroundCounter") {
|
||||
"publishing recycle event on resume after ${backgroundedFor}ms background"
|
||||
}
|
||||
recyclesFired++
|
||||
publishEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement the counter; on N → 0 transition, record the
|
||||
* background timestamp.
|
||||
*/
|
||||
fun onActivityStopped() {
|
||||
startedActivities--
|
||||
if (startedActivities <= 0) {
|
||||
startedActivities = 0
|
||||
lastBackgroundedAtMillis = nowMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Application-wide observer that publishes a
|
||||
* [NestNetworkChangeBus] event when the app returns to the foreground
|
||||
* after spending more than [backgroundThresholdMs] in the background.
|
||||
*
|
||||
* Production motivation: Android may reclaim a backgrounded app's
|
||||
* UDP-socket file descriptors as it ages out of the foreground app
|
||||
* pool (the kernel's watcher trims after roughly 30 s of no foreground
|
||||
* activity, with quite a bit of variance per OEM). When the user
|
||||
* resumes the app, the QUIC connection sitting on the now-reclaimed
|
||||
* socket has dead OS-level state, but the connection-level FSM
|
||||
* doesn't know that yet — the next `socket.send` throws, and only
|
||||
* then does the send-loop catch surface CLOSED.
|
||||
*
|
||||
* The downstream
|
||||
* [com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel]
|
||||
* already observes [NestNetworkChangeBus] for the network-handover
|
||||
* case (Wi-Fi ↔ cellular). We piggy-back on the same bus here: a
|
||||
* "long enough background" event has the same shape from the QUIC
|
||||
* driver's perspective as a network change — recycle the underlying
|
||||
* session and let the [com.vitorpamplona.nestsclient.connectReconnectingNestsListener]
|
||||
* / `connectReconnectingNestsSpeaker` orchestrators reconnect.
|
||||
*
|
||||
* Why a threshold instead of fire-on-every-resume:
|
||||
* - A short background (notification pulldown, biometric auth, lock-
|
||||
* screen glance) lasts < 1 s and the socket is still healthy. A
|
||||
* forced recycle there is a wasted ~1 s re-handshake gap of audio
|
||||
* silence — annoying to users for no benefit.
|
||||
* - A long background (call from another app, screen off > 30 s,
|
||||
* home-button-then-back) commonly leaves the socket dead. Better
|
||||
* to eat one re-handshake gap than 30 s of silence while the QUIC
|
||||
* PTO times out.
|
||||
* 5 seconds is the sweet spot: well over typical UI transitions but
|
||||
* well under any plausible socket-reclaim window.
|
||||
*/
|
||||
class AppForegroundRecycleHook(
|
||||
backgroundThresholdMs: Long = DEFAULT_BACKGROUND_THRESHOLD_MS,
|
||||
publishEvent: () -> Unit = { NestNetworkChangeBus.publish() },
|
||||
nowMillis: () -> Long = { System.currentTimeMillis() },
|
||||
) : Application.ActivityLifecycleCallbacks {
|
||||
private val counter = AppForegroundCounter(backgroundThresholdMs, publishEvent, nowMillis)
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
counter.onActivityStarted()
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
counter.onActivityStopped()
|
||||
}
|
||||
|
||||
override fun onActivityCreated(
|
||||
activity: Activity,
|
||||
savedInstanceState: Bundle?,
|
||||
) = Unit
|
||||
|
||||
override fun onActivityResumed(activity: Activity) = Unit
|
||||
|
||||
override fun onActivityPaused(activity: Activity) = Unit
|
||||
|
||||
override fun onActivitySaveInstanceState(
|
||||
activity: Activity,
|
||||
outState: Bundle,
|
||||
) = Unit
|
||||
|
||||
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Default 5 000 ms — well above the longest plausible UI
|
||||
* transition (notification pull, biometric prompt) and well
|
||||
* below the 30 s timing the Android kernel uses to reclaim
|
||||
* idle UDP sockets.
|
||||
*/
|
||||
const val DEFAULT_BACKGROUND_THRESHOLD_MS = 5_000L
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.scheduledposts
|
||||
|
||||
enum class ScheduledPostStatus {
|
||||
PENDING,
|
||||
PUBLISHING,
|
||||
SENT,
|
||||
FAILED,
|
||||
CANCELLED,
|
||||
}
|
||||
|
||||
data class ScheduledPost(
|
||||
val id: String,
|
||||
val accountPubkey: String,
|
||||
val signedEventJson: String,
|
||||
val relayUrls: List<String>,
|
||||
val extraEventsJson: List<String>,
|
||||
val publishAtSec: Long,
|
||||
val createdAtSec: Long,
|
||||
val status: ScheduledPostStatus = ScheduledPostStatus.PENDING,
|
||||
val lastAttemptAtSec: Long? = null,
|
||||
val attemptCount: Int = 0,
|
||||
val lastError: String? = null,
|
||||
// Set when the row enters a terminal state (SENT/CANCELLED). Drives retention.
|
||||
val terminatedAtSec: Long? = null,
|
||||
)
|
||||
|
||||
data class ScheduledPostFile(
|
||||
val version: Int = 1,
|
||||
val posts: List<ScheduledPost> = emptyList(),
|
||||
)
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.scheduledposts
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.extractContentPreview
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* Posts user-visible system notifications when a scheduled post completes
|
||||
* (sent or failed). Without this, a worker firing in background offers zero
|
||||
* diagnostic to the user — see the silent-publish bug the ack-aware worker
|
||||
* now guards against; the notification closes the loop.
|
||||
*/
|
||||
object ScheduledPostNotifier {
|
||||
// @Volatile so the channel reference is visible across the WorkManager IO
|
||||
// thread pool — two workers firing in the same window can race on
|
||||
// ensureChannel. createNotificationChannel itself is idempotent.
|
||||
@Volatile
|
||||
private var channel: NotificationChannel? = null
|
||||
private const val SCHEDULED_POST_NOT_ID_BASE = 0x70000
|
||||
|
||||
fun notifySent(
|
||||
context: Context,
|
||||
post: ScheduledPost,
|
||||
) {
|
||||
ensureChannel(context)
|
||||
post(
|
||||
context = context,
|
||||
notId = idFor(post.id),
|
||||
title = stringRes(context, R.string.scheduled_posts_notification_sent_title),
|
||||
body = extractContentPreview(post, 120),
|
||||
)
|
||||
}
|
||||
|
||||
fun notifyFailed(
|
||||
context: Context,
|
||||
post: ScheduledPost,
|
||||
error: String?,
|
||||
) {
|
||||
ensureChannel(context)
|
||||
val snippet = extractContentPreview(post, 120)
|
||||
val body =
|
||||
if (error.isNullOrBlank()) {
|
||||
snippet
|
||||
} else {
|
||||
"$snippet\n${stringRes(context, R.string.scheduled_posts_error_prefix, error)}"
|
||||
}
|
||||
post(
|
||||
context = context,
|
||||
notId = idFor(post.id),
|
||||
title = stringRes(context, R.string.scheduled_posts_notification_failed_title),
|
||||
body = body,
|
||||
)
|
||||
}
|
||||
|
||||
private fun post(
|
||||
context: Context,
|
||||
notId: Int,
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
val channelId = stringRes(context, R.string.app_notification_scheduled_posts_channel_id)
|
||||
val tapIntent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
val tapPendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
notId,
|
||||
tapIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val notificationManager = NotificationManagerCompat.from(context)
|
||||
// POST_NOTIFICATIONS is runtime-granted on Android 13+; bail out
|
||||
// explicitly so lint doesn't flag the notify() call and so a denied
|
||||
// user doesn't see a misleading no-op log.
|
||||
if (!notificationManager.areNotificationsEnabled()) return
|
||||
val builder =
|
||||
NotificationCompat
|
||||
.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setContentIntent(tapPendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
try {
|
||||
notificationManager.notify(notId, builder.build())
|
||||
} catch (_: SecurityException) {
|
||||
// Race: permission revoked between the check above and notify().
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (channel != null) return
|
||||
channel =
|
||||
NotificationChannel(
|
||||
stringRes(context, R.string.app_notification_scheduled_posts_channel_id),
|
||||
stringRes(context, R.string.app_notification_scheduled_posts_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = stringRes(context, R.string.app_notification_scheduled_posts_channel_description)
|
||||
}
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.createNotificationChannel(channel!!)
|
||||
}
|
||||
|
||||
// Distinct id per post so multiple completions don't collapse onto one row.
|
||||
private fun idFor(postId: String): Int = SCHEDULED_POST_NOT_ID_BASE xor postId.hashCode()
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.scheduledposts
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
|
||||
class ScheduledPostStore(
|
||||
private val storageFile: File,
|
||||
private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 },
|
||||
) {
|
||||
private val mapper =
|
||||
jacksonObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var loaded = false
|
||||
private var posts: MutableList<ScheduledPost> = mutableListOf()
|
||||
|
||||
private val _flow = MutableStateFlow<List<ScheduledPost>>(emptyList())
|
||||
|
||||
/** Live snapshot of all stored posts. Updated on every mutation. */
|
||||
val flow: StateFlow<List<ScheduledPost>> = _flow.asStateFlow()
|
||||
|
||||
suspend fun add(post: ScheduledPost) =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
posts.add(post)
|
||||
persist()
|
||||
}
|
||||
|
||||
suspend fun cancel(id: String): Boolean =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
val now = nowSec()
|
||||
val updated = mutate(id) { it.copy(status = ScheduledPostStatus.CANCELLED, terminatedAtSec = now) }
|
||||
if (updated) persist()
|
||||
updated
|
||||
}
|
||||
|
||||
suspend fun list(): List<ScheduledPost> =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
posts.toList()
|
||||
}
|
||||
|
||||
suspend fun listFor(accountPubkey: String): List<ScheduledPost> =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
posts.filter { it.accountPubkey == accountPubkey }
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim posts due at or before [nowSec]: each PENDING post with
|
||||
* publishAtSec <= now is flipped to PUBLISHING and returned. A concurrent
|
||||
* claim from another worker will see those posts as PUBLISHING and skip them.
|
||||
*/
|
||||
suspend fun claimDuePosts(nowSec: Long): List<ScheduledPost> =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
val dueIds =
|
||||
posts
|
||||
.filter { it.status == ScheduledPostStatus.PENDING && it.publishAtSec <= nowSec }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
if (dueIds.isEmpty()) return@withLock emptyList()
|
||||
dueIds.forEach { id ->
|
||||
mutate(id) {
|
||||
it.copy(
|
||||
status = ScheduledPostStatus.PUBLISHING,
|
||||
lastAttemptAtSec = nowSec,
|
||||
attemptCount = it.attemptCount + 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
persist()
|
||||
posts.filter { it.id in dueIds }
|
||||
}
|
||||
|
||||
suspend fun markSent(id: String) =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
val now = nowSec()
|
||||
if (mutate(id) { it.copy(status = ScheduledPostStatus.SENT, lastError = null, terminatedAtSec = now) }) persist()
|
||||
}
|
||||
|
||||
suspend fun markFailed(
|
||||
id: String,
|
||||
error: String?,
|
||||
) = mutex.withLock {
|
||||
ensureLoaded()
|
||||
if (mutate(id) { it.copy(status = ScheduledPostStatus.FAILED, lastError = error) }) persist()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a post to publish immediately by setting its publishAtSec to [nowSec]
|
||||
* and resetting status to PENDING. Handles two cases with one method:
|
||||
* - PENDING (future-scheduled): user wants to push it out now
|
||||
* - FAILED: user wants to retry
|
||||
* Caller is expected to enqueue ScheduledPostWorker.scheduleCatchUp() afterwards
|
||||
* so the worker picks it up promptly.
|
||||
*/
|
||||
suspend fun publishNow(
|
||||
id: String,
|
||||
nowSec: Long = System.currentTimeMillis() / 1000,
|
||||
): Boolean =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
val updated =
|
||||
mutate(id) {
|
||||
it.copy(
|
||||
publishAtSec = nowSec,
|
||||
status = ScheduledPostStatus.PENDING,
|
||||
lastError = null,
|
||||
terminatedAtSec = null,
|
||||
)
|
||||
}
|
||||
if (updated) persist()
|
||||
updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every row owned by [accountPubkey]. Used when the user deletes
|
||||
* an account — the account's signed events should not linger. Returns the
|
||||
* number of rows removed; persists once if any rows matched.
|
||||
*/
|
||||
suspend fun removeForAccount(accountPubkey: String): Int =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
val before = posts.size
|
||||
val removed = posts.removeAll { it.accountPubkey == accountPubkey }
|
||||
val count = before - posts.size
|
||||
if (removed) persist()
|
||||
count
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert a PUBLISHING claim back to PENDING (e.g. when the account is not
|
||||
* loaded at fire time, so we should retry on the next cycle rather than
|
||||
* marking the post failed permanently).
|
||||
*/
|
||||
suspend fun releaseClaim(id: String) =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
val changed =
|
||||
mutate(id) {
|
||||
if (it.status == ScheduledPostStatus.PUBLISHING) {
|
||||
it.copy(status = ScheduledPostStatus.PENDING)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
if (changed) persist()
|
||||
}
|
||||
|
||||
private fun mutate(
|
||||
id: String,
|
||||
transform: (ScheduledPost) -> ScheduledPost,
|
||||
): Boolean {
|
||||
val idx = posts.indexOfFirst { it.id == id }
|
||||
if (idx < 0) return false
|
||||
val before = posts[idx]
|
||||
val after = transform(before)
|
||||
if (after == before) return false
|
||||
posts[idx] = after
|
||||
return true
|
||||
}
|
||||
|
||||
private fun ensureLoaded() {
|
||||
if (loaded) return
|
||||
posts =
|
||||
try {
|
||||
if (storageFile.exists() && storageFile.length() > 0) {
|
||||
mapper.readValue<ScheduledPostFile>(storageFile).posts.toMutableList()
|
||||
} else {
|
||||
mutableListOf()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load scheduled posts from $storageFile", e)
|
||||
mutableListOf()
|
||||
}
|
||||
loaded = true
|
||||
val purged = purgeStale(nowSec())
|
||||
_flow.value = posts.toList()
|
||||
if (purged) persist()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop SENT rows older than [SENT_RETENTION_SEC] and CANCELLED rows older
|
||||
* than [CANCELLED_RETENTION_SEC]. Returns true if any row was removed.
|
||||
* FAILED rows are kept indefinitely so the user can still see and retry them;
|
||||
* PENDING / PUBLISHING rows are never purged.
|
||||
*/
|
||||
private fun purgeStale(now: Long): Boolean {
|
||||
val before = posts.size
|
||||
posts.removeAll { post ->
|
||||
// terminatedAtSec is the post-PR field. Legacy rows lack it; fall back to
|
||||
// lastAttemptAtSec (set at SENT) and finally createdAtSec. The fallback
|
||||
// can purge old CANCELLED rows up to 30d earlier than intended on first
|
||||
// run after upgrade — self-healing once new rows are written.
|
||||
val age = now - (post.terminatedAtSec ?: post.lastAttemptAtSec ?: post.createdAtSec)
|
||||
when (post.status) {
|
||||
ScheduledPostStatus.SENT -> age > SENT_RETENTION_SEC
|
||||
ScheduledPostStatus.CANCELLED -> age > CANCELLED_RETENTION_SEC
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
return posts.size < before
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the snapshot to disk *while holding the data mutex*. This is a
|
||||
* deliberate tradeoff: moving the write outside the lock would require a
|
||||
* separate write-mutex (or a sequence number) to preserve write ordering
|
||||
* across concurrent mutations — otherwise an older snapshot can clobber a
|
||||
* newer one if the OS schedules the second write to finish first. For a
|
||||
* file that's a few KB and a single-process owner with infrequent writes,
|
||||
* holding the mutex across the rename is the simpler and correct choice.
|
||||
* Revisit if the store ever grows past a hundred rows or starts seeing
|
||||
* concurrent multi-writer pressure.
|
||||
*/
|
||||
private fun persist() {
|
||||
val snapshot = posts.toList()
|
||||
_flow.value = snapshot
|
||||
storageFile.parentFile?.mkdirs()
|
||||
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
|
||||
try {
|
||||
mapper.writeValue(tmp, ScheduledPostFile(version = 1, posts = snapshot))
|
||||
if (!tmp.renameTo(storageFile)) {
|
||||
if (!storageFile.delete()) {
|
||||
Log.w(TAG) { "Failed to delete existing $storageFile before rename retry" }
|
||||
}
|
||||
if (!tmp.renameTo(storageFile)) {
|
||||
Log.e(TAG, "Failed to rename $tmp to $storageFile")
|
||||
if (!tmp.delete()) {
|
||||
Log.w(TAG) { "Failed to clean up temp file $tmp after rename failure" }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to persist scheduled posts to $storageFile", e)
|
||||
if (!tmp.delete()) {
|
||||
Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ScheduledPostStore"
|
||||
const val FILE_NAME = "scheduled_posts.json"
|
||||
private const val SENT_RETENTION_SEC = 7L * 24 * 3600
|
||||
private const val CANCELLED_RETENTION_SEC = 30L * 24 * 3600
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.scheduledposts
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Scans the scheduled-post store and publishes posts whose publish time has arrived.
|
||||
*
|
||||
* - schedule(context): periodic, every 15 min (WorkManager minimum).
|
||||
* - scheduleCatchUp(context): one-time, on app start, to flush posts that came
|
||||
* due while the device was off or while WorkManager
|
||||
* was deferred by Doze.
|
||||
*/
|
||||
class ScheduledPostWorker(
|
||||
appContext: Context,
|
||||
workerParams: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, workerParams) {
|
||||
init {
|
||||
// Logs every worker instantiation. Without this, "doWork never ran" looks
|
||||
// identical to "constructor never invoked" — and the latter means the OS
|
||||
// (Doze, battery-opt, JobScheduler quotas) never woke us at all.
|
||||
Log.d(TAG) { "Worker instantiated (runAttempt=${workerParams.runAttemptCount}, tags=${workerParams.tags})" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ScheduledPostWorker"
|
||||
private const val WORK_NAME = "scheduled_post_worker"
|
||||
private const val WORK_NAME_CATCH_UP = "scheduled_post_worker_catch_up"
|
||||
private const val OK_TIMEOUT_SEC = 30L
|
||||
private const val OK_POLL_MS = 500L
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val constraints =
|
||||
Constraints
|
||||
.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val request =
|
||||
PeriodicWorkRequestBuilder<ScheduledPostWorker>(15, TimeUnit.MINUTES)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
Log.d(TAG) {
|
||||
"schedule(): enqueueUniquePeriodicWork($WORK_NAME, 15 MIN, KEEP) — KEEP policy preserves any existing schedule"
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleCatchUp(context: Context) {
|
||||
val constraints =
|
||||
Constraints
|
||||
.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val request =
|
||||
OneTimeWorkRequestBuilder<ScheduledPostWorker>()
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
WORK_NAME_CATCH_UP,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
Log.d(TAG) { "scheduleCatchUp(): enqueueUniqueWork($WORK_NAME_CATCH_UP, KEEP)" }
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_CATCH_UP)
|
||||
Log.d(TAG) { "cancel(): cancelled both periodic and catch-up workers" }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
Log.d(TAG) { "doWork() ENTER nowSec=$nowSec runAttempt=$runAttemptCount tags=$tags" }
|
||||
|
||||
return try {
|
||||
val appModules = Amethyst.instance
|
||||
val store = appModules.scheduledPostStore
|
||||
|
||||
val all = store.list()
|
||||
val pending = all.count { it.status == ScheduledPostStatus.PENDING }
|
||||
Log.d(TAG) { "doWork() store has ${all.size} total, $pending PENDING" }
|
||||
|
||||
val claimed = store.claimDuePosts(nowSec)
|
||||
if (claimed.isEmpty()) {
|
||||
Log.d(TAG) { "doWork() EXIT no posts due" }
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
Log.d(TAG) { "doWork() claimed ${claimed.size} due post(s)" }
|
||||
|
||||
for (post in claimed) {
|
||||
val ageSec = nowSec - post.publishAtSec
|
||||
Log.d(TAG) {
|
||||
"Publishing post id=${post.id} publishAtSec=${post.publishAtSec} ageSec=$ageSec relays=${post.relayUrls.size}"
|
||||
}
|
||||
val account = appModules.accountsCache.accounts.value[post.accountPubkey]
|
||||
if (account == null) {
|
||||
Log.w(TAG, "Account ${post.accountPubkey} not loaded; releasing ${post.id} for retry")
|
||||
store.releaseClaim(post.id)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
val event = Event.fromJson(post.signedEventJson)
|
||||
val relays = post.relayUrls.map { NormalizedRelayUrl(it) }.toSet()
|
||||
val extras = post.extraEventsJson.map { Event.fromJson(it) }
|
||||
|
||||
Log.d(TAG) { "client.publish(${post.id}) starting on ${relays.size} relay(s)" }
|
||||
account.client.publish(event, relays)
|
||||
account.consumePostEvent(event, relays, extras)
|
||||
|
||||
val acks = waitForOk(account.client, event.id, relays.size)
|
||||
if (acks > 0) {
|
||||
store.markSent(post.id)
|
||||
ScheduledPostNotifier.notifySent(applicationContext, post)
|
||||
Log.d(TAG) { "client.publish(${post.id}) acked by $acks/${relays.size}; marked SENT" }
|
||||
} else {
|
||||
val msg = "no relay acknowledged within ${OK_TIMEOUT_SEC}s"
|
||||
store.markFailed(post.id, msg)
|
||||
ScheduledPostNotifier.notifyFailed(applicationContext, post, msg)
|
||||
Log.w(TAG, "client.publish(${post.id}) failed: $msg")
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to publish scheduled post ${post.id}", e)
|
||||
store.markFailed(post.id, e.message)
|
||||
ScheduledPostNotifier.notifyFailed(applicationContext, post, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG) { "doWork() EXIT success" }
|
||||
Result.success()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "doWork() unexpected failure", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls [INostrClient.pendingPublishRelaysFor] until at least one relay sends
|
||||
* an OK ack or [OK_TIMEOUT_SEC] elapses. Returns the number of relays that
|
||||
* acked. The publish call itself is fire-and-forget; without this wait the
|
||||
* worker can be torn down before the websocket finishes delivery.
|
||||
*/
|
||||
private suspend fun waitForOk(
|
||||
client: INostrClient,
|
||||
eventId: String,
|
||||
totalRelays: Int,
|
||||
): Int {
|
||||
val deadline = System.currentTimeMillis() + OK_TIMEOUT_SEC * 1000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
// null means the outbox dropped the entry — every relay either OK'd
|
||||
// or hit the discard cap (replaced/pow/deleted/invalid). Treat as full ack.
|
||||
val pending = client.pendingPublishRelaysFor(eventId) ?: return totalRelays
|
||||
val acked = totalRelays - pending.size
|
||||
if (acked > 0) return acked
|
||||
delay(OK_POLL_MS)
|
||||
}
|
||||
val pending = client.pendingPublishRelaysFor(eventId) ?: return totalRelays
|
||||
return totalRelays - pending.size
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui
|
||||
import android.content.Context
|
||||
import android.util.LruCache
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
@@ -131,6 +132,15 @@ fun stringRes(
|
||||
)
|
||||
}
|
||||
|
||||
// Plural resolver for non-composable scope (e.g. onClick callbacks). Not cached:
|
||||
// the resolved string varies by `count` quantity and the resourceCache is keyed by id.
|
||||
fun pluralStringRes(
|
||||
ctx: Context,
|
||||
@PluralsRes id: Int,
|
||||
count: Int,
|
||||
vararg formatArgs: Any?,
|
||||
): String = ctx.resources.getQuantityString(id, count, *formatArgs)
|
||||
|
||||
/**
|
||||
* This cache can only be used if the painter is the only copy on the screen
|
||||
* It should store a separate Painter for each size. It's safe to just assume
|
||||
|
||||
@@ -81,6 +81,7 @@ fun SwipeToDeleteContainer(
|
||||
fun SwipeToDeleteWithConfirmation(
|
||||
modifier: Modifier = Modifier,
|
||||
onDelete: () -> Unit,
|
||||
confirmLabelRes: Int = R.string.request_deletion,
|
||||
content: @Composable (RowScope.() -> Unit),
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -103,6 +104,7 @@ fun SwipeToDeleteWithConfirmation(
|
||||
onCancel = {
|
||||
scope.launch { dismissState.reset() }
|
||||
},
|
||||
confirmLabelRes = confirmLabelRes,
|
||||
)
|
||||
},
|
||||
enableDismissFromEndToStart = true,
|
||||
@@ -156,6 +158,7 @@ fun ConfirmDeleteBackground(
|
||||
dismissState: SwipeToDismissBoxState,
|
||||
onConfirmDelete: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
confirmLabelRes: Int = R.string.request_deletion,
|
||||
) {
|
||||
val settled = dismissState.currentValue == Settled && dismissState.targetValue == Settled
|
||||
|
||||
@@ -163,7 +166,7 @@ fun ConfirmDeleteBackground(
|
||||
if (!settled) {
|
||||
Color(0xFFFF1744)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
Color.Transparent
|
||||
},
|
||||
label = "ConfirmDeleteBackground",
|
||||
)
|
||||
@@ -195,12 +198,12 @@ fun ConfirmDeleteBackground(
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.Delete,
|
||||
contentDescription = stringRes(id = R.string.request_deletion),
|
||||
contentDescription = stringRes(id = confirmLabelRes),
|
||||
tint = Color.White,
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
text = stringRes(id = R.string.request_deletion),
|
||||
text = stringRes(id = confirmLabelRes),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
@@ -151,6 +151,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip43.RelayMembersSc
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
|
||||
@@ -304,6 +305,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ScheduledPosts> { ScheduledPostsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
|
||||
|
||||
+9
@@ -44,6 +44,7 @@ enum class NavBarItem {
|
||||
BOOKMARKS,
|
||||
WEB_BOOKMARKS,
|
||||
DRAFTS,
|
||||
SCHEDULED_POSTS,
|
||||
INTEREST_SETS,
|
||||
EMOJI_PACKS,
|
||||
WALLET,
|
||||
@@ -142,6 +143,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.Drafts,
|
||||
resolveRoute = { Route.Drafts },
|
||||
),
|
||||
NavBarItem.SCHEDULED_POSTS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.SCHEDULED_POSTS,
|
||||
labelRes = R.string.scheduled_posts,
|
||||
icon = MaterialSymbols.Schedule,
|
||||
resolveRoute = { Route.ScheduledPosts },
|
||||
),
|
||||
NavBarItem.INTEREST_SETS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.INTEREST_SETS,
|
||||
@@ -291,6 +299,7 @@ val DrawerYouItems: List<NavBarItem> =
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
|
||||
+60
-1
@@ -46,11 +46,14 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
@@ -58,9 +61,11 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
|
||||
@@ -261,16 +266,70 @@ private fun LogoutButton(
|
||||
accountSessionManager: AccountSessionManager,
|
||||
) {
|
||||
var logoutDialog by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
if (logoutDialog) {
|
||||
val accountHex = remember(acc) { decodePublicKeyAsHexOrNull(acc.npub) }
|
||||
val allPosts by Amethyst.instance.scheduledPostStore.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
val unpublishedCount by remember(accountHex) {
|
||||
derivedStateOf {
|
||||
if (accountHex == null) {
|
||||
0
|
||||
} else {
|
||||
allPosts.count {
|
||||
it.accountPubkey == accountHex &&
|
||||
(
|
||||
it.status == ScheduledPostStatus.PENDING ||
|
||||
it.status == ScheduledPostStatus.PUBLISHING ||
|
||||
it.status == ScheduledPostStatus.FAILED
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
title = { Text(text = stringRes(R.string.log_out)) },
|
||||
text = { Text(text = stringRes(R.string.are_you_sure_you_want_to_log_out)) },
|
||||
text = {
|
||||
if (unpublishedCount > 0) {
|
||||
Text(
|
||||
text =
|
||||
pluralStringResource(
|
||||
id = R.plurals.scheduled_posts_logout_warning,
|
||||
count = unpublishedCount,
|
||||
unpublishedCount,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Text(text = stringRes(R.string.are_you_sure_you_want_to_log_out))
|
||||
}
|
||||
},
|
||||
onDismissRequest = { logoutDialog = false },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
// Snapshot the count *now* so the user-facing Toast matches what
|
||||
// the dialog displayed, even if the store mutates between this
|
||||
// tap and the cleanup completing.
|
||||
val confirmedCount = unpublishedCount
|
||||
logoutDialog = false
|
||||
// Guard against a malformed npub: skip the Toast so we don't
|
||||
// claim "Logged out" when logOff's coroutine bails early.
|
||||
if (accountHex == null) return@TextButton
|
||||
accountSessionManager.logOff(acc)
|
||||
val toastMessage =
|
||||
if (confirmedCount > 0) {
|
||||
pluralStringRes(
|
||||
context,
|
||||
R.plurals.scheduled_posts_logout_toast,
|
||||
confirmedCount,
|
||||
confirmedCount,
|
||||
)
|
||||
} else {
|
||||
stringRes(context, R.string.scheduled_posts_logout_toast_zero)
|
||||
}
|
||||
android.widget.Toast
|
||||
.makeText(context, toastMessage, android.widget.Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
},
|
||||
) {
|
||||
Text(text = stringRes(R.string.log_out))
|
||||
|
||||
+78
-1
@@ -49,6 +49,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -84,6 +85,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
@@ -97,6 +99,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
|
||||
@@ -604,12 +607,86 @@ fun CatalogSection(
|
||||
ids.forEach { id ->
|
||||
NavBarCatalog[id]?.let { def ->
|
||||
val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground
|
||||
CatalogNavigationRow(def, tint, accountViewModel, nav)
|
||||
if (def.id == NavBarItem.SCHEDULED_POSTS) {
|
||||
ScheduledPostsNavigationRow(def, tint, accountViewModel, nav)
|
||||
} else {
|
||||
CatalogNavigationRow(def, tint, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduledPostsNavigationRow(
|
||||
def: NavBarItemDef,
|
||||
tint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val accountHex = accountViewModel.account.signer.pubKey
|
||||
val allPosts by Amethyst.instance.scheduledPostStore.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
val pendingCount by remember(accountHex) {
|
||||
derivedStateOf {
|
||||
allPosts.count {
|
||||
it.accountPubkey == accountHex &&
|
||||
(
|
||||
it.status == ScheduledPostStatus.PENDING ||
|
||||
it.status == ScheduledPostStatus.PUBLISHING ||
|
||||
it.status == ScheduledPostStatus.FAILED
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconRowWithBadge(
|
||||
title = def.labelRes,
|
||||
icon = def.icon,
|
||||
tint = tint,
|
||||
badgeCount = pendingCount,
|
||||
onClick = {
|
||||
nav.closeDrawer()
|
||||
nav.nav { def.resolveRoute(accountViewModel) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IconRowWithBadge(
|
||||
title: Int,
|
||||
icon: MaterialSymbol,
|
||||
tint: Color,
|
||||
badgeCount: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val titleStr = stringRes(title)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
onClickLabel = titleStr,
|
||||
).padding(vertical = 15.dp, horizontal = 25.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = icon,
|
||||
contentDescription = titleStr,
|
||||
modifier = Size22ModifierWith4Padding,
|
||||
tint = tint,
|
||||
)
|
||||
Text(
|
||||
modifier = IconRowTextModifier,
|
||||
text = titleStr,
|
||||
fontSize = Font18SP,
|
||||
)
|
||||
if (badgeCount > 0) {
|
||||
Badge { Text(badgeCount.toString()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CatalogNavigationRow(
|
||||
def: NavBarItemDef,
|
||||
|
||||
@@ -205,6 +205,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object Drafts : Route()
|
||||
|
||||
@Serializable object ScheduledPosts : Route()
|
||||
|
||||
@Serializable object AllSettings : Route()
|
||||
|
||||
@Serializable object AccountBackup : Route()
|
||||
|
||||
@@ -82,6 +82,7 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.Role
|
||||
@@ -128,6 +129,7 @@ import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo
|
||||
@@ -346,6 +348,8 @@ fun PayReaction(
|
||||
val authorPubkey = baseNote.author?.pubkeyHex ?: return
|
||||
val address = remember(authorPubkey) { PaymentTargetsEvent.createAddress(authorPubkey) }
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
val targets = remember(note) { (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() }
|
||||
@@ -395,6 +399,16 @@ fun PayReaction(
|
||||
}
|
||||
},
|
||||
)
|
||||
M3ActionRow(
|
||||
icon = MaterialSymbols.ContentCopy,
|
||||
text = stringRes(R.string.copy_to_clipboard),
|
||||
onClick = {
|
||||
expanded = false
|
||||
scope.launch {
|
||||
clipboardManager.setText(target.authority)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.scheduling
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@Composable
|
||||
fun ScheduleAtButton(
|
||||
isActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Schedule,
|
||||
contentDescription =
|
||||
stringRes(
|
||||
if (isActive) R.string.schedule_post_button_remove else R.string.schedule_post_button_add,
|
||||
),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.scheduling
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.SelectableDates
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.TimePickerDialog
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
|
||||
/**
|
||||
* Two-stage date + time picker for scheduling a post for future publication.
|
||||
*
|
||||
* The selected time is rounded up to the next quarter-hour to set realistic
|
||||
* expectations: the periodic worker that fires scheduled posts runs at
|
||||
* WorkManager's minimum 15-min interval, so per-minute precision is misleading.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScheduleAtPicker(
|
||||
scheduledForSec: Long,
|
||||
onChanged: (Long) -> Unit,
|
||||
alwaysOnEnabled: Boolean = true,
|
||||
hasMultipleAccounts: Boolean = false,
|
||||
) {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
var showTimePicker by remember { mutableStateOf(false) }
|
||||
|
||||
val currentTime =
|
||||
Instant
|
||||
.ofEpochMilli(scheduledForSec * 1000)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDateTime()
|
||||
|
||||
val datePickerState =
|
||||
rememberDatePickerState(
|
||||
initialSelectedDateMillis = scheduledForSec * 1000,
|
||||
yearRange = currentTime.year..2050,
|
||||
selectableDates =
|
||||
object : SelectableDates {
|
||||
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= System.currentTimeMillis() - 86_400_000
|
||||
},
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val timePickerState =
|
||||
rememberTimePickerState(
|
||||
initialHour = currentTime.hour,
|
||||
initialMinute = currentTime.minute,
|
||||
is24Hour = DateFormat.is24HourFormat(context),
|
||||
)
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 5.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Timer,
|
||||
contentDescription = stringRes(R.string.schedule_post_time_label),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.schedule_post),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.schedule_post_helper),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
)
|
||||
|
||||
if (!alwaysOnEnabled) {
|
||||
ReliabilityWarning(hasMultipleAccounts = hasMultipleAccounts)
|
||||
}
|
||||
|
||||
PresetChips(onPick = onChanged)
|
||||
|
||||
OutlinedCard(
|
||||
onClick = { showDatePicker = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(MaterialSymbols.Timer, contentDescription = stringRes(R.string.schedule_post_pick_time))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
if (scheduledForSec < TimeUtils.oneMinuteFromNow()) {
|
||||
Text(stringRes(R.string.schedule_post_pick_label), style = MaterialTheme.typography.bodyLarge)
|
||||
} else {
|
||||
Text(
|
||||
text = stringRes(R.string.schedule_post_publishes_in, timeAheadNoDot(scheduledForSec, context)),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDatePicker) {
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showDatePicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showDatePicker = false
|
||||
showTimePicker = true
|
||||
}) { Text(stringRes(R.string.next)) }
|
||||
},
|
||||
) {
|
||||
DatePicker(state = datePickerState)
|
||||
}
|
||||
}
|
||||
|
||||
if (showTimePicker) {
|
||||
TimePickerDialog(
|
||||
title = { Text(stringRes(R.string.schedule_post_picker_time_title)) },
|
||||
onDismissRequest = { showTimePicker = false },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val datetimeLocalTimeZone =
|
||||
datePickerState.selectedDateMillis?.let { localDayAtZeroHourMillis ->
|
||||
(localDayAtZeroHourMillis / 1000) +
|
||||
(timePickerState.hour * TimeUtils.ONE_HOUR) +
|
||||
(timePickerState.minute * TimeUtils.ONE_MINUTE)
|
||||
} ?: TimeUtils.oneDayAhead()
|
||||
|
||||
val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now())
|
||||
val rawSec = datetimeLocalTimeZone - offset.totalSeconds
|
||||
|
||||
onChanged(roundUpToNextQuarterHour(rawSec))
|
||||
showTimePicker = false
|
||||
},
|
||||
) { Text(stringRes(R.string.confirm)) }
|
||||
},
|
||||
) {
|
||||
TimePicker(state = timePickerState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReliabilityWarning(hasMultipleAccounts: Boolean) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Timer,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.schedule_post_warning_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
stringRes(
|
||||
if (hasMultipleAccounts) {
|
||||
R.string.schedule_post_warning_multi
|
||||
} else {
|
||||
R.string.schedule_post_warning_single
|
||||
},
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PresetChips(onPick: (Long) -> Unit) {
|
||||
val scroll = rememberScrollState()
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(scroll)
|
||||
.padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
AssistChip(
|
||||
onClick = { onPick(roundUpToNextQuarterHour(presetInOneHour())) },
|
||||
label = { Text(stringRes(R.string.schedule_post_preset_in_one_hour)) },
|
||||
)
|
||||
AssistChip(
|
||||
onClick = { onPick(roundUpToNextQuarterHour(presetTomorrowMorning())) },
|
||||
label = { Text(stringRes(R.string.schedule_post_preset_tomorrow_morning)) },
|
||||
)
|
||||
AssistChip(
|
||||
onClick = { onPick(roundUpToNextQuarterHour(presetNextMondayMorning())) },
|
||||
label = { Text(stringRes(R.string.schedule_post_preset_next_monday_morning)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun presetInOneHour(): Long = (System.currentTimeMillis() / 1000) + 3600
|
||||
|
||||
private fun presetTomorrowMorning(): Long {
|
||||
val zone = ZoneId.systemDefault()
|
||||
val tomorrow9am = LocalDate.now(zone).plusDays(1).atTime(LocalTime.of(9, 0))
|
||||
return tomorrow9am.atZone(zone).toEpochSecond()
|
||||
}
|
||||
|
||||
private fun presetNextMondayMorning(): Long {
|
||||
val zone = ZoneId.systemDefault()
|
||||
// Always step at least one day forward — if today is Monday, return next Monday.
|
||||
val target =
|
||||
LocalDate
|
||||
.now(zone)
|
||||
.plusDays(1)
|
||||
.with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY))
|
||||
.atTime(LocalTime.of(9, 0))
|
||||
return target.atZone(zone).toEpochSecond()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds [epochSec] up to the next 15-minute boundary. If already on a boundary,
|
||||
* returns the boundary itself. Edge case: if rounding yields a moment in the past
|
||||
* (rare — only if the user picks the exact current quarter-hour), bump forward
|
||||
* one slot.
|
||||
*/
|
||||
internal fun roundUpToNextQuarterHour(epochSec: Long): Long {
|
||||
val quarter = 15 * 60L
|
||||
val rounded = ((epochSec + quarter - 1) / quarter) * quarter
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
return if (rounded <= nowSec) rounded + quarter else rounded
|
||||
}
|
||||
+16
-5
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -29,14 +30,14 @@ import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
|
||||
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
@@ -140,8 +141,11 @@ class AccountSessionManager(
|
||||
externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" },
|
||||
)
|
||||
} else if (key.startsWith("nsec")) {
|
||||
val privHex =
|
||||
decodePrivateKeyAsHexOrNull(key)
|
||||
?: throw Exception("Invalid nsec key")
|
||||
AccountSettings(
|
||||
keyPair = KeyPair(privKey = key.bechToBytes()),
|
||||
keyPair = KeyPair(privKey = privHex.hexToByteArray()),
|
||||
transientAccount = transientAccount,
|
||||
)
|
||||
} else if (key.contains(" ") && Nip06().isValidMnemonic(key)) {
|
||||
@@ -356,6 +360,11 @@ class AccountSessionManager(
|
||||
|
||||
fun logOff(accountInfo: AccountInfo) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val hex = decodePublicKeyAsHexOrNull(accountInfo.npub)
|
||||
if (hex == null) {
|
||||
Log.e("Logoff", "Cannot decode npub for account being logged off; aborting cleanup")
|
||||
return@launch
|
||||
}
|
||||
if (accountInfo.npub == currentAccountNPub()) {
|
||||
// Drop the Nest bridge ref before tearing down the
|
||||
// current account so the audio-room activity can't
|
||||
@@ -364,12 +373,14 @@ class AccountSessionManager(
|
||||
.clear()
|
||||
// log off and relogin with the 0 account
|
||||
localPreferences.deleteAccount(accountInfo)
|
||||
accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey())
|
||||
accountsCache.removeAccount(hex)
|
||||
Amethyst.instance.scheduledPostStore.removeForAccount(hex)
|
||||
loginWithDefaultAccount()
|
||||
} else {
|
||||
// delete without switching logins
|
||||
localPreferences.deleteAccount(accountInfo)
|
||||
accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey())
|
||||
accountsCache.removeAccount(hex)
|
||||
Amethyst.instance.scheduledPostStore.removeForAccount(hex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -1152,6 +1152,8 @@ class AccountViewModel(
|
||||
|
||||
fun updateWarnReports(warnReports: Boolean) = launchSigner { account.updateWarnReports(warnReports) }
|
||||
|
||||
fun updateDisableClientTag(disable: Boolean) = launchSigner { account.updateDisableClientTag(disable) }
|
||||
|
||||
fun updateFilterSpam(filterSpam: Boolean) =
|
||||
launchSigner {
|
||||
if (account.updateFilterSpam(filterSpam)) {
|
||||
|
||||
+1
@@ -114,6 +114,7 @@ private fun PreloadFor(
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
|
||||
+86
-2
@@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -48,11 +49,16 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -61,6 +67,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
@@ -79,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
@@ -97,6 +105,9 @@ import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtPicker
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.roundUpToNextQuarterHour
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
|
||||
@@ -404,6 +415,26 @@ private fun NewPostScreenBody(
|
||||
}
|
||||
}
|
||||
|
||||
val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService
|
||||
.collectAsStateWithLifecycle()
|
||||
val savedAccounts by com.vitorpamplona.amethyst.LocalPreferences
|
||||
.accountsFlow()
|
||||
.collectAsStateWithLifecycle()
|
||||
val hasMultipleAccounts = (savedAccounts?.size ?: 0) > 1
|
||||
postViewModel.scheduledForSec?.let { current ->
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
|
||||
) {
|
||||
ScheduleAtPicker(
|
||||
scheduledForSec = current,
|
||||
onChanged = { postViewModel.scheduledForSec = it },
|
||||
alwaysOnEnabled = alwaysOnEnabled,
|
||||
hasMultipleAccounts = hasMultipleAccounts,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsToAddGeoHash) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
@@ -574,12 +605,63 @@ private fun NewPostScreenBody(
|
||||
onDismiss = postViewModel::dismissAiResult,
|
||||
)
|
||||
|
||||
BottomRowActions(postViewModel)
|
||||
val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService
|
||||
.collectAsStateWithLifecycle()
|
||||
var showAlwaysOnPrompt by remember { mutableStateOf(false) }
|
||||
|
||||
BottomRowActions(
|
||||
postViewModel = postViewModel,
|
||||
onScheduleClicked = {
|
||||
if (postViewModel.scheduledForSec != null) {
|
||||
postViewModel.scheduledForSec = null
|
||||
} else if (!alwaysOnEnabled) {
|
||||
showAlwaysOnPrompt = true
|
||||
} else {
|
||||
postViewModel.scheduledForSec =
|
||||
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (showAlwaysOnPrompt) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showAlwaysOnPrompt = false },
|
||||
title = { Text(stringRes(R.string.schedule_post_always_on_prompt_title)) },
|
||||
text = { Text(stringRes(R.string.schedule_post_always_on_prompt_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showAlwaysOnPrompt = false
|
||||
nav.nav(Route.Settings)
|
||||
}) {
|
||||
Text(stringRes(R.string.schedule_post_always_on_prompt_open_settings))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
showAlwaysOnPrompt = false
|
||||
postViewModel.scheduledForSec =
|
||||
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
|
||||
}) {
|
||||
Text(stringRes(R.string.schedule_post_always_on_prompt_continue))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
|
||||
private fun BottomRowActions(
|
||||
postViewModel: ShortNotePostViewModel,
|
||||
onScheduleClicked: () -> Unit = {
|
||||
postViewModel.scheduledForSec =
|
||||
if (postViewModel.scheduledForSec != null) {
|
||||
null
|
||||
} else {
|
||||
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
|
||||
}
|
||||
},
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
Row(
|
||||
modifier =
|
||||
@@ -656,6 +738,8 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
|
||||
postViewModel.toggleExpirationDate()
|
||||
}
|
||||
|
||||
ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked)
|
||||
|
||||
AddGeoHashButton(postViewModel.wantsToAddGeoHash) {
|
||||
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
|
||||
}
|
||||
|
||||
+39
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.service.ai.WritingAssistantStatus
|
||||
import com.vitorpamplona.amethyst.service.ai.WritingResult
|
||||
import com.vitorpamplona.amethyst.service.ai.WritingTone
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
@@ -305,6 +306,10 @@ open class ShortNotePostViewModel :
|
||||
// Anonymous Reply
|
||||
var wantsAnonymousPost by mutableStateOf(false)
|
||||
|
||||
// Scheduled posting: epoch seconds (UTC) when the post should be published.
|
||||
// Null = post immediately on Send (existing behavior).
|
||||
var scheduledForSec by mutableStateOf<Long?>(null)
|
||||
|
||||
// AI Writing Help for testing
|
||||
private val useMockAi = false
|
||||
|
||||
@@ -829,8 +834,41 @@ open class ShortNotePostViewModel :
|
||||
|
||||
val version = draftTag.current
|
||||
val anonymous = wantsAnonymousPost
|
||||
val scheduledFor = scheduledForSec
|
||||
cancel()
|
||||
|
||||
if (scheduledFor != null && !anonymous) {
|
||||
// Re-stamp the template with created_at = scheduled time so the post,
|
||||
// when published later, shows up at its scheduled moment in feeds
|
||||
// rather than as N minutes/hours old (= compose time).
|
||||
val rescheduledTemplate =
|
||||
EventTemplate<Event>(
|
||||
createdAt = scheduledFor,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
)
|
||||
val (event, relays, extras) = accountViewModel.account.createPostEvent(rescheduledTemplate, extraNotesToBroadcast)
|
||||
Amethyst.instance.scheduledPostStore.add(
|
||||
ScheduledPost(
|
||||
id =
|
||||
java.util.UUID
|
||||
.randomUUID()
|
||||
.toString(),
|
||||
accountPubkey = event.pubKey,
|
||||
signedEventJson = event.toJson(),
|
||||
relayUrls = relays.map { it.url },
|
||||
extraEventsJson = extras.map { it.toJson() },
|
||||
publishAtSec = scheduledFor,
|
||||
createdAtSec = System.currentTimeMillis() / 1000,
|
||||
),
|
||||
)
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(version)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (anonymous) {
|
||||
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
|
||||
} else if (accountViewModel.settings.useTrackedBroadcasts()) {
|
||||
@@ -1197,6 +1235,7 @@ open class ShortNotePostViewModel :
|
||||
wantsExclusiveGeoPost = false
|
||||
wantsSecretEmoji = false
|
||||
wantsAnonymousPost = false
|
||||
scheduledForSec = null
|
||||
|
||||
forwardZapTo.value = SplitBuilder()
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
|
||||
+1
-1
@@ -329,7 +329,7 @@ private fun OnStageIdleControls(
|
||||
TalkButton(
|
||||
onClick = {
|
||||
if (context.hasMicPermission()) {
|
||||
viewModel.startBroadcast(speakerPubkeyHex, initialMuted = true)
|
||||
viewModel.startBroadcast(speakerPubkeyHex, initialMuted = false)
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
|
||||
+16
@@ -28,8 +28,10 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
@@ -40,6 +42,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -47,6 +50,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun PaymentButton(
|
||||
@@ -72,6 +76,8 @@ fun PaymentButton(
|
||||
@Composable
|
||||
fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
@@ -111,6 +117,16 @@ fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
|
||||
}
|
||||
},
|
||||
)
|
||||
M3ActionRow(
|
||||
icon = MaterialSymbols.ContentCopy,
|
||||
text = stringRes(R.string.copy_to_clipboard),
|
||||
onClick = {
|
||||
expanded = false
|
||||
scope.launch {
|
||||
clipboardManager.setText(target.authority)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
@file:Suppress("ktlint:standard:filename")
|
||||
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
private const val TAG = "ScheduledPostMedia"
|
||||
|
||||
sealed class MediaUrl {
|
||||
abstract val url: String
|
||||
|
||||
data class Image(
|
||||
override val url: String,
|
||||
) : MediaUrl()
|
||||
|
||||
data class Video(
|
||||
override val url: String,
|
||||
) : MediaUrl()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the signed-event JSON, run [block], and return its result. Returns null on
|
||||
* any non-cancellation parse failure and logs a warning. The shared shape for
|
||||
* [extractFirstMediaUrl], [extractEventId], and [extractContentPreview].
|
||||
*/
|
||||
private inline fun <T : Any> parseSignedEvent(
|
||||
post: ScheduledPost,
|
||||
caller: String,
|
||||
block: (Event) -> T?,
|
||||
): T? =
|
||||
try {
|
||||
block(Event.fromJson(post.signedEventJson))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG) { "$caller: failed to parse signed event for ${post.id}: ${e.message}" }
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first media URL referenced via an imeta tag in the post's signed event,
|
||||
* or null when there is no imeta tag or the JSON cannot be parsed.
|
||||
*
|
||||
* Mime starting with `video/` -> [MediaUrl.Video]; anything else (including absent mime)
|
||||
* -> [MediaUrl.Image]. Lenient on purpose: most posts attach images and don't always
|
||||
* carry an `m` property.
|
||||
*/
|
||||
fun extractFirstMediaUrl(post: ScheduledPost): MediaUrl? =
|
||||
parseSignedEvent(post, "extractFirstMediaUrl") { event ->
|
||||
val firstImeta = event.imetas().firstOrNull() ?: return@parseSignedEvent null
|
||||
val mime = firstImeta.properties["m"]?.firstOrNull().orEmpty()
|
||||
when {
|
||||
mime.startsWith("video/") -> MediaUrl.Video(firstImeta.url)
|
||||
else -> MediaUrl.Image(firstImeta.url)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the signed event's id, or null when the JSON cannot be parsed.
|
||||
*/
|
||||
fun extractEventId(post: ScheduledPost): String? = parseSignedEvent(post, "extractEventId") { it.id }
|
||||
|
||||
/**
|
||||
* Returns the first [maxLen] chars of the signed event's content (trimmed) or
|
||||
* an empty string when the JSON cannot be parsed.
|
||||
*/
|
||||
fun extractContentPreview(
|
||||
post: ScheduledPost,
|
||||
maxLen: Int,
|
||||
): String =
|
||||
parseSignedEvent(post, "extractContentPreview") { event ->
|
||||
event.content.take(maxLen).trim()
|
||||
} ?: ""
|
||||
|
||||
@Composable
|
||||
fun MediaThumbnail(media: MediaUrl) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = media.url,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(64.dp),
|
||||
)
|
||||
if (media is MediaUrl.Video) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+639
@@ -0,0 +1,639 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
|
||||
import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ScheduledPostsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val accountPubkey = accountViewModel.account.signer.pubKey
|
||||
val viewModel: ScheduledPostsViewModel =
|
||||
viewModel(key = "scheduled-posts-$accountPubkey") {
|
||||
ScheduledPostsViewModel.create(accountPubkey)
|
||||
}
|
||||
val groups by viewModel.groupedPosts.collectAsStateWithLifecycle()
|
||||
val totalActive by viewModel.totalActive.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
var expandedId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Tick once per minute so relative-time strings ("publishes in 2h 13m")
|
||||
// refresh on a long-open list instead of being frozen at first composition.
|
||||
val nowSec by produceState(initialValue = System.currentTimeMillis() / 1000) {
|
||||
while (true) {
|
||||
delay(60_000)
|
||||
value = System.currentTimeMillis() / 1000
|
||||
}
|
||||
}
|
||||
|
||||
val dueSoonCount by remember {
|
||||
derivedStateOf {
|
||||
groups
|
||||
.flatMap { it.posts }
|
||||
.count { it.publishAtSec - nowSec in 1..URGENT_THRESHOLD_SEC }
|
||||
}
|
||||
}
|
||||
|
||||
val barHeight = if (totalActive > 0) 64.dp else TopBarSize
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ShorterTopAppBar(
|
||||
expandedHeight = barHeight,
|
||||
title = {
|
||||
Column(modifier = Modifier.semantics(mergeDescendants = true) {}) {
|
||||
Text(stringRes(R.string.scheduled_posts))
|
||||
if (totalActive > 0) {
|
||||
val queuedText =
|
||||
pluralStringResource(
|
||||
id = R.plurals.scheduled_posts_subtitle_queued,
|
||||
count = totalActive,
|
||||
totalActive,
|
||||
)
|
||||
val dueText =
|
||||
if (dueSoonCount > 0) {
|
||||
pluralStringResource(
|
||||
id = R.plurals.scheduled_posts_subtitle_due_suffix,
|
||||
count = dueSoonCount,
|
||||
dueSoonCount,
|
||||
)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
Text(
|
||||
text = queuedText + dueText,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (groups.isEmpty()) {
|
||||
EmptyState(onCompose = { nav.nav(Route.NewShortNote()) }, modifier = Modifier.padding(padding))
|
||||
} else {
|
||||
val today = remember(nowSec) { LocalDate.now(ZoneId.systemDefault()) }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
groups.forEach { group ->
|
||||
stickyHeader(key = group.day) {
|
||||
DayHeader(group.day, today, context)
|
||||
}
|
||||
items(group.posts, key = { it.id }) { post ->
|
||||
val isExpanded = expandedId == post.id
|
||||
val rowAlpha by animateFloatAsState(
|
||||
targetValue = if (expandedId != null && !isExpanded) 0.65f else 1f,
|
||||
label = "row-alpha",
|
||||
)
|
||||
Box(modifier = Modifier.alpha(rowAlpha)) {
|
||||
if (isExpanded) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(),
|
||||
) {
|
||||
ScheduledPostCardCollapsed(
|
||||
post = post,
|
||||
nowSec = nowSec,
|
||||
onClick = { expandedId = null },
|
||||
)
|
||||
ScheduledPostCardExpandedPanel(
|
||||
post = post,
|
||||
onPublishNow = {
|
||||
viewModel.publishNow(post.id)
|
||||
ScheduledPostWorker.scheduleCatchUp(context)
|
||||
expandedId = null
|
||||
},
|
||||
onDelete = {
|
||||
viewModel.cancel(post.id)
|
||||
expandedId = null
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SwipeToDeleteWithConfirmation(
|
||||
modifier = Modifier.fillMaxWidth().animateContentSize(),
|
||||
onDelete = { viewModel.cancel(post.id) },
|
||||
confirmLabelRes = R.string.quick_action_delete,
|
||||
) {
|
||||
ScheduledPostCardCollapsed(
|
||||
post = post,
|
||||
nowSec = nowSec,
|
||||
onClick = { expandedId = post.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val URGENT_THRESHOLD_SEC = 3600L
|
||||
|
||||
// Tailwind amber-400. Material 3 has no amber slot, but the publishing-pulse
|
||||
// reads better than `tertiary` against a violet card.
|
||||
private val PublishingAmber = Color(0xFFFBBF24)
|
||||
|
||||
@Composable
|
||||
private fun Modifier.urgentEdge(enabled: Boolean): Modifier {
|
||||
if (!enabled) return this
|
||||
val gradientStart = MaterialTheme.colorScheme.primary
|
||||
val gradientEnd = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||
val brush =
|
||||
remember(gradientStart, gradientEnd) {
|
||||
Brush.verticalGradient(listOf(gradientStart, gradientEnd))
|
||||
}
|
||||
return this.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
brush = brush,
|
||||
topLeft = Offset(0f, 8.dp.toPx()),
|
||||
size = Size(3.dp.toPx(), size.height - 16.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduledPostCardCollapsed(
|
||||
post: ScheduledPost,
|
||||
nowSec: Long,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val preview = remember(post.id) { extractContentPreview(post, 200) }
|
||||
val media = remember(post.id) { extractFirstMediaUrl(post) }
|
||||
val relayCountText =
|
||||
pluralStringResource(
|
||||
id = R.plurals.scheduled_posts_relay_count,
|
||||
count = post.relayUrls.size,
|
||||
post.relayUrls.size,
|
||||
)
|
||||
|
||||
val isFailed = post.status == ScheduledPostStatus.FAILED
|
||||
// Composite the tint over surface so the card is opaque — otherwise the
|
||||
// SwipeToDismissBox background ("Delete" / "Cancel") bleeds through at rest.
|
||||
val surface = MaterialTheme.colorScheme.surface
|
||||
val containerColor =
|
||||
if (isFailed) {
|
||||
MaterialTheme.colorScheme.error
|
||||
.copy(alpha = 0.06f)
|
||||
.compositeOver(surface)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
.copy(alpha = 0.06f)
|
||||
.compositeOver(surface)
|
||||
}
|
||||
val borderColor =
|
||||
if (isFailed) {
|
||||
MaterialTheme.colorScheme.error.copy(alpha = 0.22f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
|
||||
}
|
||||
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.urgentEdge(!isFailed && post.publishAtSec - nowSec in 1..URGENT_THRESHOLD_SEC),
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||
border = BorderStroke(1.dp, borderColor),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
StatusPill(post.status)
|
||||
Text(
|
||||
text = formatAtTime(post.publishAtSec, nowSec, context),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (media != null) {
|
||||
MediaThumbnail(media)
|
||||
}
|
||||
Text(
|
||||
text = preview,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = relayCountText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduledPostCardExpandedPanel(
|
||||
post: ScheduledPost,
|
||||
onPublishNow: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val eventId = remember(post.id) { extractEventId(post) }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f))
|
||||
|
||||
Column {
|
||||
SectionLabel(stringRes(R.string.relays))
|
||||
post.relayUrls.forEach { url ->
|
||||
Text(
|
||||
text = url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f),
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (eventId != null) {
|
||||
Column {
|
||||
SectionLabel(stringRes(R.string.quick_action_copy_note_id))
|
||||
Text(
|
||||
text = eventId,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = {
|
||||
scope.launch { clipboardManager.setText(eventId) }
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
stringRes(context, R.string.scheduled_posts_event_id_copied),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val err = post.lastError
|
||||
if (post.status == ScheduledPostStatus.FAILED && !err.isNullOrBlank()) {
|
||||
Text(
|
||||
text = stringRes(R.string.scheduled_posts_error_prefix, err),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = onPublishNow,
|
||||
enabled = post.status != ScheduledPostStatus.PUBLISHING,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
val labelRes =
|
||||
when (post.status) {
|
||||
ScheduledPostStatus.FAILED -> R.string.retry
|
||||
ScheduledPostStatus.PUBLISHING -> R.string.scheduled_posts_status_publishing
|
||||
else -> R.string.scheduled_posts_action_send_now
|
||||
}
|
||||
Text(stringRes(labelRes))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text(stringRes(R.string.quick_action_delete))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionLabel(text: String) {
|
||||
val locale = LocalConfiguration.current.locales[0]
|
||||
Text(
|
||||
text = text.uppercase(locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayHeader(
|
||||
day: LocalDate,
|
||||
today: LocalDate,
|
||||
context: android.content.Context,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = formatDayHeader(day, today, context),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusPill(status: ScheduledPostStatus) {
|
||||
val (labelRes, color, pulse) =
|
||||
when (status) {
|
||||
ScheduledPostStatus.PENDING -> {
|
||||
Triple(R.string.scheduled_posts_status_pending, MaterialTheme.colorScheme.primary, false)
|
||||
}
|
||||
|
||||
ScheduledPostStatus.PUBLISHING -> {
|
||||
Triple(R.string.scheduled_posts_status_publishing, PublishingAmber, true)
|
||||
}
|
||||
|
||||
ScheduledPostStatus.FAILED -> {
|
||||
Triple(R.string.scheduled_posts_status_failed, MaterialTheme.colorScheme.error, false)
|
||||
}
|
||||
|
||||
ScheduledPostStatus.SENT -> {
|
||||
Triple(R.string.scheduled_posts_status_sent, MaterialTheme.colorScheme.tertiary, false)
|
||||
}
|
||||
|
||||
ScheduledPostStatus.CANCELLED -> {
|
||||
Triple(R.string.scheduled_posts_status_cancelled, MaterialTheme.colorScheme.onSurfaceVariant, false)
|
||||
}
|
||||
}
|
||||
val dotAlpha =
|
||||
if (pulse) {
|
||||
val transition = rememberInfiniteTransition(label = "publishing-pulse")
|
||||
transition
|
||||
.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0.35f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1400, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "publishing-pulse-alpha",
|
||||
).value
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = color.copy(alpha = 0.18f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color.copy(alpha = dotAlpha)),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringRes(labelRes),
|
||||
color = color,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(
|
||||
onCompose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Schedule,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringRes(R.string.scheduled_posts_empty_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.scheduled_posts_empty_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(onClick = onCompose) {
|
||||
Text(stringRes(R.string.new_post))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val shortTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
private val fullDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
|
||||
|
||||
private fun formatAtTime(
|
||||
publishAtSec: Long,
|
||||
nowSec: Long,
|
||||
context: android.content.Context,
|
||||
): String {
|
||||
val absolute =
|
||||
Instant
|
||||
.ofEpochSecond(publishAtSec)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalTime()
|
||||
.format(shortTimeFormatter)
|
||||
return if (publishAtSec > nowSec) {
|
||||
stringRes(context, R.string.scheduled_posts_at_time, absolute, timeAheadNoDot(publishAtSec, context))
|
||||
} else {
|
||||
stringRes(context, R.string.scheduled_posts_at_time_past, absolute, timeAgoNoDot(publishAtSec, context).trim())
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDayHeader(
|
||||
day: LocalDate,
|
||||
today: LocalDate,
|
||||
context: android.content.Context,
|
||||
): String =
|
||||
when (day) {
|
||||
today -> stringRes(context, R.string.today)
|
||||
today.plusDays(1) -> stringRes(context, R.string.scheduled_posts_day_tomorrow)
|
||||
else -> day.format(fullDateFormatter)
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
/** A day-bucket of posts for the scheduled-posts list screen. */
|
||||
data class ScheduledPostDayGroup(
|
||||
val day: LocalDate,
|
||||
val posts: List<ScheduledPost>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Drives the "Scheduled posts" screen for a single account. Filters the global
|
||||
* ScheduledPostStore down to posts owned by [accountPubkey] that are still
|
||||
* "in progress" — PENDING, PUBLISHING, or FAILED. SENT and CANCELLED rows are
|
||||
* hidden from the list (they're "done").
|
||||
*/
|
||||
class ScheduledPostsViewModel(
|
||||
private val store: ScheduledPostStore,
|
||||
private val accountPubkey: String,
|
||||
) : ViewModel() {
|
||||
private val activeStatuses =
|
||||
setOf(
|
||||
ScheduledPostStatus.PENDING,
|
||||
ScheduledPostStatus.PUBLISHING,
|
||||
ScheduledPostStatus.FAILED,
|
||||
)
|
||||
|
||||
/** Posts for [accountPubkey] in active statuses, grouped by local-day, sorted ascending. */
|
||||
val groupedPosts: StateFlow<List<ScheduledPostDayGroup>> =
|
||||
store.flow
|
||||
.map { all ->
|
||||
val zone = ZoneId.systemDefault()
|
||||
all
|
||||
.filter { it.accountPubkey == accountPubkey && it.status in activeStatuses }
|
||||
.sortedBy { it.publishAtSec }
|
||||
.groupBy { Instant.ofEpochSecond(it.publishAtSec).atZone(zone).toLocalDate() }
|
||||
.map { (day, list) -> ScheduledPostDayGroup(day, list) }
|
||||
.sortedBy { it.day }
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
val totalActive: StateFlow<Int> =
|
||||
groupedPosts
|
||||
.map { groups -> groups.sumOf { it.posts.size } }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = 0,
|
||||
)
|
||||
|
||||
fun cancel(id: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
store.cancel(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun publishNow(id: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
store.publishNow(id)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(accountPubkey: String): ScheduledPostsViewModel =
|
||||
ScheduledPostsViewModel(
|
||||
store = Amethyst.instance.scheduledPostStore,
|
||||
accountPubkey = accountPubkey,
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
@@ -371,6 +371,21 @@ private fun HeaderOptions(accountViewModel: AccountViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
SettingsRow(
|
||||
R.string.disable_client_tag_title,
|
||||
R.string.disable_client_tag_explainer,
|
||||
) {
|
||||
var disableClientTag by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.disableClientTag.value) }
|
||||
|
||||
Switch(
|
||||
checked = disableClientTag,
|
||||
onCheckedChange = {
|
||||
disableClientTag = it
|
||||
accountViewModel.updateDisableClientTag(disableClientTag)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SettingsRow(
|
||||
R.string.show_sensitive_content_title,
|
||||
R.string.show_sensitive_content_explainer,
|
||||
|
||||
@@ -396,6 +396,75 @@
|
||||
<string name="migrate_bookmarks_button">Přesunout vše do nových záložek</string>
|
||||
<string name="migrate_bookmarks_success">Záložky úspěšně přesunuty</string>
|
||||
<string name="drafts">Koncepty</string>
|
||||
<string name="scheduled_posts">Naplánované příspěvky</string>
|
||||
<string name="schedule_post">Naplánovat</string>
|
||||
<string name="schedule_post_time_label">Naplánovaný čas</string>
|
||||
<string name="schedule_post_helper">Příspěvky se publikují přibližně do 15 minut od naplánovaného času.</string>
|
||||
<string name="schedule_post_pick_time">Vyberte naplánovaný čas</string>
|
||||
<string name="schedule_post_pick_label">Naplánovat na…</string>
|
||||
<string name="schedule_post_publishes_in">Publikováno za %1$s</string>
|
||||
<string name="schedule_post_was_due">Mělo být před %1$s</string>
|
||||
<string name="schedule_post_picker_time_title">Čas</string>
|
||||
<string name="schedule_post_button_add">Naplánovat příspěvek</string>
|
||||
<string name="schedule_post_button_remove">Zrušit plánování</string>
|
||||
<string name="schedule_post_warning_title">Trvalá oznámení vypnuta</string>
|
||||
<string name="schedule_post_warning_single">Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI.</string>
|
||||
<string name="schedule_post_warning_multi">Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Naplánované příspěvky jiných účtů se nespustí, dokud je aktivní tento účet. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI.</string>
|
||||
<string name="schedule_post_preset_in_one_hour">Za 1 hodinu</string>
|
||||
<string name="schedule_post_preset_tomorrow_morning">Zítra v 9:00</string>
|
||||
<string name="schedule_post_preset_next_monday_morning">Příští pondělí v 9:00</string>
|
||||
<string name="schedule_post_always_on_prompt_title">Povolit trvalá oznámení?</string>
|
||||
<string name="schedule_post_always_on_prompt_message">Naplánované příspěvky se spolehlivě publikují pouze tehdy, když jsou povolena trvalá oznámení. Jinak se nemusí spustit, dokud aplikaci znovu neotevřete.</string>
|
||||
<string name="schedule_post_always_on_prompt_open_settings">Otevřít nastavení</string>
|
||||
<string name="schedule_post_always_on_prompt_continue">Přesto pokračovat</string>
|
||||
<string name="scheduled_posts_at_time">%1$s · za %2$s</string>
|
||||
<string name="scheduled_posts_at_time_past">%1$s · před %2$s</string>
|
||||
<string name="scheduled_posts_day_tomorrow">Zítra</string>
|
||||
<string name="scheduled_posts_logout_toast_zero">Odhlášeno</string>
|
||||
<plurals name="scheduled_posts_logout_toast">
|
||||
<item quantity="one">Odhlášeno · smazán %d naplánovaný příspěvek</item>
|
||||
<item quantity="few">Odhlášeno · smazány %d naplánované příspěvky</item>
|
||||
<item quantity="many">Odhlášeno · smazáno %d naplánovaných příspěvků</item>
|
||||
<item quantity="other">Odhlášeno · smazáno %d naplánovaných příspěvků</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_notification_sent_title">Naplánovaný příspěvek publikován</string>
|
||||
<string name="scheduled_posts_notification_failed_title">Naplánovaný příspěvek selhal</string>
|
||||
<string name="app_notification_scheduled_posts_channel_name">Naplánované příspěvky</string>
|
||||
<string name="app_notification_scheduled_posts_channel_description">Oznámení, když je naplánovaný příspěvek publikován nebo selže při publikaci.</string>
|
||||
<string name="scheduled_posts_action_send_now">Odeslat hned</string>
|
||||
<string name="scheduled_posts_empty_title">Žádné naplánované příspěvky</string>
|
||||
<string name="scheduled_posts_empty_hint">Napište poznámku a klepněte na ikonu hodin pro naplánování na později.</string>
|
||||
<string name="scheduled_posts_error_prefix">Chyba: %1$s</string>
|
||||
<string name="scheduled_posts_status_pending">Naplánováno</string>
|
||||
<string name="scheduled_posts_status_publishing">Odesílá se…</string>
|
||||
<string name="scheduled_posts_status_failed">Selhalo</string>
|
||||
<string name="scheduled_posts_status_sent">Odesláno</string>
|
||||
<string name="scheduled_posts_status_cancelled">Zrušeno</string>
|
||||
<plurals name="scheduled_posts_logout_warning">
|
||||
<item quantity="one">Máte %d naplánovaný příspěvek, který ještě nebyl publikován. Odhlášením bude trvale smazán.</item>
|
||||
<item quantity="few">Máte %d naplánované příspěvky, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</item>
|
||||
<item quantity="many">Máte %d naplánovaných příspěvků, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</item>
|
||||
<item quantity="other">Máte %d naplánovaných příspěvků, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_queued">
|
||||
<item quantity="one">%d ve frontě</item>
|
||||
<item quantity="few">%d ve frontě</item>
|
||||
<item quantity="many">%d ve frontě</item>
|
||||
<item quantity="other">%d ve frontě</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||
<item quantity="one"> · %d do 1 h</item>
|
||||
<item quantity="few"> · %d do 1 h</item>
|
||||
<item quantity="many"> · %d do 1 h</item>
|
||||
<item quantity="other"> · %d do 1 h</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_relay_count">
|
||||
<item quantity="one">na %d relay</item>
|
||||
<item quantity="few">na %d relaye</item>
|
||||
<item quantity="many">na %d relayů</item>
|
||||
<item quantity="other">na %d relayů</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_event_id_copied">ID příspěvku zkopírováno</string>
|
||||
<string name="polls">Ankety</string>
|
||||
<string name="open_polls">Otevřené</string>
|
||||
<string name="closed_polls">Uzavřené</string>
|
||||
|
||||
@@ -402,6 +402,64 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="migrate_bookmarks_button">Alle in neue Lesezeichen verschieben</string>
|
||||
<string name="migrate_bookmarks_success">Lesezeichen erfolgreich migriert</string>
|
||||
<string name="drafts">Entwürfe</string>
|
||||
<string name="scheduled_posts">Geplante Beiträge</string>
|
||||
<string name="schedule_post">Planen</string>
|
||||
<string name="schedule_post_time_label">Geplante Zeit</string>
|
||||
<string name="schedule_post_helper">Beiträge werden innerhalb von ~15 Minuten nach der geplanten Zeit veröffentlicht.</string>
|
||||
<string name="schedule_post_pick_time">Geplante Zeit auswählen</string>
|
||||
<string name="schedule_post_pick_label">Planen für…</string>
|
||||
<string name="schedule_post_publishes_in">Veröffentlicht in %1$s</string>
|
||||
<string name="schedule_post_was_due">Fällig vor %1$s</string>
|
||||
<string name="schedule_post_picker_time_title">Zeit</string>
|
||||
<string name="schedule_post_button_add">Beitrag planen</string>
|
||||
<string name="schedule_post_button_remove">Planung abbrechen</string>
|
||||
<string name="schedule_post_warning_title">Dauerbenachrichtigungen deaktiviert</string>
|
||||
<string name="schedule_post_warning_single">Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App das nächste Mal öffnest. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung.</string>
|
||||
<string name="schedule_post_warning_multi">Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App wieder öffnest. Geplante Beiträge anderer Konten werden nicht ausgelöst, solange dieses Konto aktiv ist. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung.</string>
|
||||
<string name="schedule_post_preset_in_one_hour">In 1 Stunde</string>
|
||||
<string name="schedule_post_preset_tomorrow_morning">Morgen 9 Uhr</string>
|
||||
<string name="schedule_post_preset_next_monday_morning">Nächsten Montag 9 Uhr</string>
|
||||
<string name="schedule_post_always_on_prompt_title">Dauerbenachrichtigungen aktivieren?</string>
|
||||
<string name="schedule_post_always_on_prompt_message">Geplante Beiträge werden zuverlässig nur veröffentlicht, wenn Dauerbenachrichtigungen aktiviert sind. Andernfalls werden sie möglicherweise erst beim nächsten Öffnen der App ausgelöst.</string>
|
||||
<string name="schedule_post_always_on_prompt_open_settings">Einstellungen öffnen</string>
|
||||
<string name="schedule_post_always_on_prompt_continue">Trotzdem fortfahren</string>
|
||||
<string name="scheduled_posts_at_time_past">%1$s · vor %2$s</string>
|
||||
<string name="scheduled_posts_day_tomorrow">Morgen</string>
|
||||
<string name="scheduled_posts_logout_toast_zero">Abgemeldet</string>
|
||||
<plurals name="scheduled_posts_logout_toast">
|
||||
<item quantity="one">Abgemeldet · %d geplanter Beitrag gelöscht</item>
|
||||
<item quantity="other">Abgemeldet · %d geplante Beiträge gelöscht</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_notification_sent_title">Geplanter Beitrag veröffentlicht</string>
|
||||
<string name="scheduled_posts_notification_failed_title">Geplanter Beitrag fehlgeschlagen</string>
|
||||
<string name="app_notification_scheduled_posts_channel_name">Geplante Beiträge</string>
|
||||
<string name="app_notification_scheduled_posts_channel_description">Benachrichtigungen, wenn ein geplanter Beitrag veröffentlicht wird oder die Veröffentlichung fehlschlägt.</string>
|
||||
<string name="scheduled_posts_action_send_now">Jetzt senden</string>
|
||||
<string name="scheduled_posts_empty_title">Keine geplanten Beiträge</string>
|
||||
<string name="scheduled_posts_empty_hint">Verfasse eine Notiz und tippe auf das Uhr-Symbol, um sie für später zu planen.</string>
|
||||
<string name="scheduled_posts_error_prefix">Fehler: %1$s</string>
|
||||
<string name="scheduled_posts_status_pending">Geplant</string>
|
||||
<string name="scheduled_posts_status_publishing">Wird gesendet…</string>
|
||||
<string name="scheduled_posts_status_failed">Fehlgeschlagen</string>
|
||||
<string name="scheduled_posts_status_sent">Gesendet</string>
|
||||
<string name="scheduled_posts_status_cancelled">Abgebrochen</string>
|
||||
<plurals name="scheduled_posts_logout_warning">
|
||||
<item quantity="one">Du hast %d geplanten Beitrag, der noch nicht veröffentlicht wurde. Beim Abmelden wird er dauerhaft gelöscht.</item>
|
||||
<item quantity="other">Du hast %d geplante Beiträge, die noch nicht veröffentlicht wurden. Beim Abmelden werden sie dauerhaft gelöscht.</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_queued">
|
||||
<item quantity="one">%d in Warteschlange</item>
|
||||
<item quantity="other">%d in Warteschlange</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||
<item quantity="one"> · %d fällig in 1 Std.</item>
|
||||
<item quantity="other"> · %d fällig in 1 Std.</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_relay_count">
|
||||
<item quantity="one">an %d Relay</item>
|
||||
<item quantity="other">an %d Relays</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_event_id_copied">Beitrags-ID kopiert</string>
|
||||
<string name="polls">Umfragen</string>
|
||||
<string name="open_polls">Offen</string>
|
||||
<string name="closed_polls">Geschlossen</string>
|
||||
|
||||
@@ -398,6 +398,59 @@
|
||||
<string name="migrate_bookmarks_button">Összes átköltöztetése az új könyvjelzőkbe</string>
|
||||
<string name="migrate_bookmarks_success">A könyvjelzők átköltöztetése sikeresen befejeződött</string>
|
||||
<string name="drafts">Piszkozatok</string>
|
||||
<string name="scheduled_posts">Ütemezett bejegyzések</string>
|
||||
<string name="schedule_post">Ütemezés</string>
|
||||
<string name="schedule_post_time_label">Ütemezés ideje</string>
|
||||
<string name="schedule_post_helper">A bejegyzések a tervezett időponttól számított körülbelül 15 percen belül jelennek meg.</string>
|
||||
<string name="schedule_post_pick_time">Válasszon ki egy időpontot</string>
|
||||
<string name="schedule_post_pick_label">Ütemezés…</string>
|
||||
<string name="schedule_post_publishes_in">Közzététel: %1$s</string>
|
||||
<string name="schedule_post_was_due">Már %1$s ezelőtt esedékes volt</string>
|
||||
<string name="schedule_post_picker_time_title">Időpont</string>
|
||||
<string name="schedule_post_button_add">Bejegyzés ütemezése</string>
|
||||
<string name="schedule_post_button_remove">Ütemezés visszavonása</string>
|
||||
<string name="schedule_post_warning_title">Folyamatos értesítési szolgáltatás letiltva</string>
|
||||
<string name="schedule_post_warning_single">Az ütemezett bejegyzések csak akkor jelennek meg, ha legközelebb újra megnyitja az alkalmazást. A megbízható háttérbeli ütemezés érdekében engedélyezze a „Folyamatos értesítési szolgáltatás” beállítását a Beállítások → Felhasználói felület beállítása menüpontban.</string>
|
||||
<string name="schedule_post_warning_multi">Az ütemezett bejegyzések csak akkor jelennek meg, ha újra megnyitja az alkalmazást. Más fiókok ütemezett bejegyzései nem jelennek meg, amíg ez a fiók aktív. A megbízható háttérbeli ütemezés érdekében engedélyezze a „Folyamatos értesítési szolgáltatás” beállítását a Beállítások → Felhasználói felület beállítása menüpontban.</string>
|
||||
<string name="schedule_post_preset_in_one_hour">1 órán belül</string>
|
||||
<string name="schedule_post_preset_tomorrow_morning">Holnap délelőtt 9 órakor</string>
|
||||
<string name="schedule_post_preset_next_monday_morning">Következő hétfő délelőtt 9 órakor</string>
|
||||
<string name="schedule_post_always_on_prompt_title">Bekapcsolja a folyamatos értesítési szolgáltatást?</string>
|
||||
<string name="schedule_post_always_on_prompt_message">Az ütemezett bejegyzések csak akkor jelennek meg biztosan, ha a „Folyamatos értesítési szolgáltatás” engedélyezve van. Ellenkező esetben előfordulhat, hogy csak az alkalmazás következő megnyitásakor jelennek meg.</string>
|
||||
<string name="schedule_post_always_on_prompt_open_settings">Beállítások menyitása</string>
|
||||
<string name="schedule_post_always_on_prompt_continue">Folytatás mindenképpen</string>
|
||||
<string name="scheduled_posts_at_time">%1$s → %2$s</string>
|
||||
<string name="scheduled_posts_at_time_past">%1$s · %2$s ezelőtt</string>
|
||||
<string name="scheduled_posts_day_tomorrow">Holnap</string>
|
||||
<string name="scheduled_posts_logout_toast_zero">Kijelentkezve</string>
|
||||
<string name="scheduled_posts_logout_toast">Ön kijelentkezett · %1$d ütemezett bejegyzés törölve</string>
|
||||
<string name="scheduled_posts_notification_sent_title">Ütemezett bejegyzés közzétéve</string>
|
||||
<string name="scheduled_posts_notification_failed_title">Nem sikerült közzétenni egy ütemezett bejegyzést</string>
|
||||
<string name="app_notification_scheduled_posts_channel_name">Ütemezett bejegyzések</string>
|
||||
<string name="app_notification_scheduled_posts_channel_description">Értesítések, amikor egy ütemezett bejegyzés megjelenik vagy nem sikerül közzététenni.</string>
|
||||
<string name="scheduled_posts_action_send_now">Küldés most</string>
|
||||
<string name="scheduled_posts_empty_title">Nincsenek ütemezett bejegyzések</string>
|
||||
<string name="scheduled_posts_empty_hint">Írja meg az üzenetet, majd koppintson az óraikonra, hogy a közzétételét későbbre ütemezhesse.</string>
|
||||
<string name="scheduled_posts_error_prefix">Hiba: %1$s</string>
|
||||
<string name="scheduled_posts_status_pending">Ütemezve</string>
|
||||
<string name="scheduled_posts_status_publishing">Küldés…</string>
|
||||
<string name="scheduled_posts_status_failed">Sikertelen</string>
|
||||
<string name="scheduled_posts_status_sent">Elküldve</string>
|
||||
<string name="scheduled_posts_status_cancelled">Megszakitva</string>
|
||||
<string name="scheduled_posts_logout_warning">Önnek %1$d ütemezett bejegyzése van, amelyeket még nem tett közzé. Kijelentkezés esetén ezek véglegesen törlődnek.</string>
|
||||
<plurals name="scheduled_posts_subtitle_queued">
|
||||
<item quantity="one">%d sorbaállítva</item>
|
||||
<item quantity="other">%d sorbaállítva</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||
<item quantity="one"> · 1 bejegyzés 1 órán belül</item>
|
||||
<item quantity="other"> · %d bejegyzés 1 órán belül</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_relay_count">
|
||||
<item quantity="one">1 átjátszóhoz</item>
|
||||
<item quantity="other">%d átjátszóhoz</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_event_id_copied">Bejegyzés azonosítója másolva</string>
|
||||
<string name="polls">Szavazások</string>
|
||||
<string name="open_polls">Megnyitás</string>
|
||||
<string name="closed_polls">Lezárva</string>
|
||||
|
||||
@@ -396,6 +396,67 @@
|
||||
<string name="migrate_bookmarks_button">Mover Tudo para Novos Favoritos</string>
|
||||
<string name="migrate_bookmarks_success">Favoritos migrados com sucesso</string>
|
||||
<string name="drafts">Rascunhos</string>
|
||||
<string name="scheduled_posts">Posts agendados</string>
|
||||
<string name="schedule_post">Agendar</string>
|
||||
<string name="schedule_post_time_label">Hora agendada</string>
|
||||
<string name="schedule_post_helper">Posts são publicados em até ~15 minutos após o horário agendado.</string>
|
||||
<string name="schedule_post_pick_time">Escolher horário agendado</string>
|
||||
<string name="schedule_post_pick_label">Agendar para…</string>
|
||||
<string name="schedule_post_publishes_in">Publica em %1$s</string>
|
||||
<string name="schedule_post_was_due">Devia ter sido publicado há %1$s</string>
|
||||
<string name="schedule_post_picker_time_title">Hora</string>
|
||||
<string name="schedule_post_button_add">Agendar post</string>
|
||||
<string name="schedule_post_button_remove">Cancelar agendamento</string>
|
||||
<string name="schedule_post_warning_title">Notificações sempre ativas desativadas</string>
|
||||
<string name="schedule_post_warning_single">Posts agendados podem não ser publicados até você reabrir o app. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano.</string>
|
||||
<string name="schedule_post_warning_multi">Posts agendados podem não ser publicados até você reabrir o app. Posts agendados de outras contas não serão disparados enquanto esta conta estiver ativa. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano.</string>
|
||||
<string name="schedule_post_preset_in_one_hour">Em 1 hora</string>
|
||||
<string name="schedule_post_preset_tomorrow_morning">Amanhã às 9h</string>
|
||||
<string name="schedule_post_preset_next_monday_morning">Próxima segunda às 9h</string>
|
||||
<string name="schedule_post_always_on_prompt_title">Ativar notificações sempre ativas?</string>
|
||||
<string name="schedule_post_always_on_prompt_message">Posts agendados publicam de forma confiável apenas quando notificações sempre ativas estão ativadas. Caso contrário, podem não disparar até você reabrir o app.</string>
|
||||
<string name="schedule_post_always_on_prompt_open_settings">Abrir configurações</string>
|
||||
<string name="schedule_post_always_on_prompt_continue">Continuar mesmo assim</string>
|
||||
<string name="scheduled_posts_at_time">%1$s · em %2$s</string>
|
||||
<string name="scheduled_posts_at_time_past">%1$s · há %2$s</string>
|
||||
<string name="scheduled_posts_day_tomorrow">Amanhã</string>
|
||||
<string name="scheduled_posts_logout_toast_zero">Desconectado</string>
|
||||
<plurals name="scheduled_posts_logout_toast">
|
||||
<item quantity="one">Desconectado · %d post agendado excluído</item>
|
||||
<item quantity="many">Desconectado · %d posts agendados excluídos</item>
|
||||
<item quantity="other">Desconectado · %d posts agendados excluídos</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_notification_sent_title">Post agendado publicado</string>
|
||||
<string name="scheduled_posts_notification_failed_title">Post agendado falhou</string>
|
||||
<string name="app_notification_scheduled_posts_channel_name">Posts agendados</string>
|
||||
<string name="app_notification_scheduled_posts_channel_description">Notificações quando um post agendado é publicado ou falha ao publicar.</string>
|
||||
<string name="scheduled_posts_action_send_now">Enviar agora</string>
|
||||
<string name="scheduled_posts_empty_title">Sem posts agendados</string>
|
||||
<string name="scheduled_posts_empty_hint">Componha uma nota e toque no ícone do relógio para agendá-la.</string>
|
||||
<string name="scheduled_posts_error_prefix">Erro: %1$s</string>
|
||||
<string name="scheduled_posts_status_pending">Agendado</string>
|
||||
<string name="scheduled_posts_status_publishing">Enviando…</string>
|
||||
<string name="scheduled_posts_status_failed">Falhou</string>
|
||||
<string name="scheduled_posts_status_sent">Enviado</string>
|
||||
<string name="scheduled_posts_status_cancelled">Cancelado</string>
|
||||
<plurals name="scheduled_posts_logout_warning">
|
||||
<item quantity="one">Você tem %d post agendado que ainda não foi publicado. Sair excluirá esse post permanentemente.</item>
|
||||
<item quantity="many">Você tem %d posts agendados que ainda não foram publicados. Sair excluirá esses posts permanentemente.</item>
|
||||
<item quantity="other">Você tem %d posts agendados que ainda não foram publicados. Sair excluirá esses posts permanentemente.</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_queued">
|
||||
<item quantity="one">%d na fila</item>
|
||||
<item quantity="other">%d na fila</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||
<item quantity="one"> · %d em 1h</item>
|
||||
<item quantity="other"> · %d em 1h</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_relay_count">
|
||||
<item quantity="one">para %d relay</item>
|
||||
<item quantity="other">para %d relays</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_event_id_copied">ID do post copiado</string>
|
||||
<string name="polls">Enquetes</string>
|
||||
<string name="open_polls">Abertas</string>
|
||||
<string name="closed_polls">Encerradas</string>
|
||||
|
||||
@@ -396,6 +396,65 @@
|
||||
<string name="migrate_bookmarks_button">Flytta allt till nya bokmärken</string>
|
||||
<string name="migrate_bookmarks_success">Bokmärken migrerade</string>
|
||||
<string name="drafts">Utkast</string>
|
||||
<string name="scheduled_posts">Schemalagda inlägg</string>
|
||||
<string name="schedule_post">Schemalägg</string>
|
||||
<string name="schedule_post_time_label">Schemalagd tid</string>
|
||||
<string name="schedule_post_helper">Inlägg publiceras inom ~15 minuter från den schemalagda tiden.</string>
|
||||
<string name="schedule_post_pick_time">Välj schemalagd tid</string>
|
||||
<string name="schedule_post_pick_label">Schemalägg för…</string>
|
||||
<string name="schedule_post_publishes_in">Publiceras om %1$s</string>
|
||||
<string name="schedule_post_was_due">Skulle ha publicerats för %1$s sedan</string>
|
||||
<string name="schedule_post_picker_time_title">Tid</string>
|
||||
<string name="schedule_post_button_add">Schemalägg inlägg</string>
|
||||
<string name="schedule_post_button_remove">Avbryt schemaläggning</string>
|
||||
<string name="schedule_post_warning_title">Alltid-på-aviseringar avstängda</string>
|
||||
<string name="schedule_post_warning_single">Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden.</string>
|
||||
<string name="schedule_post_warning_multi">Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Andra kontons schemalagda inlägg utlöses inte medan detta konto är aktivt. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden.</string>
|
||||
<string name="schedule_post_preset_in_one_hour">Om 1 timme</string>
|
||||
<string name="schedule_post_preset_tomorrow_morning">Imorgon kl. 09:00</string>
|
||||
<string name="schedule_post_preset_next_monday_morning">Nästa måndag kl. 09:00</string>
|
||||
<string name="schedule_post_always_on_prompt_title">Aktivera alltid-på-aviseringar?</string>
|
||||
<string name="schedule_post_always_on_prompt_message">Schemalagda inlägg publiceras pålitligt endast när alltid-på-aviseringar är aktiverade. Annars kanske de inte utlöses förrän du öppnar appen igen.</string>
|
||||
<string name="schedule_post_always_on_prompt_open_settings">Öppna inställningar</string>
|
||||
<string name="schedule_post_always_on_prompt_continue">Fortsätt ändå</string>
|
||||
<string name="scheduled_posts_at_time">%1$s · om %2$s</string>
|
||||
<string name="scheduled_posts_at_time_past">%1$s · för %2$s sedan</string>
|
||||
<string name="scheduled_posts_day_tomorrow">Imorgon</string>
|
||||
<string name="scheduled_posts_logout_toast_zero">Utloggad</string>
|
||||
<plurals name="scheduled_posts_logout_toast">
|
||||
<item quantity="one">Utloggad · %d schemalagt inlägg raderat</item>
|
||||
<item quantity="other">Utloggad · %d schemalagda inlägg raderade</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_notification_sent_title">Schemalagt inlägg publicerat</string>
|
||||
<string name="scheduled_posts_notification_failed_title">Schemalagt inlägg misslyckades</string>
|
||||
<string name="app_notification_scheduled_posts_channel_name">Schemalagda inlägg</string>
|
||||
<string name="app_notification_scheduled_posts_channel_description">Aviseringar när ett schemalagt inlägg publiceras eller misslyckas att publicera.</string>
|
||||
<string name="scheduled_posts_action_send_now">Skicka nu</string>
|
||||
<string name="scheduled_posts_empty_title">Inga schemalagda inlägg</string>
|
||||
<string name="scheduled_posts_empty_hint">Skriv en anteckning och tryck på klockikonen för att schemalägga den.</string>
|
||||
<string name="scheduled_posts_error_prefix">Fel: %1$s</string>
|
||||
<string name="scheduled_posts_status_pending">Schemalagd</string>
|
||||
<string name="scheduled_posts_status_publishing">Skickar…</string>
|
||||
<string name="scheduled_posts_status_failed">Misslyckades</string>
|
||||
<string name="scheduled_posts_status_sent">Skickat</string>
|
||||
<string name="scheduled_posts_status_cancelled">Avbrutet</string>
|
||||
<plurals name="scheduled_posts_logout_warning">
|
||||
<item quantity="one">Du har %d schemalagt inlägg som inte har publicerats än. Att logga ut raderar det permanent.</item>
|
||||
<item quantity="other">Du har %d schemalagda inlägg som inte har publicerats än. Att logga ut raderar dem permanent.</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_queued">
|
||||
<item quantity="one">%d i kö</item>
|
||||
<item quantity="other">%d i kö</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||
<item quantity="one"> · %d inom 1 h</item>
|
||||
<item quantity="other"> · %d inom 1 h</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_relay_count">
|
||||
<item quantity="one">till %d relä</item>
|
||||
<item quantity="other">till %d reläer</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_event_id_copied">Inläggs-ID kopierat</string>
|
||||
<string name="polls">Omröstningar</string>
|
||||
<string name="open_polls">Öppna</string>
|
||||
<string name="closed_polls">Stängda</string>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<string name="referenced_event_not_found">未找到相关事件</string>
|
||||
<string name="could_not_decrypt_the_message">无法解密消息</string>
|
||||
<string name="group_picture">群聊图片</string>
|
||||
<string name="explicit_content">明确内容</string>
|
||||
<string name="explicit_content">露骨内容</string>
|
||||
<string name="relay_notice">中继通知</string>
|
||||
<string name="duplicated_post">重复的贴文</string>
|
||||
<string name="spam">垃圾信息</string>
|
||||
@@ -39,7 +39,7 @@
|
||||
<string name="block_hide_user"><![CDATA[阻止并隐藏用户]]></string>
|
||||
<string name="report_spam_scam">举报垃圾邮件/诈骗</string>
|
||||
<string name="report_impersonation">举报冒充</string>
|
||||
<string name="report_explicit_content">举报明确内容</string>
|
||||
<string name="report_explicit_content">举报露骨内容</string>
|
||||
<string name="report_illegal_behaviour">举报非法行为</string>
|
||||
<string name="report_malware">举报恶意软件</string>
|
||||
<string name="report_mod">举报 Mod</string>
|
||||
@@ -398,6 +398,56 @@
|
||||
<string name="migrate_bookmarks_button">移动全部到新书签</string>
|
||||
<string name="migrate_bookmarks_success">书签迁移成功</string>
|
||||
<string name="drafts">草稿</string>
|
||||
<string name="scheduled_posts">定时帖子</string>
|
||||
<string name="schedule_post">计划</string>
|
||||
<string name="schedule_post_time_label">计划的时间</string>
|
||||
<string name="schedule_post_helper">帖子发布时间计划时间的 ~15 分钟内。</string>
|
||||
<string name="schedule_post_pick_time">选择计划时间</string>
|
||||
<string name="schedule_post_pick_label">选择计划项目…</string>
|
||||
<string name="schedule_post_publishes_in">在 %1$s 内发布</string>
|
||||
<string name="schedule_post_was_due">%1$s 前到期</string>
|
||||
<string name="schedule_post_picker_time_title">时间</string>
|
||||
<string name="schedule_post_button_add">定时帖</string>
|
||||
<string name="schedule_post_button_remove">取消时间安排</string>
|
||||
<string name="schedule_post_warning_title">禁用了始终显示的通知</string>
|
||||
<string name="schedule_post_warning_single">下次重新打开应用前可能不会发布定时贴。要获得稳定的后台定时功能在“设置” → “用户界面偏好” 中启用“始终显示”。</string>
|
||||
<string name="schedule_post_warning_multi">在重新打开应用前可能不会发布定时帖。此账户活跃时,其他账户的定时帖不会发布。要获得稳定的后台定时功能,在“设置” →\"用户界面首选项“ 中开启”始终显示“。</string>
|
||||
<string name="schedule_post_preset_in_one_hour">1小时内</string>
|
||||
<string name="schedule_post_preset_tomorrow_morning">明天上午9点</string>
|
||||
<string name="schedule_post_preset_next_monday_morning">下周一上午9点</string>
|
||||
<string name="schedule_post_always_on_prompt_title">开启”始终显示的通知“?</string>
|
||||
<string name="schedule_post_always_on_prompt_message">只有在启用了”始终显示的通知“时才能可靠地发布定时帖。否则,在下次重新打开应用前定时帖可能不会发布。</string>
|
||||
<string name="schedule_post_always_on_prompt_open_settings">打开设置</string>
|
||||
<string name="schedule_post_always_on_prompt_continue">仍然继续</string>
|
||||
<string name="scheduled_posts_at_time">%1$s ·在 %2$s</string>
|
||||
<string name="scheduled_posts_at_time_past">%1$s · %2$s 前</string>
|
||||
<string name="scheduled_posts_day_tomorrow">明天</string>
|
||||
<string name="scheduled_posts_logout_toast_zero">已退出登录</string>
|
||||
<string name="scheduled_posts_logout_toast">已退出登录 删除了 %1$d 个定时帖</string>
|
||||
<string name="scheduled_posts_notification_sent_title">发布了定时帖</string>
|
||||
<string name="scheduled_posts_notification_failed_title">未能发布定时帖</string>
|
||||
<string name="app_notification_scheduled_posts_channel_name">定时帖</string>
|
||||
<string name="app_notification_scheduled_posts_channel_description">当成功发布了定时帖或未能发布定时帖时发出通知。</string>
|
||||
<string name="scheduled_posts_action_send_now">立即发送</string>
|
||||
<string name="scheduled_posts_empty_title">无定时帖</string>
|
||||
<string name="scheduled_posts_empty_hint">撰写笔记并轻触时钟图标稍后安排时间。</string>
|
||||
<string name="scheduled_posts_error_prefix">错误:%1$s</string>
|
||||
<string name="scheduled_posts_status_pending">已安排时间</string>
|
||||
<string name="scheduled_posts_status_publishing">正在发送…</string>
|
||||
<string name="scheduled_posts_status_failed">失败</string>
|
||||
<string name="scheduled_posts_status_sent">已发送</string>
|
||||
<string name="scheduled_posts_status_cancelled">已取消</string>
|
||||
<string name="scheduled_posts_logout_warning">您有尚未发布的 %1$d 个定时帖。退出登录将永久删除它们。</string>
|
||||
<plurals name="scheduled_posts_subtitle_queued">
|
||||
<item quantity="other">%d 个已加入队列</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||
<item quantity="other"> • %d 个于1小时内到期</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_relay_count">
|
||||
<item quantity="other">到 %d 个中继</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_event_id_copied">已复制笔记 ID</string>
|
||||
<string name="polls">投票</string>
|
||||
<string name="open_polls">开启</string>
|
||||
<string name="closed_polls">已关闭</string>
|
||||
@@ -739,7 +789,7 @@
|
||||
<string name="zap_type_nonzap">非打闪</string>
|
||||
<string name="zap_type_nonzap_explainer">Nostr 上没有痕迹,仅在闪电上</string>
|
||||
<string name="post_anonymously">匿名</string>
|
||||
<string name="post_anonymously_explainer">使用新的一次性身份发布。您的帐户将不会被链接到这个回复。</string>
|
||||
<string name="post_anonymously_explainer">使用新的一次性身份发帖。您的帐户将不会被链接到这个回复。</string>
|
||||
<string name="anonymous_reply_warning">此回复将从新的匿名身份发布</string>
|
||||
<string name="file_server">文件服务器</string>
|
||||
<string name="file_server_description">选择上传文件时使用的服务器</string>
|
||||
|
||||
@@ -428,6 +428,66 @@
|
||||
<string name="migrate_bookmarks_button">Move All to New Bookmarks</string>
|
||||
<string name="migrate_bookmarks_success">Bookmarks migrated successfully</string>
|
||||
<string name="drafts">Drafts</string>
|
||||
<string name="scheduled_posts">Scheduled posts</string>
|
||||
<string name="schedule_post">Schedule</string>
|
||||
<string name="schedule_post_time_label">Scheduled time</string>
|
||||
<string name="schedule_post_helper">Posts publish within ~15 minutes of the scheduled time.</string>
|
||||
<string name="schedule_post_pick_time">Pick scheduled time</string>
|
||||
<string name="schedule_post_pick_label">Schedule for…</string>
|
||||
<string name="schedule_post_publishes_in">Publishes in %1$s</string>
|
||||
<string name="schedule_post_was_due">Was due %1$s ago</string>
|
||||
<string name="schedule_post_picker_time_title">Time</string>
|
||||
<string name="schedule_post_button_add">Schedule post</string>
|
||||
<string name="schedule_post_button_remove">Cancel scheduling</string>
|
||||
<string name="schedule_post_warning_title">Always-on notifications disabled</string>
|
||||
<string name="schedule_post_warning_single">Scheduled posts may not publish until you next reopen the app. Enable always-on in Settings → UI Preferences for reliable background scheduling.</string>
|
||||
<string name="schedule_post_warning_multi">Scheduled posts may not publish until you reopen the app. Other accounts\' scheduled posts won\'t fire while this account is active. Enable always-on in Settings → UI Preferences for reliable background scheduling.</string>
|
||||
<string name="schedule_post_preset_in_one_hour">In 1 hour</string>
|
||||
<string name="schedule_post_preset_tomorrow_morning">Tomorrow 9 AM</string>
|
||||
<string name="schedule_post_preset_next_monday_morning">Next Monday 9 AM</string>
|
||||
<string name="schedule_post_always_on_prompt_title">Enable always-on notifications?</string>
|
||||
<string name="schedule_post_always_on_prompt_message">Scheduled posts publish reliably only when always-on notifications are enabled. Otherwise, they may not fire until you next reopen the app.</string>
|
||||
<string name="schedule_post_always_on_prompt_open_settings">Open settings</string>
|
||||
<string name="schedule_post_always_on_prompt_continue">Continue anyway</string>
|
||||
<string name="scheduled_posts_at_time">%1$s · in %2$s</string>
|
||||
<string name="scheduled_posts_at_time_past">%1$s · %2$s ago</string>
|
||||
<string name="scheduled_posts_day_tomorrow">Tomorrow</string>
|
||||
<string name="scheduled_posts_logout_toast_zero">Logged out</string>
|
||||
<plurals name="scheduled_posts_logout_toast">
|
||||
<item quantity="one">Logged out · %d scheduled post deleted</item>
|
||||
<item quantity="other">Logged out · %d scheduled posts deleted</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_notification_sent_title">Scheduled post published</string>
|
||||
<string name="scheduled_posts_notification_failed_title">Scheduled post failed</string>
|
||||
<string name="app_notification_scheduled_posts_channel_id" translatable="false">ScheduledPostsID</string>
|
||||
<string name="app_notification_scheduled_posts_channel_name">Scheduled posts</string>
|
||||
<string name="app_notification_scheduled_posts_channel_description">Notifications when a scheduled post is published or fails to publish.</string>
|
||||
<string name="scheduled_posts_action_send_now">Send now</string>
|
||||
<string name="scheduled_posts_empty_title">No scheduled posts</string>
|
||||
<string name="scheduled_posts_empty_hint">Compose a note and tap the clock icon to schedule it for later.</string>
|
||||
<string name="scheduled_posts_error_prefix">Error: %1$s</string>
|
||||
<string name="scheduled_posts_status_pending">Scheduled</string>
|
||||
<string name="scheduled_posts_status_publishing">Sending…</string>
|
||||
<string name="scheduled_posts_status_failed">Failed</string>
|
||||
<string name="scheduled_posts_status_sent">Sent</string>
|
||||
<string name="scheduled_posts_status_cancelled">Cancelled</string>
|
||||
<plurals name="scheduled_posts_logout_warning">
|
||||
<item quantity="one">You have %d scheduled post that hasn\'t been published yet. Logging out will permanently delete it.</item>
|
||||
<item quantity="other">You have %d scheduled posts that haven\'t been published yet. Logging out will permanently delete them.</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_queued">
|
||||
<item quantity="one">%d queued</item>
|
||||
<item quantity="other">%d queued</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||
<item quantity="one"> · %d due in 1h</item>
|
||||
<item quantity="other"> · %d due in 1h</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_relay_count">
|
||||
<item quantity="one">to %d relay</item>
|
||||
<item quantity="other">to %d relays</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_event_id_copied">Note ID copied</string>
|
||||
<string name="polls">Polls</string>
|
||||
<string name="open_polls">Open</string>
|
||||
<string name="closed_polls">Closed</string>
|
||||
@@ -1125,6 +1185,8 @@
|
||||
|
||||
<string name="filter_spam_from_strangers_title">Filter spam</string>
|
||||
<string name="filter_spam_from_strangers_explainer">Hides posts from strangers that were exactly the same for 5 or more times</string>
|
||||
<string name="disable_client_tag_title">Don\'t add client tag to my events</string>
|
||||
<string name="disable_client_tag_explainer">When enabled, Amethyst will not append a NIP-89 client tag to events you publish.</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows_title">Warn on reports</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Shows a warning message when posts have 5 or more reports from your follows</string>
|
||||
<string name="show_sensitive_content_title">Show sensitive content</string>
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.nests
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [AppForegroundCounter] — the pure-state core of
|
||||
* [AppForegroundRecycleHook]. Drives the lifecycle transitions
|
||||
* directly with a controllable clock so the threshold logic doesn't
|
||||
* need a real wall-clock wait.
|
||||
*/
|
||||
class AppForegroundRecycleHookTest {
|
||||
@Test
|
||||
fun firstForegroundAfterProcessStartDoesNotPublish() {
|
||||
// The very first onActivityStarted has no prior background to
|
||||
// recycle from — recycling here would fire a redundant
|
||||
// re-handshake on every cold start, which is wasteful.
|
||||
var fakeNow = 0L
|
||||
var publishCount = 0
|
||||
val counter =
|
||||
AppForegroundCounter(
|
||||
publishEvent = { publishCount++ },
|
||||
nowMillis = { fakeNow },
|
||||
)
|
||||
|
||||
fakeNow = 1_000L
|
||||
counter.onActivityStarted()
|
||||
|
||||
assertEquals("first onActivityStarted must not publish — no prior background", 0, publishCount)
|
||||
assertEquals(0, counter.recyclesFired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shortBackgroundDoesNotTriggerRecycle() {
|
||||
// 1 s background (notification pull, biometric prompt) is
|
||||
// typical UI noise and the QUIC socket is still healthy.
|
||||
var fakeNow = 0L
|
||||
var publishCount = 0
|
||||
val counter =
|
||||
AppForegroundCounter(
|
||||
backgroundThresholdMs = 5_000L,
|
||||
publishEvent = { publishCount++ },
|
||||
nowMillis = { fakeNow },
|
||||
)
|
||||
|
||||
fakeNow = 1_000L
|
||||
counter.onActivityStarted()
|
||||
fakeNow = 2_000L
|
||||
counter.onActivityStopped()
|
||||
fakeNow = 3_000L // backgrounded for 1 s only
|
||||
counter.onActivityStarted()
|
||||
|
||||
assertEquals(
|
||||
"background < threshold must not publish — short transitions don't reclaim sockets",
|
||||
0,
|
||||
publishCount,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backgroundLongerThanThresholdTriggersRecycle() {
|
||||
// 6 s background crosses the 5 s default threshold — Android
|
||||
// may have reclaimed the socket FD by now, so recycle the
|
||||
// QUIC session on resume.
|
||||
var fakeNow = 0L
|
||||
var publishCount = 0
|
||||
val counter =
|
||||
AppForegroundCounter(
|
||||
backgroundThresholdMs = 5_000L,
|
||||
publishEvent = { publishCount++ },
|
||||
nowMillis = { fakeNow },
|
||||
)
|
||||
|
||||
fakeNow = 1_000L
|
||||
counter.onActivityStarted()
|
||||
fakeNow = 2_000L
|
||||
counter.onActivityStopped()
|
||||
fakeNow = 8_000L // backgrounded for 6 s
|
||||
counter.onActivityStarted()
|
||||
|
||||
assertEquals(
|
||||
"background ≥ threshold must publish exactly once on resume",
|
||||
1,
|
||||
publishCount,
|
||||
)
|
||||
assertEquals(1, counter.recyclesFired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleActivitiesTrackTransitionCorrectly() {
|
||||
// Picture-in-picture mode is implemented as a second activity
|
||||
// overlaid on the main activity. While both are started the
|
||||
// app is in foreground; only when both stop does the app
|
||||
// truly background.
|
||||
var fakeNow = 0L
|
||||
var publishCount = 0
|
||||
val counter =
|
||||
AppForegroundCounter(
|
||||
backgroundThresholdMs = 5_000L,
|
||||
publishEvent = { publishCount++ },
|
||||
nowMillis = { fakeNow },
|
||||
)
|
||||
|
||||
// First activity starts (cold start), no publish.
|
||||
fakeNow = 1_000L
|
||||
counter.onActivityStarted()
|
||||
// Second activity (e.g. PIP / dialog) starts on top. Still
|
||||
// foreground; no publish — `wasBackgrounded` was false.
|
||||
fakeNow = 2_000L
|
||||
counter.onActivityStarted()
|
||||
// First activity stops (e.g. user backs out of main).
|
||||
// counter = 1, still foreground.
|
||||
fakeNow = 3_000L
|
||||
counter.onActivityStopped()
|
||||
assertEquals(
|
||||
"intermediate stop with another activity still started must not background",
|
||||
0,
|
||||
publishCount,
|
||||
)
|
||||
// Second stops → app truly backgrounds.
|
||||
fakeNow = 4_000L
|
||||
counter.onActivityStopped()
|
||||
// 6 s later, an activity restarts → recycle.
|
||||
fakeNow = 10_000L
|
||||
counter.onActivityStarted()
|
||||
assertEquals(1, publishCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun consecutiveLongBackgroundsEachPublishOnce() {
|
||||
// Two separate back-and-forth cycles must each fire exactly
|
||||
// one publish. A regression that misses to refresh the
|
||||
// last-backgrounded timestamp on second-stop would either
|
||||
// double-fire on the second resume or skip it.
|
||||
var fakeNow = 0L
|
||||
var publishCount = 0
|
||||
val counter =
|
||||
AppForegroundCounter(
|
||||
backgroundThresholdMs = 5_000L,
|
||||
publishEvent = { publishCount++ },
|
||||
nowMillis = { fakeNow },
|
||||
)
|
||||
|
||||
// Cycle 1: cold start → 6 s background → resume (publish #1)
|
||||
fakeNow = 1_000L
|
||||
counter.onActivityStarted()
|
||||
fakeNow = 2_000L
|
||||
counter.onActivityStopped()
|
||||
fakeNow = 8_000L
|
||||
counter.onActivityStarted()
|
||||
assertEquals("first resume after long background must publish", 1, publishCount)
|
||||
|
||||
// Cycle 2: 8 s background again → resume (publish #2)
|
||||
fakeNow = 10_000L
|
||||
counter.onActivityStopped()
|
||||
fakeNow = 18_000L
|
||||
counter.onActivityStarted()
|
||||
assertEquals("second resume after long background must also publish", 2, publishCount)
|
||||
}
|
||||
}
|
||||
+542
@@ -0,0 +1,542 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.scheduledposts
|
||||
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
class ScheduledPostStoreTest {
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private lateinit var file: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
file = File(temp.root, "scheduled_posts.json")
|
||||
}
|
||||
|
||||
private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file, now)
|
||||
|
||||
private fun samplePost(
|
||||
id: String = "id-1",
|
||||
publishAtSec: Long = 1_000,
|
||||
accountPubkey: String = "pk1",
|
||||
) = ScheduledPost(
|
||||
id = id,
|
||||
accountPubkey = accountPubkey,
|
||||
signedEventJson = "{}",
|
||||
relayUrls = listOf("wss://relay.example/"),
|
||||
extraEventsJson = emptyList(),
|
||||
publishAtSec = publishAtSec,
|
||||
createdAtSec = 500,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun add_persists_to_disk() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost())
|
||||
assertTrue("storage file should exist after add", file.exists())
|
||||
|
||||
val reloaded = newStore().list()
|
||||
assertEquals(1, reloaded.size)
|
||||
assertEquals("id-1", reloaded[0].id)
|
||||
assertEquals(ScheduledPostStatus.PENDING, reloaded[0].status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun claimDuePosts_returns_only_due_pending_posts() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(id = "due", publishAtSec = 1000))
|
||||
store.add(samplePost(id = "future", publishAtSec = 5000))
|
||||
store.add(samplePost(id = "also-due", publishAtSec = 999))
|
||||
|
||||
val claimed = store.claimDuePosts(nowSec = 1000)
|
||||
val ids = claimed.map { it.id }.toSet()
|
||||
assertEquals(setOf("due", "also-due"), ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun claimDuePosts_flips_status_to_publishing() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(publishAtSec = 1000))
|
||||
|
||||
store.claimDuePosts(nowSec = 1000)
|
||||
|
||||
val all = store.list()
|
||||
assertEquals(ScheduledPostStatus.PUBLISHING, all[0].status)
|
||||
assertEquals(1, all[0].attemptCount)
|
||||
assertEquals(1000L, all[0].lastAttemptAtSec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun claimDuePosts_second_call_returns_empty() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(publishAtSec = 1000))
|
||||
|
||||
val first = store.claimDuePosts(nowSec = 1000)
|
||||
val second = store.claimDuePosts(nowSec = 1000)
|
||||
|
||||
assertEquals(1, first.size)
|
||||
assertEquals(0, second.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrent_claimDuePosts_only_one_wins() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(id = "race", publishAtSec = 1000))
|
||||
|
||||
val results =
|
||||
(1..10)
|
||||
.map { async { store.claimDuePosts(nowSec = 1000) } }
|
||||
.awaitAll()
|
||||
|
||||
val totalClaimed = results.sumOf { it.size }
|
||||
assertEquals("exactly one concurrent caller should claim the post", 1, totalClaimed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markSent_updates_status_and_clears_error() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost())
|
||||
store.markFailed("id-1", "earlier error")
|
||||
store.markSent("id-1")
|
||||
|
||||
val all = store.list()
|
||||
assertEquals(ScheduledPostStatus.SENT, all[0].status)
|
||||
assertNull(all[0].lastError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markFailed_records_error() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost())
|
||||
store.markFailed("id-1", "boom")
|
||||
|
||||
val all = store.list()
|
||||
assertEquals(ScheduledPostStatus.FAILED, all[0].status)
|
||||
assertEquals("boom", all[0].lastError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun releaseClaim_reverts_publishing_to_pending() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(publishAtSec = 1000))
|
||||
store.claimDuePosts(nowSec = 1000)
|
||||
|
||||
store.releaseClaim("id-1")
|
||||
|
||||
val all = store.list()
|
||||
assertEquals(ScheduledPostStatus.PENDING, all[0].status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun releaseClaim_does_not_touch_non_publishing() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost())
|
||||
store.releaseClaim("id-1")
|
||||
|
||||
assertEquals(ScheduledPostStatus.PENDING, store.list()[0].status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancel_sets_cancelled_status_and_returns_true() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost())
|
||||
|
||||
val ok = store.cancel("id-1")
|
||||
|
||||
assertTrue(ok)
|
||||
assertEquals(ScheduledPostStatus.CANCELLED, store.list()[0].status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancel_unknown_id_returns_false() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
assertEquals(false, store.cancel("nope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listFor_filters_by_account() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(id = "a", accountPubkey = "pk-a"))
|
||||
store.add(samplePost(id = "b", accountPubkey = "pk-b"))
|
||||
store.add(samplePost(id = "c", accountPubkey = "pk-a"))
|
||||
|
||||
val filtered = store.listFor("pk-a").map { it.id }.toSet()
|
||||
assertEquals(setOf("a", "c"), filtered)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelled_posts_are_not_claimed() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(publishAtSec = 1000))
|
||||
store.cancel("id-1")
|
||||
|
||||
val claimed = store.claimDuePosts(nowSec = 5000)
|
||||
|
||||
assertEquals(0, claimed.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missing_file_loads_as_empty() =
|
||||
runTest {
|
||||
assertTrue("file should not exist before first read", !file.exists())
|
||||
val store = newStore()
|
||||
assertEquals(0, store.list().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun corrupt_file_loads_as_empty() =
|
||||
runTest {
|
||||
file.writeText("not valid json {{{")
|
||||
val store = newStore()
|
||||
assertEquals(0, store.list().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishNow_sets_publishAtSec_to_now_and_status_pending() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(publishAtSec = 9_999_999))
|
||||
|
||||
val ok = store.publishNow("id-1", nowSec = 1234)
|
||||
|
||||
assertTrue(ok)
|
||||
val updated = store.list().single()
|
||||
assertEquals(ScheduledPostStatus.PENDING, updated.status)
|
||||
assertEquals(1234L, updated.publishAtSec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishNow_clears_failed_state_for_retry() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost())
|
||||
store.markFailed("id-1", "earlier failure")
|
||||
|
||||
store.publishNow("id-1", nowSec = 5000)
|
||||
|
||||
val updated = store.list().single()
|
||||
assertEquals(ScheduledPostStatus.PENDING, updated.status)
|
||||
assertNull(updated.lastError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishNow_unknown_id_returns_false() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
assertEquals(false, store.publishNow("nope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishNow_makes_post_immediately_claimable() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(publishAtSec = 9_999_999))
|
||||
assertEquals(0, store.claimDuePosts(nowSec = 1000).size)
|
||||
|
||||
store.publishNow("id-1", nowSec = 1000)
|
||||
|
||||
val claimed = store.claimDuePosts(nowSec = 1000)
|
||||
assertEquals(1, claimed.size)
|
||||
assertEquals("id-1", claimed[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flow_emits_initial_empty_then_post_after_add() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
assertEquals(0, store.flow.value.size)
|
||||
|
||||
store.add(samplePost())
|
||||
|
||||
assertEquals(1, store.flow.value.size)
|
||||
assertEquals("id-1", store.flow.value[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flow_reflects_status_transitions() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(publishAtSec = 1000))
|
||||
assertEquals(ScheduledPostStatus.PENDING, store.flow.value[0].status)
|
||||
|
||||
store.claimDuePosts(nowSec = 1000)
|
||||
assertEquals(ScheduledPostStatus.PUBLISHING, store.flow.value[0].status)
|
||||
|
||||
store.markSent("id-1")
|
||||
assertEquals(ScheduledPostStatus.SENT, store.flow.value[0].status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flow_seeded_from_disk_on_first_access() =
|
||||
runTest {
|
||||
// Pre-populate the file via a first store instance
|
||||
newStore().add(samplePost())
|
||||
|
||||
// Second store starts with empty in-memory flow until first access
|
||||
val store = newStore()
|
||||
assertEquals(0, store.flow.value.size)
|
||||
|
||||
// Triggering any read method causes ensureLoaded() to seed the flow
|
||||
store.list()
|
||||
assertEquals(1, store.flow.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeForAccount_removes_all_matching_rows_and_returns_count() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(id = "a1", accountPubkey = "pk-a"))
|
||||
store.add(samplePost(id = "a2", accountPubkey = "pk-a"))
|
||||
store.add(samplePost(id = "b1", accountPubkey = "pk-b"))
|
||||
|
||||
val removed = store.removeForAccount("pk-a")
|
||||
|
||||
assertEquals(2, removed)
|
||||
val remaining = store.list()
|
||||
assertEquals(1, remaining.size)
|
||||
assertEquals("b1", remaining[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeForAccount_no_match_returns_zero_and_does_not_persist() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(accountPubkey = "pk-a"))
|
||||
val bytesBefore = file.readBytes()
|
||||
|
||||
val removed = store.removeForAccount("pk-other")
|
||||
|
||||
assertEquals(0, removed)
|
||||
assertEquals(1, store.list().size)
|
||||
assertTrue("file should not be rewritten on no-op", bytesBefore.contentEquals(file.readBytes()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeForAccount_persists_to_disk() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(id = "a1", accountPubkey = "pk-a"))
|
||||
store.add(samplePost(id = "b1", accountPubkey = "pk-b"))
|
||||
|
||||
store.removeForAccount("pk-a")
|
||||
|
||||
val reloaded = newStore().list()
|
||||
assertEquals(1, reloaded.size)
|
||||
assertEquals("b1", reloaded[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeForAccount_purges_terminal_states_too() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(id = "p1", accountPubkey = "pk-a"))
|
||||
store.add(samplePost(id = "p2", accountPubkey = "pk-a"))
|
||||
store.markSent("p1")
|
||||
store.cancel("p2")
|
||||
|
||||
val removed = store.removeForAccount("pk-a")
|
||||
|
||||
assertEquals(2, removed)
|
||||
assertEquals(0, store.list().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancel_stamps_terminatedAtSec() =
|
||||
runTest {
|
||||
val clock = 1_700_000_000L
|
||||
val store = newStore { clock }
|
||||
store.add(samplePost(id = "x"))
|
||||
|
||||
store.cancel("x")
|
||||
|
||||
assertEquals(clock, store.list().single().terminatedAtSec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markSent_stamps_terminatedAtSec() =
|
||||
runTest {
|
||||
val clock = 1_700_000_000L
|
||||
val store = newStore { clock }
|
||||
store.add(samplePost(id = "x", publishAtSec = clock))
|
||||
store.claimDuePosts(clock)
|
||||
|
||||
store.markSent("x")
|
||||
|
||||
assertEquals(clock, store.list().single().terminatedAtSec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishNow_clears_terminatedAtSec() =
|
||||
runTest {
|
||||
val clock = 1_700_000_000L
|
||||
val store = newStore { clock }
|
||||
store.add(samplePost(id = "x"))
|
||||
store.cancel("x") // stamps terminatedAtSec
|
||||
|
||||
store.publishNow("x", nowSec = clock + 5)
|
||||
|
||||
assertNull(store.list().single().terminatedAtSec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ensureLoaded_purges_sent_older_than_seven_days() =
|
||||
runTest {
|
||||
val createTime = 1_700_000_000L
|
||||
newStore { createTime }.also { it.add(samplePost(id = "old-sent", publishAtSec = createTime)) }
|
||||
newStore { createTime }.also {
|
||||
it.claimDuePosts(createTime)
|
||||
it.markSent("old-sent")
|
||||
}
|
||||
|
||||
val eightDaysLater = createTime + 8L * 24 * 3600
|
||||
val reloaded = newStore { eightDaysLater }
|
||||
assertEquals(0, reloaded.list().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ensureLoaded_keeps_recent_sent() =
|
||||
runTest {
|
||||
val createTime = 1_700_000_000L
|
||||
newStore { createTime }.also { it.add(samplePost(id = "fresh", publishAtSec = createTime)) }
|
||||
newStore { createTime }.also {
|
||||
it.claimDuePosts(createTime)
|
||||
it.markSent("fresh")
|
||||
}
|
||||
|
||||
val sixDaysLater = createTime + 6L * 24 * 3600
|
||||
val reloaded = newStore { sixDaysLater }
|
||||
assertEquals(1, reloaded.list().size)
|
||||
assertEquals(ScheduledPostStatus.SENT, reloaded.list().single().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ensureLoaded_purges_cancelled_older_than_thirty_days() =
|
||||
runTest {
|
||||
val createTime = 1_700_000_000L
|
||||
newStore { createTime }.also {
|
||||
it.add(samplePost(id = "old-cancel"))
|
||||
it.cancel("old-cancel")
|
||||
}
|
||||
|
||||
val thirtyOneDaysLater = createTime + 31L * 24 * 3600
|
||||
val reloaded = newStore { thirtyOneDaysLater }
|
||||
assertEquals(0, reloaded.list().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ensureLoaded_keeps_recent_cancelled() =
|
||||
runTest {
|
||||
val createTime = 1_700_000_000L
|
||||
newStore { createTime }.also {
|
||||
it.add(samplePost(id = "recent-cancel"))
|
||||
it.cancel("recent-cancel")
|
||||
}
|
||||
|
||||
val twentyDaysLater = createTime + 20L * 24 * 3600
|
||||
val reloaded = newStore { twentyDaysLater }
|
||||
assertEquals(1, reloaded.list().size)
|
||||
assertEquals(ScheduledPostStatus.CANCELLED, reloaded.list().single().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ensureLoaded_keeps_failed_indefinitely() =
|
||||
runTest {
|
||||
val createTime = 1_700_000_000L
|
||||
newStore { createTime }.also { it.add(samplePost(id = "fail", publishAtSec = createTime)) }
|
||||
newStore { createTime }.also {
|
||||
it.claimDuePosts(createTime)
|
||||
it.markFailed("fail", "boom")
|
||||
}
|
||||
|
||||
val ninetyDaysLater = createTime + 90L * 24 * 3600
|
||||
val reloaded = newStore { ninetyDaysLater }
|
||||
assertEquals(1, reloaded.list().size)
|
||||
assertEquals(ScheduledPostStatus.FAILED, reloaded.list().single().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ensureLoaded_persists_purge_to_disk() =
|
||||
runTest {
|
||||
val createTime = 1_700_000_000L
|
||||
newStore { createTime }.also { it.add(samplePost(id = "old", publishAtSec = createTime)) }
|
||||
newStore { createTime }.also {
|
||||
it.claimDuePosts(createTime)
|
||||
it.markSent("old")
|
||||
}
|
||||
val sizeBefore = file.length()
|
||||
|
||||
val eightDaysLater = createTime + 8L * 24 * 3600
|
||||
newStore { eightDaysLater }.list() // triggers ensureLoaded + purge + persist
|
||||
|
||||
assertTrue("file should shrink after purge", file.length() < sizeBefore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundtrip_preserves_all_fields() =
|
||||
runTest {
|
||||
val original =
|
||||
ScheduledPost(
|
||||
id = "roundtrip",
|
||||
accountPubkey = "pk-x",
|
||||
signedEventJson = """{"kind":1,"content":"hi"}""",
|
||||
relayUrls = listOf("wss://a/", "wss://b/"),
|
||||
extraEventsJson = listOf("{}", "{}"),
|
||||
publishAtSec = 1_700_000_000,
|
||||
createdAtSec = 1_699_900_000,
|
||||
status = ScheduledPostStatus.PENDING,
|
||||
lastAttemptAtSec = null,
|
||||
attemptCount = 0,
|
||||
lastError = null,
|
||||
)
|
||||
newStore().add(original)
|
||||
|
||||
val reloaded = newStore().list().single()
|
||||
assertEquals(original, reloaded)
|
||||
assertNotNull(reloaded.relayUrls)
|
||||
assertEquals(2, reloaded.relayUrls.size)
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts
|
||||
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ScheduledPostMediaTest {
|
||||
private fun postWithJson(json: String) =
|
||||
ScheduledPost(
|
||||
id = "id",
|
||||
accountPubkey = "pk",
|
||||
signedEventJson = json,
|
||||
relayUrls = emptyList(),
|
||||
extraEventsJson = emptyList(),
|
||||
publishAtSec = 0,
|
||||
createdAtSec = 0,
|
||||
status = ScheduledPostStatus.PENDING,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun extractFirstMediaUrl_returns_null_when_no_imeta() {
|
||||
val json = """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[],"content":"hi","sig":"x"}"""
|
||||
assertNull(extractFirstMediaUrl(postWithJson(json)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractFirstMediaUrl_returns_image_when_mime_starts_with_image() {
|
||||
val json =
|
||||
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg","m image/jpeg"]],"content":"hi","sig":"x"}"""
|
||||
val result = extractFirstMediaUrl(postWithJson(json))
|
||||
assertTrue(result is MediaUrl.Image)
|
||||
assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractFirstMediaUrl_returns_video_when_mime_starts_with_video() {
|
||||
val json =
|
||||
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/v.mp4","m video/mp4"]],"content":"hi","sig":"x"}"""
|
||||
val result = extractFirstMediaUrl(postWithJson(json))
|
||||
assertTrue(result is MediaUrl.Video)
|
||||
assertEquals("https://x/v.mp4", (result as MediaUrl.Video).url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractFirstMediaUrl_defaults_to_image_when_no_mime() {
|
||||
val json =
|
||||
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg"]],"content":"hi","sig":"x"}"""
|
||||
val result = extractFirstMediaUrl(postWithJson(json))
|
||||
assertTrue(result is MediaUrl.Image)
|
||||
assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractFirstMediaUrl_picks_first_when_multiple_imeta() {
|
||||
val json =
|
||||
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg","m image/jpeg"],["imeta","url https://y/v.mp4","m video/mp4"]],"content":"hi","sig":"x"}"""
|
||||
val result = extractFirstMediaUrl(postWithJson(json))
|
||||
assertTrue(result is MediaUrl.Image)
|
||||
assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractFirstMediaUrl_returns_null_when_json_malformed() {
|
||||
assertNull(extractFirstMediaUrl(postWithJson("{not json}")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractFirstMediaUrl_returns_null_when_imeta_has_no_url() {
|
||||
val json =
|
||||
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","m image/jpeg"]],"content":"hi","sig":"x"}"""
|
||||
assertNull(extractFirstMediaUrl(postWithJson(json)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractFirstMediaUrl_picks_first_when_video_precedes_image() {
|
||||
val json =
|
||||
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/v.mp4","m video/mp4"],["imeta","url https://y/a.jpg","m image/jpeg"]],"content":"hi","sig":"x"}"""
|
||||
val result = extractFirstMediaUrl(postWithJson(json))
|
||||
assertTrue(result is MediaUrl.Video)
|
||||
assertEquals("https://x/v.mp4", (result as MediaUrl.Video).url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractEventId_returns_id_for_well_formed_json() {
|
||||
val json =
|
||||
"""{"id":"abc123","pubkey":"p","kind":1,"created_at":0,"tags":[],"content":"hi","sig":"x"}"""
|
||||
assertEquals("abc123", extractEventId(postWithJson(json)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractEventId_returns_null_when_json_malformed() {
|
||||
assertNull(extractEventId(postWithJson("{not json}")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user