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.reqCommand.user.UserFinderQueryState
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
|
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
|
||||||
import com.vitorpamplona.amethyst.service.safeCacheDir
|
import com.vitorpamplona.amethyst.service.safeCacheDir
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
|
||||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||||
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
|
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
|
||||||
import com.vitorpamplona.amethyst.ui.resourceCacheInit
|
import com.vitorpamplona.amethyst.ui.resourceCacheInit
|
||||||
@@ -446,6 +448,11 @@ class AppModules(
|
|||||||
// subscriptions, and NotificationRelayService.
|
// subscriptions, and NotificationRelayService.
|
||||||
val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope)
|
val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope)
|
||||||
|
|
||||||
|
// Local store for posts the user has scheduled to publish later. Backed by a
|
||||||
|
// single JSON file under the app's private filesDir; read by ScheduledPostWorker.
|
||||||
|
val scheduledPostStore =
|
||||||
|
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME))
|
||||||
|
|
||||||
// Organizes cache clearing
|
// Organizes cache clearing
|
||||||
val trimmingService by
|
val trimmingService by
|
||||||
lazy {
|
lazy {
|
||||||
@@ -564,6 +571,12 @@ class AppModules(
|
|||||||
// starts observing LocalCache for notification-worthy events
|
// starts observing LocalCache for notification-worthy events
|
||||||
notificationDispatcher.start()
|
notificationDispatcher.start()
|
||||||
|
|
||||||
|
// Schedule the scheduled-posts worker (periodic + one-time catch-up).
|
||||||
|
// Runs independently of the always-on notification setting so scheduled
|
||||||
|
// posts still fire when always-on notifications are disabled.
|
||||||
|
ScheduledPostWorker.schedule(appContext)
|
||||||
|
ScheduledPostWorker.scheduleCatchUp(appContext)
|
||||||
|
|
||||||
// Watch for account login and start/stop always-on notification service
|
// Watch for account login and start/stop always-on notification service
|
||||||
applicationIOScope.launch {
|
applicationIOScope.launch {
|
||||||
sessionManager.accountContent.collectLatest { state ->
|
sessionManager.accountContent.collectLatest { state ->
|
||||||
|
|||||||
+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.nip86.RelayManagementScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
|
||||||
@@ -304,6 +305,7 @@ fun BuildNavigation(
|
|||||||
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
|
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
|
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.ScheduledPosts> { ScheduledPostsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
|
||||||
|
|||||||
+9
@@ -44,6 +44,7 @@ enum class NavBarItem {
|
|||||||
BOOKMARKS,
|
BOOKMARKS,
|
||||||
WEB_BOOKMARKS,
|
WEB_BOOKMARKS,
|
||||||
DRAFTS,
|
DRAFTS,
|
||||||
|
SCHEDULED_POSTS,
|
||||||
INTEREST_SETS,
|
INTEREST_SETS,
|
||||||
EMOJI_PACKS,
|
EMOJI_PACKS,
|
||||||
WALLET,
|
WALLET,
|
||||||
@@ -142,6 +143,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
|||||||
icon = MaterialSymbols.Drafts,
|
icon = MaterialSymbols.Drafts,
|
||||||
resolveRoute = { Route.Drafts },
|
resolveRoute = { Route.Drafts },
|
||||||
),
|
),
|
||||||
|
NavBarItem.SCHEDULED_POSTS to
|
||||||
|
NavBarItemDef(
|
||||||
|
id = NavBarItem.SCHEDULED_POSTS,
|
||||||
|
labelRes = R.string.scheduled_posts,
|
||||||
|
icon = MaterialSymbols.Schedule,
|
||||||
|
resolveRoute = { Route.ScheduledPosts },
|
||||||
|
),
|
||||||
NavBarItem.INTEREST_SETS to
|
NavBarItem.INTEREST_SETS to
|
||||||
NavBarItemDef(
|
NavBarItemDef(
|
||||||
id = NavBarItem.INTEREST_SETS,
|
id = NavBarItem.INTEREST_SETS,
|
||||||
@@ -291,6 +299,7 @@ val DrawerYouItems: List<NavBarItem> =
|
|||||||
NavBarItem.BOOKMARKS,
|
NavBarItem.BOOKMARKS,
|
||||||
NavBarItem.WEB_BOOKMARKS,
|
NavBarItem.WEB_BOOKMARKS,
|
||||||
NavBarItem.DRAFTS,
|
NavBarItem.DRAFTS,
|
||||||
|
NavBarItem.SCHEDULED_POSTS,
|
||||||
NavBarItem.INTEREST_SETS,
|
NavBarItem.INTEREST_SETS,
|
||||||
NavBarItem.EMOJI_PACKS,
|
NavBarItem.EMOJI_PACKS,
|
||||||
NavBarItem.WALLET,
|
NavBarItem.WALLET,
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object Drafts : Route()
|
@Serializable object Drafts : Route()
|
||||||
|
|
||||||
|
@Serializable object ScheduledPosts : Route()
|
||||||
|
|
||||||
@Serializable object AllSettings : Route()
|
@Serializable object AllSettings : Route()
|
||||||
|
|
||||||
@Serializable object AccountBackup : Route()
|
@Serializable object AccountBackup : Route()
|
||||||
|
|||||||
+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.BOOKMARKS,
|
||||||
NavBarItem.WEB_BOOKMARKS,
|
NavBarItem.WEB_BOOKMARKS,
|
||||||
NavBarItem.DRAFTS,
|
NavBarItem.DRAFTS,
|
||||||
|
NavBarItem.SCHEDULED_POSTS,
|
||||||
NavBarItem.INTEREST_SETS,
|
NavBarItem.INTEREST_SETS,
|
||||||
NavBarItem.EMOJI_PACKS,
|
NavBarItem.EMOJI_PACKS,
|
||||||
NavBarItem.WALLET,
|
NavBarItem.WALLET,
|
||||||
|
|||||||
+35
@@ -52,6 +52,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -61,6 +62,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.content.IntentCompat
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.core.util.Consumer
|
import androidx.core.util.Consumer
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
@@ -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.notify.Notifying
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField
|
import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews
|
import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtButton
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtPicker
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.roundUpToNextQuarterHour
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton
|
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest
|
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
|
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
|
||||||
@@ -404,6 +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) {
|
if (postViewModel.wantsToAddGeoHash) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = CenterVertically,
|
verticalAlignment = CenterVertically,
|
||||||
@@ -656,6 +681,16 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
|
|||||||
postViewModel.toggleExpirationDate()
|
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) {
|
AddGeoHashButton(postViewModel.wantsToAddGeoHash) {
|
||||||
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
|
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
|
||||||
}
|
}
|
||||||
|
|||||||
+39
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.service.ai.WritingAssistantStatus
|
|||||||
import com.vitorpamplona.amethyst.service.ai.WritingResult
|
import com.vitorpamplona.amethyst.service.ai.WritingResult
|
||||||
import com.vitorpamplona.amethyst.service.ai.WritingTone
|
import com.vitorpamplona.amethyst.service.ai.WritingTone
|
||||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||||
|
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
|
||||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||||
@@ -305,6 +306,10 @@ open class ShortNotePostViewModel :
|
|||||||
// Anonymous Reply
|
// Anonymous Reply
|
||||||
var wantsAnonymousPost by mutableStateOf(false)
|
var wantsAnonymousPost by mutableStateOf(false)
|
||||||
|
|
||||||
|
// Scheduled posting: epoch seconds (UTC) when the post should be published.
|
||||||
|
// Null = post immediately on Send (existing behavior).
|
||||||
|
var scheduledForSec by mutableStateOf<Long?>(null)
|
||||||
|
|
||||||
// AI Writing Help for testing
|
// AI Writing Help for testing
|
||||||
private val useMockAi = false
|
private val useMockAi = false
|
||||||
|
|
||||||
@@ -829,8 +834,41 @@ open class ShortNotePostViewModel :
|
|||||||
|
|
||||||
val version = draftTag.current
|
val version = draftTag.current
|
||||||
val anonymous = wantsAnonymousPost
|
val anonymous = wantsAnonymousPost
|
||||||
|
val scheduledFor = scheduledForSec
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
|
if (scheduledFor != null && !anonymous) {
|
||||||
|
// Re-stamp the template with created_at = scheduled time so the post,
|
||||||
|
// when published later, shows up at its scheduled moment in feeds
|
||||||
|
// rather than as N minutes/hours old (= compose time).
|
||||||
|
val rescheduledTemplate =
|
||||||
|
EventTemplate<Event>(
|
||||||
|
createdAt = scheduledFor,
|
||||||
|
kind = template.kind,
|
||||||
|
tags = template.tags,
|
||||||
|
content = template.content,
|
||||||
|
)
|
||||||
|
val (event, relays, extras) = accountViewModel.account.createPostEvent(rescheduledTemplate, extraNotesToBroadcast)
|
||||||
|
Amethyst.instance.scheduledPostStore.add(
|
||||||
|
ScheduledPost(
|
||||||
|
id =
|
||||||
|
java.util.UUID
|
||||||
|
.randomUUID()
|
||||||
|
.toString(),
|
||||||
|
accountPubkey = event.pubKey,
|
||||||
|
signedEventJson = event.toJson(),
|
||||||
|
relayUrls = relays.map { it.url },
|
||||||
|
extraEventsJson = extras.map { it.toJson() },
|
||||||
|
publishAtSec = scheduledFor,
|
||||||
|
createdAtSec = System.currentTimeMillis() / 1000,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
accountViewModel.launchSigner {
|
||||||
|
accountViewModel.account.deleteDraftIgnoreErrors(version)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (anonymous) {
|
if (anonymous) {
|
||||||
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
|
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
|
||||||
} else if (accountViewModel.settings.useTrackedBroadcasts()) {
|
} else if (accountViewModel.settings.useTrackedBroadcasts()) {
|
||||||
@@ -1197,6 +1235,7 @@ open class ShortNotePostViewModel :
|
|||||||
wantsExclusiveGeoPost = false
|
wantsExclusiveGeoPost = false
|
||||||
wantsSecretEmoji = false
|
wantsSecretEmoji = false
|
||||||
wantsAnonymousPost = false
|
wantsAnonymousPost = false
|
||||||
|
scheduledForSec = null
|
||||||
|
|
||||||
forwardZapTo.value = SplitBuilder()
|
forwardZapTo.value = SplitBuilder()
|
||||||
forwardZapToEditting.value = TextFieldValue("")
|
forwardZapToEditting.value = TextFieldValue("")
|
||||||
|
|||||||
+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_button">Move All to New Bookmarks</string>
|
||||||
<string name="migrate_bookmarks_success">Bookmarks migrated successfully</string>
|
<string name="migrate_bookmarks_success">Bookmarks migrated successfully</string>
|
||||||
<string name="drafts">Drafts</string>
|
<string name="drafts">Drafts</string>
|
||||||
|
<string name="scheduled_posts">Scheduled posts</string>
|
||||||
<string name="polls">Polls</string>
|
<string name="polls">Polls</string>
|
||||||
<string name="open_polls">Open</string>
|
<string name="open_polls">Open</string>
|
||||||
<string name="closed_polls">Closed</string>
|
<string name="closed_polls">Closed</string>
|
||||||
|
|||||||
+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