feat(scheduled-posts): warn when scheduling without always-on notifications
feat(scheduled-posts): add screen + drawer entry to view, push, or delete feat(scheduled-posts): use scheduled time as created_at + add diagnostic logs feat(scheduled-posts): add picker UI and toolbar toggle to post composer feat(scheduled-posts): wire schedule branch into ShortNotePostViewModel feat(scheduled-posts): add storage + worker for delayed post publishing
This commit is contained in:
@@ -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.nip95.Nip95CacheFactory
|
||||
import com.vitorpamplona.amethyst.ui.resourceCacheInit
|
||||
@@ -446,6 +448,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 {
|
||||
@@ -564,6 +571,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 ->
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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,
|
||||
)
|
||||
|
||||
data class ScheduledPostFile(
|
||||
val version: Int = 1,
|
||||
val posts: List<ScheduledPost> = emptyList(),
|
||||
)
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* 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 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 updated = mutate(id) { it.copy(status = ScheduledPostStatus.CANCELLED) }
|
||||
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()
|
||||
if (mutate(id) { it.copy(status = ScheduledPostStatus.SENT, lastError = null) }) 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,
|
||||
)
|
||||
}
|
||||
if (updated) persist()
|
||||
updated
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
_flow.value = posts.toList()
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
val snapshot = posts.toList()
|
||||
_flow.value = snapshot
|
||||
val parent = storageFile.parentFile
|
||||
if (parent != null && !parent.exists()) parent.mkdirs()
|
||||
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
|
||||
try {
|
||||
mapper.writeValue(tmp, ScheduledPostFile(version = 1, posts = snapshot))
|
||||
if (!tmp.renameTo(storageFile)) {
|
||||
storageFile.delete()
|
||||
if (!tmp.renameTo(storageFile)) {
|
||||
Log.e(TAG, "Failed to rename $tmp to $storageFile")
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to persist scheduled posts to $storageFile", e)
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ScheduledPostStore"
|
||||
const val FILE_NAME = "scheduled_posts.json"
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* 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.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
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"
|
||||
|
||||
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)
|
||||
|
||||
store.markSent(post.id)
|
||||
Log.d(TAG) { "client.publish(${post.id}) done; marked SENT" }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to publish scheduled post ${post.id}", e)
|
||||
store.markFailed(post.id, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG) { "doWork() EXIT success" }
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "doWork() unexpected failure", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -205,6 +205,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object Drafts : Route()
|
||||
|
||||
@Serializable object ScheduledPosts : Route()
|
||||
|
||||
@Serializable object AllSettings : Route()
|
||||
|
||||
@Serializable object AccountBackup : Route()
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
|
||||
@Composable
|
||||
fun ScheduleAtButton(
|
||||
isActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(onClick = { onClick() }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Schedule,
|
||||
contentDescription = if (isActive) "Cancel scheduling" else "Schedule post",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (isActive) Color(0xFF1E88E5) else MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.scheduling
|
||||
|
||||
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.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.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* 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 timePickerState =
|
||||
rememberTimePickerState(
|
||||
initialHour = currentTime.hour,
|
||||
initialMinute = currentTime.minute,
|
||||
is24Hour = false,
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 5.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Timer,
|
||||
contentDescription = "Scheduled time",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color(0xFF1E88E5),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Schedule",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
|
||||
Text(
|
||||
text = "Posts publish within ~15 minutes of the scheduled time.",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
)
|
||||
|
||||
if (!alwaysOnEnabled) {
|
||||
ReliabilityWarning(hasMultipleAccounts = hasMultipleAccounts)
|
||||
}
|
||||
|
||||
OutlinedCard(
|
||||
onClick = { showDatePicker = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(MaterialSymbols.Timer, contentDescription = "Pick scheduled time")
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
if (scheduledForSec < TimeUtils.oneMinuteFromNow()) {
|
||||
Text("Schedule for…", style = MaterialTheme.typography.bodyLarge)
|
||||
} else {
|
||||
Text(
|
||||
text = "Publishes in ${timeAheadNoDot(scheduledForSec, context)}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDatePicker) {
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showDatePicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showDatePicker = false
|
||||
showTimePicker = true
|
||||
}) { Text("Next") }
|
||||
},
|
||||
) {
|
||||
DatePicker(state = datePickerState)
|
||||
}
|
||||
}
|
||||
|
||||
if (showTimePicker) {
|
||||
TimePickerDialog(
|
||||
title = { Text("Time") },
|
||||
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("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 = "Always-on notifications disabled",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
if (hasMultipleAccounts) {
|
||||
"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."
|
||||
} else {
|
||||
"Scheduled posts may not publish until you next reopen the app. Enable always-on in Settings → UI Preferences for reliable background scheduling."
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
+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,
|
||||
|
||||
+35
@@ -52,6 +52,7 @@ 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.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -61,6 +62,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
|
||||
@@ -97,6 +99,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 +409,26 @@ private fun NewPostScreenBody(
|
||||
}
|
||||
}
|
||||
|
||||
postViewModel.scheduledForSec?.let { current ->
|
||||
val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService
|
||||
.collectAsStateWithLifecycle()
|
||||
val savedAccounts by com.vitorpamplona.amethyst.LocalPreferences
|
||||
.accountsFlow()
|
||||
.collectAsStateWithLifecycle()
|
||||
val hasMultipleAccounts = (savedAccounts?.size ?: 0) > 1
|
||||
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,
|
||||
@@ -656,6 +681,16 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
|
||||
postViewModel.toggleExpirationDate()
|
||||
}
|
||||
|
||||
ScheduleAtButton(postViewModel.scheduledForSec != null) {
|
||||
postViewModel.scheduledForSec =
|
||||
if (postViewModel.scheduledForSec != null) {
|
||||
null
|
||||
} else {
|
||||
// Default to 1 hour from now, rounded up to the next 15-min slot
|
||||
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
|
||||
}
|
||||
}
|
||||
|
||||
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("")
|
||||
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* 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.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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::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 posts by viewModel.posts.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
var pendingPublishId by remember { mutableStateOf<String?>(null) }
|
||||
var pendingCancelId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ShorterTopAppBar(
|
||||
title = { Text("Scheduled posts") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (posts.isEmpty()) {
|
||||
EmptyState(modifier = Modifier.padding(padding))
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = PaddingValues(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(posts, key = { it.id }) { post ->
|
||||
ScheduledPostRow(
|
||||
post = post,
|
||||
onPublishNow = { pendingPublishId = post.id },
|
||||
onCancel = { pendingCancelId = post.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingPublishId?.let { id ->
|
||||
ConfirmDialog(
|
||||
title = "Send now?",
|
||||
message = "This post will publish to relays immediately. The original schedule will be discarded.",
|
||||
confirmLabel = "Send",
|
||||
onConfirm = {
|
||||
viewModel.publishNow(id, context)
|
||||
pendingPublishId = null
|
||||
},
|
||||
onDismiss = { pendingPublishId = null },
|
||||
)
|
||||
}
|
||||
|
||||
pendingCancelId?.let { id ->
|
||||
ConfirmDialog(
|
||||
title = "Delete scheduled post?",
|
||||
message = "The post will not be published. This cannot be undone.",
|
||||
confirmLabel = "Delete",
|
||||
destructive = true,
|
||||
onConfirm = {
|
||||
viewModel.cancel(id)
|
||||
pendingCancelId = null
|
||||
},
|
||||
onDismiss = { pendingCancelId = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduledPostRow(
|
||||
post: ScheduledPost,
|
||||
onPublishNow: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.outlinedCardColors(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
StatusChip(post.status)
|
||||
Text(
|
||||
text = formatPublishMoment(post.publishAtSec, context),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = extractPreview(post),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (post.status == ScheduledPostStatus.FAILED && post.lastError != null) {
|
||||
Text(
|
||||
text = "Error: ${post.lastError}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Delete,
|
||||
contentDescription = "Delete",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onPublishNow) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.Send,
|
||||
contentDescription = "Send now",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusChip(status: ScheduledPostStatus) {
|
||||
val (label, tint) =
|
||||
when (status) {
|
||||
ScheduledPostStatus.PENDING -> "Scheduled" to Color(0xFF1E88E5)
|
||||
ScheduledPostStatus.PUBLISHING -> "Sending…" to Color(0xFFFFA000)
|
||||
ScheduledPostStatus.FAILED -> "Failed" to MaterialTheme.colorScheme.error
|
||||
ScheduledPostStatus.SENT -> "Sent" to Color(0xFF43A047)
|
||||
ScheduledPostStatus.CANCELLED -> "Cancelled" to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(label, fontWeight = FontWeight.Medium) },
|
||||
colors =
|
||||
AssistChipDefaults.assistChipColors(
|
||||
labelColor = tint,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Schedule,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "No scheduled posts",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Compose a note and tap the clock icon to schedule it for later.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
confirmLabel: String,
|
||||
destructive: Boolean = false,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(
|
||||
confirmLabel,
|
||||
color = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractPreview(post: ScheduledPost): String {
|
||||
val json = post.signedEventJson
|
||||
val needle = "\"content\":\""
|
||||
val start = json.indexOf(needle)
|
||||
if (start < 0) return ""
|
||||
val from = start + needle.length
|
||||
val sb = StringBuilder()
|
||||
var i = from
|
||||
while (i < json.length) {
|
||||
val c = json[i]
|
||||
if (c == '\\' && i + 1 < json.length) {
|
||||
when (json[i + 1]) {
|
||||
'n' -> sb.append('\n')
|
||||
't' -> sb.append('\t')
|
||||
'\\' -> sb.append('\\')
|
||||
'"' -> sb.append('"')
|
||||
else -> sb.append(json[i + 1])
|
||||
}
|
||||
i += 2
|
||||
} else if (c == '"') {
|
||||
break
|
||||
} else {
|
||||
sb.append(c)
|
||||
i++
|
||||
}
|
||||
if (sb.length > 200) break
|
||||
}
|
||||
return sb.toString().trim()
|
||||
}
|
||||
|
||||
private fun formatPublishMoment(
|
||||
publishAtSec: Long,
|
||||
context: android.content.Context,
|
||||
): String {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val deltaSec = publishAtSec - nowSec
|
||||
return when {
|
||||
deltaSec > 0 -> {
|
||||
"Publishes in ${timeAheadNoDot(publishAtSec, context)}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
val ago = -deltaSec
|
||||
val mins = TimeUnit.SECONDS.toMinutes(ago)
|
||||
when {
|
||||
mins < 1 -> "Due now"
|
||||
mins < 60 -> "Was due ${mins}m ago"
|
||||
else -> "Was due ${TimeUnit.SECONDS.toHours(ago)}h ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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.content.Context
|
||||
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 com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
|
||||
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
|
||||
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
|
||||
val posts: StateFlow<List<ScheduledPost>> =
|
||||
store.flow
|
||||
.map { all ->
|
||||
all
|
||||
.filter { it.accountPubkey == accountPubkey && it.status in activeStatuses }
|
||||
.sortedBy { it.publishAtSec }
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
fun cancel(id: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
store.cancel(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun publishNow(
|
||||
id: String,
|
||||
context: Context,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (store.publishNow(id)) {
|
||||
ScheduledPostWorker.scheduleCatchUp(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(accountPubkey: String): ScheduledPostsViewModel =
|
||||
ScheduledPostsViewModel(
|
||||
store = Amethyst.instance.scheduledPostStore,
|
||||
accountPubkey = accountPubkey,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -428,6 +428,7 @@
|
||||
<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="polls">Polls</string>
|
||||
<string name="open_polls">Open</string>
|
||||
<string name="closed_polls">Closed</string>
|
||||
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* 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() = ScheduledPostStore(file)
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user