diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index e0e0dc6d04..7cb3243471 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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 -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt new file mode 100644 index 0000000000..320782d470 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.scheduledposts + +enum class ScheduledPostStatus { + PENDING, + PUBLISHING, + SENT, + FAILED, + CANCELLED, +} + +data class ScheduledPost( + val id: String, + val accountPubkey: String, + val signedEventJson: String, + val relayUrls: List, + val extraEventsJson: List, + val publishAtSec: Long, + val createdAtSec: Long, + val status: ScheduledPostStatus = ScheduledPostStatus.PENDING, + val lastAttemptAtSec: Long? = null, + val attemptCount: Int = 0, + val lastError: String? = null, + // Set when the row enters a terminal state (SENT/CANCELLED). Drives retention. + val terminatedAtSec: Long? = null, +) + +data class ScheduledPostFile( + val version: Int = 1, + val posts: List = emptyList(), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt new file mode 100644 index 0000000000..37f57c45e6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.scheduledposts + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.extractContentPreview +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * Posts user-visible system notifications when a scheduled post completes + * (sent or failed). Without this, a worker firing in background offers zero + * diagnostic to the user — see the silent-publish bug the ack-aware worker + * now guards against; the notification closes the loop. + */ +object ScheduledPostNotifier { + // @Volatile so the channel reference is visible across the WorkManager IO + // thread pool — two workers firing in the same window can race on + // ensureChannel. createNotificationChannel itself is idempotent. + @Volatile + private var channel: NotificationChannel? = null + private const val SCHEDULED_POST_NOT_ID_BASE = 0x70000 + + fun notifySent( + context: Context, + post: ScheduledPost, + ) { + ensureChannel(context) + post( + context = context, + notId = idFor(post.id), + title = stringRes(context, R.string.scheduled_posts_notification_sent_title), + body = extractContentPreview(post, 120), + ) + } + + fun notifyFailed( + context: Context, + post: ScheduledPost, + error: String?, + ) { + ensureChannel(context) + val snippet = extractContentPreview(post, 120) + val body = + if (error.isNullOrBlank()) { + snippet + } else { + "$snippet\n${stringRes(context, R.string.scheduled_posts_error_prefix, error)}" + } + post( + context = context, + notId = idFor(post.id), + title = stringRes(context, R.string.scheduled_posts_notification_failed_title), + body = body, + ) + } + + private fun post( + context: Context, + notId: Int, + title: String, + body: String, + ) { + val channelId = stringRes(context, R.string.app_notification_scheduled_posts_channel_id) + val tapIntent = + Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + val tapPendingIntent = + PendingIntent.getActivity( + context, + notId, + tapIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val builder = + NotificationCompat + .Builder(context, channelId) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(title) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setContentIntent(tapPendingIntent) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setAutoCancel(true) + .setWhen(System.currentTimeMillis()) + // Silently no-ops on Android 13+ if POST_NOTIFICATIONS isn't granted. + NotificationManagerCompat.from(context).notify(notId, builder.build()) + } + + private fun ensureChannel(context: Context) { + if (channel != null) return + channel = + NotificationChannel( + stringRes(context, R.string.app_notification_scheduled_posts_channel_id), + stringRes(context, R.string.app_notification_scheduled_posts_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = stringRes(context, R.string.app_notification_scheduled_posts_channel_description) + } + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.createNotificationChannel(channel!!) + } + + // Distinct id per post so multiple completions don't collapse onto one row. + private fun idFor(postId: String): Int = SCHEDULED_POST_NOT_ID_BASE xor postId.hashCode() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt new file mode 100644 index 0000000000..325ad10ab9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt @@ -0,0 +1,274 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.scheduledposts + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File + +class ScheduledPostStore( + private val storageFile: File, + private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 }, +) { + private val mapper = + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + private val mutex = Mutex() + private var loaded = false + private var posts: MutableList = mutableListOf() + + private val _flow = MutableStateFlow>(emptyList()) + + /** Live snapshot of all stored posts. Updated on every mutation. */ + val flow: StateFlow> = _flow.asStateFlow() + + suspend fun add(post: ScheduledPost) = + mutex.withLock { + ensureLoaded() + posts.add(post) + persist() + } + + suspend fun cancel(id: String): Boolean = + mutex.withLock { + ensureLoaded() + val now = nowSec() + val updated = mutate(id) { it.copy(status = ScheduledPostStatus.CANCELLED, terminatedAtSec = now) } + if (updated) persist() + updated + } + + suspend fun list(): List = + mutex.withLock { + ensureLoaded() + posts.toList() + } + + suspend fun listFor(accountPubkey: String): List = + 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 = + mutex.withLock { + ensureLoaded() + val dueIds = + posts + .filter { it.status == ScheduledPostStatus.PENDING && it.publishAtSec <= nowSec } + .map { it.id } + .toSet() + if (dueIds.isEmpty()) return@withLock emptyList() + dueIds.forEach { id -> + mutate(id) { + it.copy( + status = ScheduledPostStatus.PUBLISHING, + lastAttemptAtSec = nowSec, + attemptCount = it.attemptCount + 1, + ) + } + } + persist() + posts.filter { it.id in dueIds } + } + + suspend fun markSent(id: String) = + mutex.withLock { + ensureLoaded() + val now = nowSec() + if (mutate(id) { it.copy(status = ScheduledPostStatus.SENT, lastError = null, terminatedAtSec = now) }) persist() + } + + suspend fun markFailed( + id: String, + error: String?, + ) = mutex.withLock { + ensureLoaded() + if (mutate(id) { it.copy(status = ScheduledPostStatus.FAILED, lastError = error) }) persist() + } + + /** + * Force a post to publish immediately by setting its publishAtSec to [nowSec] + * and resetting status to PENDING. Handles two cases with one method: + * - PENDING (future-scheduled): user wants to push it out now + * - FAILED: user wants to retry + * Caller is expected to enqueue ScheduledPostWorker.scheduleCatchUp() afterwards + * so the worker picks it up promptly. + */ + suspend fun publishNow( + id: String, + nowSec: Long = System.currentTimeMillis() / 1000, + ): Boolean = + mutex.withLock { + ensureLoaded() + val updated = + mutate(id) { + it.copy( + publishAtSec = nowSec, + status = ScheduledPostStatus.PENDING, + lastError = null, + terminatedAtSec = null, + ) + } + if (updated) persist() + updated + } + + /** + * Remove every row owned by [accountPubkey]. Used when the user deletes + * an account — the account's signed events should not linger. Returns the + * number of rows removed; persists once if any rows matched. + */ + suspend fun removeForAccount(accountPubkey: String): Int = + mutex.withLock { + ensureLoaded() + val before = posts.size + val removed = posts.removeAll { it.accountPubkey == accountPubkey } + val count = before - posts.size + if (removed) persist() + count + } + + /** + * Revert a PUBLISHING claim back to PENDING (e.g. when the account is not + * loaded at fire time, so we should retry on the next cycle rather than + * marking the post failed permanently). + */ + suspend fun releaseClaim(id: String) = + mutex.withLock { + ensureLoaded() + val changed = + mutate(id) { + if (it.status == ScheduledPostStatus.PUBLISHING) { + it.copy(status = ScheduledPostStatus.PENDING) + } else { + it + } + } + if (changed) persist() + } + + private fun mutate( + id: String, + transform: (ScheduledPost) -> ScheduledPost, + ): Boolean { + val idx = posts.indexOfFirst { it.id == id } + if (idx < 0) return false + val before = posts[idx] + val after = transform(before) + if (after == before) return false + posts[idx] = after + return true + } + + private fun ensureLoaded() { + if (loaded) return + posts = + try { + if (storageFile.exists() && storageFile.length() > 0) { + mapper.readValue(storageFile).posts.toMutableList() + } else { + mutableListOf() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load scheduled posts from $storageFile", e) + mutableListOf() + } + loaded = true + val purged = purgeStale(nowSec()) + _flow.value = posts.toList() + if (purged) persist() + } + + /** + * Drop SENT rows older than [SENT_RETENTION_SEC] and CANCELLED rows older + * than [CANCELLED_RETENTION_SEC]. Returns true if any row was removed. + * FAILED rows are kept indefinitely so the user can still see and retry them; + * PENDING / PUBLISHING rows are never purged. + */ + private fun purgeStale(now: Long): Boolean { + val before = posts.size + posts.removeAll { post -> + // terminatedAtSec is the post-PR field. Legacy rows lack it; fall back to + // lastAttemptAtSec (set at SENT) and finally createdAtSec. The fallback + // can purge old CANCELLED rows up to 30d earlier than intended on first + // run after upgrade — self-healing once new rows are written. + val age = now - (post.terminatedAtSec ?: post.lastAttemptAtSec ?: post.createdAtSec) + when (post.status) { + ScheduledPostStatus.SENT -> age > SENT_RETENTION_SEC + ScheduledPostStatus.CANCELLED -> age > CANCELLED_RETENTION_SEC + else -> false + } + } + return posts.size < before + } + + /** + * Writes the snapshot to disk *while holding the data mutex*. This is a + * deliberate tradeoff: moving the write outside the lock would require a + * separate write-mutex (or a sequence number) to preserve write ordering + * across concurrent mutations — otherwise an older snapshot can clobber a + * newer one if the OS schedules the second write to finish first. For a + * file that's a few KB and a single-process owner with infrequent writes, + * holding the mutex across the rename is the simpler and correct choice. + * Revisit if the store ever grows past a hundred rows or starts seeing + * concurrent multi-writer pressure. + */ + private fun persist() { + val snapshot = posts.toList() + _flow.value = snapshot + storageFile.parentFile?.mkdirs() + val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") + try { + mapper.writeValue(tmp, ScheduledPostFile(version = 1, posts = snapshot)) + if (!tmp.renameTo(storageFile)) { + 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" + private const val SENT_RETENTION_SEC = 7L * 24 * 3600 + private const val CANCELLED_RETENTION_SEC = 30L * 24 * 3600 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt new file mode 100644 index 0000000000..d32293035b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.scheduledposts + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import java.util.concurrent.TimeUnit + +/** + * Scans the scheduled-post store and publishes posts whose publish time has arrived. + * + * - schedule(context): periodic, every 15 min (WorkManager minimum). + * - scheduleCatchUp(context): one-time, on app start, to flush posts that came + * due while the device was off or while WorkManager + * was deferred by Doze. + */ +class ScheduledPostWorker( + appContext: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + init { + // Logs every worker instantiation. Without this, "doWork never ran" looks + // identical to "constructor never invoked" — and the latter means the OS + // (Doze, battery-opt, JobScheduler quotas) never woke us at all. + Log.d(TAG) { "Worker instantiated (runAttempt=${workerParams.runAttemptCount}, tags=${workerParams.tags})" } + } + + companion object { + private const val TAG = "ScheduledPostWorker" + private const val WORK_NAME = "scheduled_post_worker" + private const val WORK_NAME_CATCH_UP = "scheduled_post_worker_catch_up" + private const val OK_TIMEOUT_SEC = 30L + private const val OK_POLL_MS = 500L + + fun schedule(context: Context) { + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = + PeriodicWorkRequestBuilder(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() + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME_CATCH_UP, + ExistingWorkPolicy.KEEP, + request, + ) + Log.d(TAG) { "scheduleCatchUp(): enqueueUniqueWork($WORK_NAME_CATCH_UP, KEEP)" } + } + + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_CATCH_UP) + Log.d(TAG) { "cancel(): cancelled both periodic and catch-up workers" } + } + } + + override suspend fun doWork(): Result { + val nowSec = System.currentTimeMillis() / 1000 + Log.d(TAG) { "doWork() ENTER nowSec=$nowSec runAttempt=$runAttemptCount tags=$tags" } + + return try { + val appModules = Amethyst.instance + val store = appModules.scheduledPostStore + + val all = store.list() + val pending = all.count { it.status == ScheduledPostStatus.PENDING } + Log.d(TAG) { "doWork() store has ${all.size} total, $pending PENDING" } + + val claimed = store.claimDuePosts(nowSec) + if (claimed.isEmpty()) { + Log.d(TAG) { "doWork() EXIT no posts due" } + return Result.success() + } + + Log.d(TAG) { "doWork() claimed ${claimed.size} due post(s)" } + + for (post in claimed) { + val ageSec = nowSec - post.publishAtSec + Log.d(TAG) { + "Publishing post id=${post.id} publishAtSec=${post.publishAtSec} ageSec=$ageSec relays=${post.relayUrls.size}" + } + val account = appModules.accountsCache.accounts.value[post.accountPubkey] + if (account == null) { + Log.w(TAG, "Account ${post.accountPubkey} not loaded; releasing ${post.id} for retry") + store.releaseClaim(post.id) + continue + } + + try { + val event = Event.fromJson(post.signedEventJson) + val relays = post.relayUrls.map { NormalizedRelayUrl(it) }.toSet() + val extras = post.extraEventsJson.map { Event.fromJson(it) } + + Log.d(TAG) { "client.publish(${post.id}) starting on ${relays.size} relay(s)" } + account.client.publish(event, relays) + account.consumePostEvent(event, relays, extras) + + val acks = waitForOk(account.client, event.id, relays.size) + if (acks > 0) { + store.markSent(post.id) + ScheduledPostNotifier.notifySent(applicationContext, post) + Log.d(TAG) { "client.publish(${post.id}) acked by $acks/${relays.size}; marked SENT" } + } else { + val msg = "no relay acknowledged within ${OK_TIMEOUT_SEC}s" + store.markFailed(post.id, msg) + ScheduledPostNotifier.notifyFailed(applicationContext, post, msg) + Log.w(TAG, "client.publish(${post.id}) failed: $msg") + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to publish scheduled post ${post.id}", e) + store.markFailed(post.id, e.message) + ScheduledPostNotifier.notifyFailed(applicationContext, post, e.message) + } + } + + Log.d(TAG) { "doWork() EXIT success" } + Result.success() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "doWork() unexpected failure", e) + Result.retry() + } + } + + /** + * Polls [INostrClient.pendingPublishRelaysFor] until at least one relay sends + * an OK ack or [OK_TIMEOUT_SEC] elapses. Returns the number of relays that + * acked. The publish call itself is fire-and-forget; without this wait the + * worker can be torn down before the websocket finishes delivery. + */ + private suspend fun waitForOk( + client: INostrClient, + eventId: String, + totalRelays: Int, + ): Int { + val deadline = System.currentTimeMillis() + OK_TIMEOUT_SEC * 1000 + while (System.currentTimeMillis() < deadline) { + val pending = client.pendingPublishRelaysFor(eventId) + // null means the outbox dropped the entry — every relay either OK'd + // or hit the discard cap (replaced/pow/deleted/invalid). Treat as full ack. + if (pending == null) return totalRelays + val acked = totalRelays - pending.size + if (acked > 0) return acked + delay(OK_POLL_MS) + } + val pending = client.pendingPublishRelaysFor(eventId) + return if (pending == null) totalRelays else totalRelays - pending.size + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt index 8e8dc20d35..c528a4ed22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt @@ -81,6 +81,7 @@ fun SwipeToDeleteContainer( fun SwipeToDeleteWithConfirmation( modifier: Modifier = Modifier, onDelete: () -> Unit, + confirmLabelRes: Int = R.string.request_deletion, content: @Composable (RowScope.() -> Unit), ) { val scope = rememberCoroutineScope() @@ -103,6 +104,7 @@ fun SwipeToDeleteWithConfirmation( onCancel = { scope.launch { dismissState.reset() } }, + confirmLabelRes = confirmLabelRes, ) }, enableDismissFromEndToStart = true, @@ -156,6 +158,7 @@ fun ConfirmDeleteBackground( dismissState: SwipeToDismissBoxState, onConfirmDelete: () -> Unit, onCancel: () -> Unit, + confirmLabelRes: Int = R.string.request_deletion, ) { val settled = dismissState.currentValue == Settled && dismissState.targetValue == Settled @@ -163,7 +166,7 @@ fun ConfirmDeleteBackground( if (!settled) { Color(0xFFFF1744) } else { - MaterialTheme.colorScheme.surfaceVariant + Color.Transparent }, label = "ConfirmDeleteBackground", ) @@ -195,12 +198,12 @@ fun ConfirmDeleteBackground( ) { Icon( MaterialSymbols.Delete, - contentDescription = stringRes(id = R.string.request_deletion), + contentDescription = stringRes(id = confirmLabelRes), tint = Color.White, ) Spacer(modifier = Modifier.padding(horizontal = 4.dp)) Text( - text = stringRes(id = R.string.request_deletion), + text = stringRes(id = confirmLabelRes), color = Color.White, style = MaterialTheme.typography.titleMedium, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 7872e6cbea..dfa37caf3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { PinnedNotesScreen(accountViewModel, nav) } composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } + composableFromEnd { ScheduledPostsScreen(accountViewModel, nav) } composableFromEnd { SettingsScreen(accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 85bf299b70..0d78384598 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -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 = 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.BOOKMARKS, NavBarItem.WEB_BOOKMARKS, NavBarItem.DRAFTS, + NavBarItem.SCHEDULED_POSTS, NavBarItem.INTEREST_SETS, NavBarItem.EMOJI_PACKS, NavBarItem.WALLET, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt index 3560b2f1f2..cd818fcd84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt @@ -46,11 +46,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.AccountInfo +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -58,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.note.toShortDisplay @@ -261,16 +264,58 @@ private fun LogoutButton( accountSessionManager: AccountSessionManager, ) { var logoutDialog by remember { mutableStateOf(false) } + val context = LocalContext.current if (logoutDialog) { + val accountHex = remember(acc) { decodePublicKeyAsHexOrNull(acc.npub) } + val allPosts by Amethyst.instance.scheduledPostStore.flow + .collectAsStateWithLifecycle() + val unpublishedCount by remember(accountHex) { + derivedStateOf { + if (accountHex == null) { + 0 + } else { + allPosts.count { + it.accountPubkey == accountHex && + ( + it.status == ScheduledPostStatus.PENDING || + it.status == ScheduledPostStatus.PUBLISHING || + it.status == ScheduledPostStatus.FAILED + ) + } + } + } + } AlertDialog( title = { Text(text = stringRes(R.string.log_out)) }, - text = { Text(text = stringRes(R.string.are_you_sure_you_want_to_log_out)) }, + text = { + if (unpublishedCount > 0) { + Text(text = stringRes(R.string.scheduled_posts_logout_warning, unpublishedCount)) + } else { + Text(text = stringRes(R.string.are_you_sure_you_want_to_log_out)) + } + }, onDismissRequest = { logoutDialog = false }, confirmButton = { TextButton( onClick = { + // Snapshot the count *now* so the user-facing Toast matches what + // the dialog displayed, even if the store mutates between this + // tap and the cleanup completing. + val confirmedCount = unpublishedCount logoutDialog = false + // Guard against a malformed npub: skip the Toast so we don't + // claim "Logged out" when logOff's coroutine bails early. + if (accountHex == null) return@TextButton accountSessionManager.logOff(acc) + val toastMessage = + if (confirmedCount > 0) { + stringRes(context, R.string.scheduled_posts_logout_toast, confirmedCount) + } else { + stringRes(context, R.string.scheduled_posts_logout_toast_zero) + } + android.widget.Toast + .makeText(context, toastMessage, android.widget.Toast.LENGTH_SHORT) + .show() }, ) { Text(text = stringRes(R.string.log_out)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index e208de7e21..a4ba397054 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -49,6 +49,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Badge import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -84,6 +85,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -97,6 +99,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems @@ -604,12 +607,86 @@ fun CatalogSection( ids.forEach { id -> NavBarCatalog[id]?.let { def -> val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground - CatalogNavigationRow(def, tint, accountViewModel, nav) + if (def.id == NavBarItem.SCHEDULED_POSTS) { + ScheduledPostsNavigationRow(def, tint, accountViewModel, nav) + } else { + CatalogNavigationRow(def, tint, accountViewModel, nav) + } } } } } +@Composable +private fun ScheduledPostsNavigationRow( + def: NavBarItemDef, + tint: Color, + accountViewModel: AccountViewModel, + nav: INav, +) { + val accountHex = accountViewModel.account.signer.pubKey + val allPosts by Amethyst.instance.scheduledPostStore.flow + .collectAsStateWithLifecycle() + val pendingCount by remember(accountHex) { + derivedStateOf { + allPosts.count { + it.accountPubkey == accountHex && + ( + it.status == ScheduledPostStatus.PENDING || + it.status == ScheduledPostStatus.PUBLISHING || + it.status == ScheduledPostStatus.FAILED + ) + } + } + } + IconRowWithBadge( + title = def.labelRes, + icon = def.icon, + tint = tint, + badgeCount = pendingCount, + onClick = { + nav.closeDrawer() + nav.nav { def.resolveRoute(accountViewModel) } + }, + ) +} + +@Composable +private fun IconRowWithBadge( + title: Int, + icon: MaterialSymbol, + tint: Color, + badgeCount: Int, + onClick: () -> Unit, +) { + val titleStr = stringRes(title) + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable( + onClick = onClick, + onClickLabel = titleStr, + ).padding(vertical = 15.dp, horizontal = 25.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = icon, + contentDescription = titleStr, + modifier = Size22ModifierWith4Padding, + tint = tint, + ) + Text( + modifier = IconRowTextModifier, + text = titleStr, + fontSize = Font18SP, + ) + if (badgeCount > 0) { + Badge { Text(badgeCount.toString()) } + } + } +} + @Composable fun CatalogNavigationRow( def: NavBarItemDef, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index d414e8ad55..3d7127083b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -205,6 +205,8 @@ sealed class Route { @Serializable object Drafts : Route() + @Serializable object ScheduledPosts : Route() + @Serializable object AllSettings : Route() @Serializable object AccountBackup : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtButton.kt new file mode 100644 index 0000000000..1c5440e9b6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtButton.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.scheduling + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun ScheduleAtButton( + isActive: Boolean, + onClick: () -> Unit, +) { + IconButton(onClick = onClick) { + Icon( + symbol = MaterialSymbols.Schedule, + contentDescription = + stringRes( + if (isActive) R.string.schedule_post_button_remove else R.string.schedule_post_button_add, + ), + modifier = Modifier.size(20.dp), + tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt new file mode 100644 index 0000000000..fc90827347 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.scheduling + +import android.text.format.DateFormat +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.utils.TimeUtils +import java.time.DayOfWeek +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.temporal.TemporalAdjusters + +/** + * Two-stage date + time picker for scheduling a post for future publication. + * + * The selected time is rounded up to the next quarter-hour to set realistic + * expectations: the periodic worker that fires scheduled posts runs at + * WorkManager's minimum 15-min interval, so per-minute precision is misleading. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduleAtPicker( + scheduledForSec: Long, + onChanged: (Long) -> Unit, + alwaysOnEnabled: Boolean = true, + hasMultipleAccounts: Boolean = false, +) { + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + + val currentTime = + Instant + .ofEpochMilli(scheduledForSec * 1000) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + + val datePickerState = + rememberDatePickerState( + initialSelectedDateMillis = scheduledForSec * 1000, + yearRange = currentTime.year..2050, + selectableDates = + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= System.currentTimeMillis() - 86_400_000 + }, + ) + + val context = LocalContext.current + + val timePickerState = + rememberTimePickerState( + initialHour = currentTime.hour, + initialMinute = currentTime.minute, + is24Hour = DateFormat.is24HourFormat(context), + ) + + Column(Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 5.dp), + ) { + Icon( + symbol = MaterialSymbols.Timer, + contentDescription = stringRes(R.string.schedule_post_time_label), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Text( + text = stringRes(R.string.schedule_post), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + } + + HorizontalDivider(thickness = DividerThickness) + + Text( + text = stringRes(R.string.schedule_post_helper), + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), + ) + + if (!alwaysOnEnabled) { + ReliabilityWarning(hasMultipleAccounts = hasMultipleAccounts) + } + + PresetChips(onPick = onChanged) + + OutlinedCard( + onClick = { showDatePicker = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(MaterialSymbols.Timer, contentDescription = stringRes(R.string.schedule_post_pick_time)) + Spacer(Modifier.width(12.dp)) + + if (scheduledForSec < TimeUtils.oneMinuteFromNow()) { + Text(stringRes(R.string.schedule_post_pick_label), style = MaterialTheme.typography.bodyLarge) + } else { + Text( + text = stringRes(R.string.schedule_post_publishes_in, timeAheadNoDot(scheduledForSec, context)), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + } + + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + showDatePicker = false + showTimePicker = true + }) { Text(stringRes(R.string.next)) } + }, + ) { + DatePicker(state = datePickerState) + } + } + + if (showTimePicker) { + TimePickerDialog( + title = { Text(stringRes(R.string.schedule_post_picker_time_title)) }, + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton( + onClick = { + val datetimeLocalTimeZone = + datePickerState.selectedDateMillis?.let { localDayAtZeroHourMillis -> + (localDayAtZeroHourMillis / 1000) + + (timePickerState.hour * TimeUtils.ONE_HOUR) + + (timePickerState.minute * TimeUtils.ONE_MINUTE) + } ?: TimeUtils.oneDayAhead() + + val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) + val rawSec = datetimeLocalTimeZone - offset.totalSeconds + + onChanged(roundUpToNextQuarterHour(rawSec)) + showTimePicker = false + }, + ) { Text(stringRes(R.string.confirm)) } + }, + ) { + TimePicker(state = timePickerState) + } + } +} + +@Composable +private fun ReliabilityWarning(hasMultipleAccounts: Boolean) { + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.Timer, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringRes(R.string.schedule_post_warning_title), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(start = 8.dp), + ) + } + Text( + text = + stringRes( + if (hasMultipleAccounts) { + R.string.schedule_post_warning_multi + } else { + R.string.schedule_post_warning_single + }, + ), + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(top = 6.dp), + ) + } + } +} + +@Composable +private fun PresetChips(onPick: (Long) -> Unit) { + val scroll = rememberScrollState() + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(scroll) + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AssistChip( + onClick = { onPick(roundUpToNextQuarterHour(presetInOneHour())) }, + label = { Text(stringRes(R.string.schedule_post_preset_in_one_hour)) }, + ) + AssistChip( + onClick = { onPick(roundUpToNextQuarterHour(presetTomorrowMorning())) }, + label = { Text(stringRes(R.string.schedule_post_preset_tomorrow_morning)) }, + ) + AssistChip( + onClick = { onPick(roundUpToNextQuarterHour(presetNextMondayMorning())) }, + label = { Text(stringRes(R.string.schedule_post_preset_next_monday_morning)) }, + ) + } +} + +private fun presetInOneHour(): Long = (System.currentTimeMillis() / 1000) + 3600 + +private fun presetTomorrowMorning(): Long { + val zone = ZoneId.systemDefault() + val tomorrow9am = LocalDate.now(zone).plusDays(1).atTime(LocalTime.of(9, 0)) + return tomorrow9am.atZone(zone).toEpochSecond() +} + +private fun presetNextMondayMorning(): Long { + val zone = ZoneId.systemDefault() + // Always step at least one day forward — if today is Monday, return next Monday. + val target = + LocalDate + .now(zone) + .plusDays(1) + .with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY)) + .atTime(LocalTime.of(9, 0)) + return target.atZone(zone).toEpochSecond() +} + +/** + * Rounds [epochSec] up to the next 15-minute boundary. If already on a boundary, + * returns the boundary itself. Edge case: if rounding yields a moment in the past + * (rare — only if the user picks the exact current quarter-hour), bump forward + * one slot. + */ +internal fun roundUpToNextQuarterHour(epochSec: Long): Long { + val quarter = 15 * 60L + val rounded = ((epochSec + quarter - 1) / quarter) * quarter + val nowSec = System.currentTimeMillis() / 1000 + return if (rounded <= nowSec) rounded + quarter else rounded +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index c26f6840da..e6de75c913 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.AccountInfo +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.model.Account @@ -29,14 +30,14 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes +import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent @@ -140,8 +141,11 @@ class AccountSessionManager( externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" }, ) } else if (key.startsWith("nsec")) { + val privHex = + decodePrivateKeyAsHexOrNull(key) + ?: throw Exception("Invalid nsec key") AccountSettings( - keyPair = KeyPair(privKey = key.bechToBytes()), + keyPair = KeyPair(privKey = privHex.hexToByteArray()), transientAccount = transientAccount, ) } else if (key.contains(" ") && Nip06().isValidMnemonic(key)) { @@ -356,6 +360,11 @@ class AccountSessionManager( fun logOff(accountInfo: AccountInfo) { scope.launch(Dispatchers.IO) { + val hex = decodePublicKeyAsHexOrNull(accountInfo.npub) + if (hex == null) { + Log.e("Logoff", "Cannot decode npub for account being logged off; aborting cleanup") + return@launch + } if (accountInfo.npub == currentAccountNPub()) { // Drop the Nest bridge ref before tearing down the // current account so the audio-room activity can't @@ -364,12 +373,14 @@ class AccountSessionManager( .clear() // log off and relogin with the 0 account localPreferences.deleteAccount(accountInfo) - accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) + accountsCache.removeAccount(hex) + Amethyst.instance.scheduledPostStore.removeForAccount(hex) loginWithDefaultAccount() } else { // delete without switching logins localPreferences.deleteAccount(accountInfo) - accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) + accountsCache.removeAccount(hex) + Amethyst.instance.scheduledPostStore.removeForAccount(hex) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index b0fb4c633c..a5de943b18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index b9dc3d8889..2ac05e953b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.IconButton @@ -48,11 +49,16 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -61,6 +67,7 @@ import androidx.compose.ui.unit.dp import androidx.core.content.IntentCompat import androidx.core.net.toUri import androidx.core.util.Consumer +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -79,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose @@ -97,6 +105,9 @@ import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews +import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtButton +import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtPicker +import com.vitorpamplona.amethyst.ui.note.creators.scheduling.roundUpToNextQuarterHour import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription @@ -404,6 +415,26 @@ private fun NewPostScreenBody( } } + val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService + .collectAsStateWithLifecycle() + val savedAccounts by com.vitorpamplona.amethyst.LocalPreferences + .accountsFlow() + .collectAsStateWithLifecycle() + val hasMultipleAccounts = (savedAccounts?.size ?: 0) > 1 + postViewModel.scheduledForSec?.let { current -> + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), + ) { + ScheduleAtPicker( + scheduledForSec = current, + onChanged = { postViewModel.scheduledForSec = it }, + alwaysOnEnabled = alwaysOnEnabled, + hasMultipleAccounts = hasMultipleAccounts, + ) + } + } + if (postViewModel.wantsToAddGeoHash) { Row( verticalAlignment = CenterVertically, @@ -574,12 +605,63 @@ private fun NewPostScreenBody( onDismiss = postViewModel::dismissAiResult, ) - BottomRowActions(postViewModel) + val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService + .collectAsStateWithLifecycle() + var showAlwaysOnPrompt by remember { mutableStateOf(false) } + + BottomRowActions( + postViewModel = postViewModel, + onScheduleClicked = { + if (postViewModel.scheduledForSec != null) { + postViewModel.scheduledForSec = null + } else if (!alwaysOnEnabled) { + showAlwaysOnPrompt = true + } else { + postViewModel.scheduledForSec = + roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60) + } + }, + ) + + if (showAlwaysOnPrompt) { + AlertDialog( + onDismissRequest = { showAlwaysOnPrompt = false }, + title = { Text(stringRes(R.string.schedule_post_always_on_prompt_title)) }, + text = { Text(stringRes(R.string.schedule_post_always_on_prompt_message)) }, + confirmButton = { + TextButton(onClick = { + showAlwaysOnPrompt = false + nav.nav(Route.Settings) + }) { + Text(stringRes(R.string.schedule_post_always_on_prompt_open_settings)) + } + }, + dismissButton = { + TextButton(onClick = { + showAlwaysOnPrompt = false + postViewModel.scheduledForSec = + roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60) + }) { + Text(stringRes(R.string.schedule_post_always_on_prompt_continue)) + } + }, + ) + } } } @Composable -private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { +private fun BottomRowActions( + postViewModel: ShortNotePostViewModel, + onScheduleClicked: () -> Unit = { + postViewModel.scheduledForSec = + if (postViewModel.scheduledForSec != null) { + null + } else { + roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60) + } + }, +) { val scrollState = rememberScrollState() Row( modifier = @@ -656,6 +738,8 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { postViewModel.toggleExpirationDate() } + ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked) + AddGeoHashButton(postViewModel.wantsToAddGeoHash) { postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index e92b1ac831..5339a4a253 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -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(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( + 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("") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMedia.kt new file mode 100644 index 0000000000..be44d23640 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMedia.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +@file:Suppress("ktlint:standard:filename") + +package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException + +private const val TAG = "ScheduledPostMedia" + +sealed class MediaUrl { + abstract val url: String + + data class Image( + override val url: String, + ) : MediaUrl() + + data class Video( + override val url: String, + ) : MediaUrl() +} + +/** + * Parse the signed-event JSON, run [block], and return its result. Returns null on + * any non-cancellation parse failure and logs a warning. The shared shape for + * [extractFirstMediaUrl], [extractEventId], and [extractContentPreview]. + */ +private inline fun parseSignedEvent( + post: ScheduledPost, + caller: String, + block: (Event) -> T?, +): T? = + try { + block(Event.fromJson(post.signedEventJson)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG) { "$caller: failed to parse signed event for ${post.id}: ${e.message}" } + null + } + +/** + * Returns the first media URL referenced via an imeta tag in the post's signed event, + * or null when there is no imeta tag or the JSON cannot be parsed. + * + * Mime starting with `video/` -> [MediaUrl.Video]; anything else (including absent mime) + * -> [MediaUrl.Image]. Lenient on purpose: most posts attach images and don't always + * carry an `m` property. + */ +fun extractFirstMediaUrl(post: ScheduledPost): MediaUrl? = + parseSignedEvent(post, "extractFirstMediaUrl") { event -> + val firstImeta = event.imetas().firstOrNull() ?: return@parseSignedEvent null + val mime = firstImeta.properties["m"]?.firstOrNull().orEmpty() + when { + mime.startsWith("video/") -> MediaUrl.Video(firstImeta.url) + else -> MediaUrl.Image(firstImeta.url) + } + } + +/** + * Returns the signed event's id, or null when the JSON cannot be parsed. + */ +fun extractEventId(post: ScheduledPost): String? = parseSignedEvent(post, "extractEventId") { it.id } + +/** + * Returns the first [maxLen] chars of the signed event's content (trimmed) or + * an empty string when the JSON cannot be parsed. + */ +fun extractContentPreview( + post: ScheduledPost, + maxLen: Int, +): String = + parseSignedEvent(post, "extractContentPreview") { event -> + event.content.take(maxLen).trim() + } ?: "" + +@Composable +fun MediaThumbnail(media: MediaUrl) { + Box( + modifier = + Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = media.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(64.dp), + ) + if (media is MediaUrl.Video) { + Icon( + symbol = MaterialSymbols.PlayArrow, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt new file mode 100644 index 0000000000..ee383a3622 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt @@ -0,0 +1,636 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts + +import android.widget.Toast +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker +import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.delay +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun ScheduledPostsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val accountPubkey = accountViewModel.account.signer.pubKey + val viewModel: ScheduledPostsViewModel = + viewModel(key = "scheduled-posts-$accountPubkey") { + ScheduledPostsViewModel.create(accountPubkey) + } + val groups by viewModel.groupedPosts.collectAsStateWithLifecycle() + val totalActive by viewModel.totalActive.collectAsStateWithLifecycle() + val context = LocalContext.current + var expandedId by remember { mutableStateOf(null) } + + // Tick once per minute so relative-time strings ("publishes in 2h 13m") + // refresh on a long-open list instead of being frozen at first composition. + val nowSec by produceState(initialValue = System.currentTimeMillis() / 1000) { + while (true) { + delay(60_000) + value = System.currentTimeMillis() / 1000 + } + } + + val dueSoonCount by remember { + derivedStateOf { + groups + .flatMap { it.posts } + .count { it.publishAtSec - nowSec in 1..URGENT_THRESHOLD_SEC } + } + } + + val barHeight = if (totalActive > 0) 64.dp else TopBarSize + + Scaffold( + topBar = { + ShorterTopAppBar( + expandedHeight = barHeight, + title = { + Column(modifier = Modifier.semantics(mergeDescendants = true) {}) { + Text(stringRes(R.string.scheduled_posts)) + if (totalActive > 0) { + val queuedText = + pluralStringResource( + id = R.plurals.scheduled_posts_subtitle_queued, + count = totalActive, + totalActive, + ) + val dueText = + if (dueSoonCount > 0) { + pluralStringResource( + id = R.plurals.scheduled_posts_subtitle_due_suffix, + count = dueSoonCount, + dueSoonCount, + ) + } else { + "" + } + Text( + text = queuedText + dueText, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() } + }, + ) + }, + ) { padding -> + if (groups.isEmpty()) { + EmptyState(onCompose = { nav.nav(Route.NewShortNote()) }, modifier = Modifier.padding(padding)) + } else { + val today = remember(nowSec) { LocalDate.now(ZoneId.systemDefault()) } + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + groups.forEach { group -> + stickyHeader(key = group.day) { + DayHeader(group.day, today, context) + } + items(group.posts, key = { it.id }) { post -> + val isExpanded = expandedId == post.id + val rowAlpha by animateFloatAsState( + targetValue = if (expandedId != null && !isExpanded) 0.65f else 1f, + label = "row-alpha", + ) + Box(modifier = Modifier.alpha(rowAlpha)) { + if (isExpanded) { + Column( + modifier = + Modifier + .fillMaxWidth() + .animateContentSize(), + ) { + ScheduledPostCardCollapsed( + post = post, + nowSec = nowSec, + onClick = { expandedId = null }, + ) + ScheduledPostCardExpandedPanel( + post = post, + onPublishNow = { + viewModel.publishNow(post.id) + ScheduledPostWorker.scheduleCatchUp(context) + expandedId = null + }, + onDelete = { + viewModel.cancel(post.id) + expandedId = null + }, + ) + } + } else { + SwipeToDeleteWithConfirmation( + modifier = Modifier.fillMaxWidth().animateContentSize(), + onDelete = { viewModel.cancel(post.id) }, + confirmLabelRes = R.string.quick_action_delete, + ) { + ScheduledPostCardCollapsed( + post = post, + nowSec = nowSec, + onClick = { expandedId = post.id }, + ) + } + } + } + } + } + } + } + } +} + +private const val URGENT_THRESHOLD_SEC = 3600L + +// Tailwind amber-400. Material 3 has no amber slot, but the publishing-pulse +// reads better than `tertiary` against a violet card. +private val PublishingAmber = Color(0xFFFBBF24) + +@Composable +private fun Modifier.urgentEdge(enabled: Boolean): Modifier { + if (!enabled) return this + val gradientStart = MaterialTheme.colorScheme.primary + val gradientEnd = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) + val brush = + remember(gradientStart, gradientEnd) { + Brush.verticalGradient(listOf(gradientStart, gradientEnd)) + } + return this.drawWithContent { + drawContent() + drawRect( + brush = brush, + topLeft = Offset(0f, 8.dp.toPx()), + size = Size(3.dp.toPx(), size.height - 16.dp.toPx()), + ) + } +} + +@Composable +private fun ScheduledPostCardCollapsed( + post: ScheduledPost, + nowSec: Long, + onClick: () -> Unit, +) { + val context = LocalContext.current + val preview = remember(post.id) { extractContentPreview(post, 200) } + val media = remember(post.id) { extractFirstMediaUrl(post) } + val relayCountText = + pluralStringResource( + id = R.plurals.scheduled_posts_relay_count, + count = post.relayUrls.size, + post.relayUrls.size, + ) + + val isFailed = post.status == ScheduledPostStatus.FAILED + // Composite the tint over surface so the card is opaque — otherwise the + // SwipeToDismissBox background ("Delete" / "Cancel") bleeds through at rest. + val surface = MaterialTheme.colorScheme.surface + val containerColor = + if (isFailed) { + MaterialTheme.colorScheme.error + .copy(alpha = 0.06f) + .compositeOver(surface) + } else { + MaterialTheme.colorScheme.primary + .copy(alpha = 0.06f) + .compositeOver(surface) + } + val borderColor = + if (isFailed) { + MaterialTheme.colorScheme.error.copy(alpha = 0.22f) + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + } + + Card( + onClick = onClick, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .urgentEdge(!isFailed && post.publishAtSec - nowSec in 1..URGENT_THRESHOLD_SEC), + colors = CardDefaults.cardColors(containerColor = containerColor), + border = BorderStroke(1.dp, borderColor), + shape = RoundedCornerShape(14.dp), + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + StatusPill(post.status) + Text( + text = formatAtTime(post.publishAtSec, nowSec, context), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (media != null) { + MediaThumbnail(media) + } + Text( + text = preview, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + Text( + text = relayCountText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ScheduledPostCardExpandedPanel( + post: ScheduledPost, + onPublishNow: () -> Unit, + onDelete: () -> Unit, +) { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val eventId = remember(post.id) { extractEventId(post) } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + HorizontalDivider(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)) + + Column { + SectionLabel(stringRes(R.string.relays)) + post.relayUrls.forEach { url -> + Text( + text = url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), + modifier = Modifier.padding(vertical = 2.dp), + ) + } + } + + if (eventId != null) { + Column { + SectionLabel(stringRes(R.string.quick_action_copy_note_id)) + Text( + text = eventId, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = {}, + onLongClick = { + clipboard.setText(AnnotatedString(eventId)) + Toast + .makeText( + context, + stringRes(context, R.string.scheduled_posts_event_id_copied), + Toast.LENGTH_SHORT, + ).show() + }, + ), + ) + } + } + + val err = post.lastError + if (post.status == ScheduledPostStatus.FAILED && !err.isNullOrBlank()) { + Text( + text = stringRes(R.string.scheduled_posts_error_prefix, err), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Button( + onClick = onPublishNow, + enabled = post.status != ScheduledPostStatus.PUBLISHING, + modifier = Modifier.weight(1f), + ) { + val labelRes = + when (post.status) { + ScheduledPostStatus.FAILED -> R.string.retry + ScheduledPostStatus.PUBLISHING -> R.string.scheduled_posts_status_publishing + else -> R.string.scheduled_posts_action_send_now + } + Text(stringRes(labelRes)) + } + OutlinedButton( + onClick = onDelete, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringRes(R.string.quick_action_delete)) + } + } + } +} + +@Composable +private fun SectionLabel(text: String) { + val locale = LocalConfiguration.current.locales[0] + Text( + text = text.uppercase(locale), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) +} + +@Composable +private fun DayHeader( + day: LocalDate, + today: LocalDate, + context: android.content.Context, +) { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = formatDayHeader(day, today, context), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } +} + +@Composable +private fun StatusPill(status: ScheduledPostStatus) { + val (labelRes, color, pulse) = + when (status) { + ScheduledPostStatus.PENDING -> { + Triple(R.string.scheduled_posts_status_pending, MaterialTheme.colorScheme.primary, false) + } + + ScheduledPostStatus.PUBLISHING -> { + Triple(R.string.scheduled_posts_status_publishing, PublishingAmber, true) + } + + ScheduledPostStatus.FAILED -> { + Triple(R.string.scheduled_posts_status_failed, MaterialTheme.colorScheme.error, false) + } + + ScheduledPostStatus.SENT -> { + Triple(R.string.scheduled_posts_status_sent, MaterialTheme.colorScheme.tertiary, false) + } + + ScheduledPostStatus.CANCELLED -> { + Triple(R.string.scheduled_posts_status_cancelled, MaterialTheme.colorScheme.onSurfaceVariant, false) + } + } + val dotAlpha = + if (pulse) { + val transition = rememberInfiniteTransition(label = "publishing-pulse") + transition + .animateFloat( + initialValue = 1f, + targetValue = 0.35f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "publishing-pulse-alpha", + ).value + } else { + 1f + } + + Surface( + shape = RoundedCornerShape(50), + color = color.copy(alpha = 0.18f), + ) { + Row( + modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(6.dp) + .clip(CircleShape) + .background(color.copy(alpha = dotAlpha)), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = stringRes(labelRes), + color = color, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + ) + } + } +} + +@Composable +private fun EmptyState( + onCompose: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize().padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + modifier = + Modifier + .size(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.Schedule, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + Text( + text = stringRes(R.string.scheduled_posts_empty_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringRes(R.string.scheduled_posts_empty_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onCompose) { + Text(stringRes(R.string.new_post)) + } + } + } +} + +private val shortTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) +private val fullDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL) + +private fun formatAtTime( + publishAtSec: Long, + nowSec: Long, + context: android.content.Context, +): String { + val absolute = + Instant + .ofEpochSecond(publishAtSec) + .atZone(ZoneId.systemDefault()) + .toLocalTime() + .format(shortTimeFormatter) + return if (publishAtSec > nowSec) { + stringRes(context, R.string.scheduled_posts_at_time, absolute, timeAheadNoDot(publishAtSec, context)) + } else { + stringRes(context, R.string.scheduled_posts_at_time_past, absolute, timeAgoNoDot(publishAtSec, context).trim()) + } +} + +private fun formatDayHeader( + day: LocalDate, + today: LocalDate, + context: android.content.Context, +): String = + when (day) { + today -> stringRes(context, R.string.today) + today.plusDays(1) -> stringRes(context, R.string.scheduled_posts_day_tomorrow) + else -> day.format(fullDateFormatter) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt new file mode 100644 index 0000000000..cf24d233a0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** A day-bucket of posts for the scheduled-posts list screen. */ +data class ScheduledPostDayGroup( + val day: LocalDate, + val posts: List, +) + +/** + * Drives the "Scheduled posts" screen for a single account. Filters the global + * ScheduledPostStore down to posts owned by [accountPubkey] that are still + * "in progress" — PENDING, PUBLISHING, or FAILED. SENT and CANCELLED rows are + * hidden from the list (they're "done"). + */ +class ScheduledPostsViewModel( + private val store: ScheduledPostStore, + private val accountPubkey: String, +) : ViewModel() { + private val activeStatuses = + setOf( + ScheduledPostStatus.PENDING, + ScheduledPostStatus.PUBLISHING, + ScheduledPostStatus.FAILED, + ) + + /** Posts for [accountPubkey] in active statuses, grouped by local-day, sorted ascending. */ + val groupedPosts: StateFlow> = + store.flow + .map { all -> + val zone = ZoneId.systemDefault() + all + .filter { it.accountPubkey == accountPubkey && it.status in activeStatuses } + .sortedBy { it.publishAtSec } + .groupBy { Instant.ofEpochSecond(it.publishAtSec).atZone(zone).toLocalDate() } + .map { (day, list) -> ScheduledPostDayGroup(day, list) } + .sortedBy { it.day } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + val totalActive: StateFlow = + groupedPosts + .map { groups -> groups.sumOf { it.posts.size } } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = 0, + ) + + fun cancel(id: String) { + viewModelScope.launch(Dispatchers.IO) { + store.cancel(id) + } + } + + fun publishNow(id: String) { + viewModelScope.launch(Dispatchers.IO) { + store.publishNow(id) + } + } + + companion object { + fun create(accountPubkey: String): ScheduledPostsViewModel = + ScheduledPostsViewModel( + store = Amethyst.instance.scheduledPostStore, + accountPubkey = accountPubkey, + ) + } +} diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 56116c8782..9caae76f19 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -2440,4 +2440,63 @@ Veřejná emoji jsou viditelná pro všechny a objeví se ve vaší nabídce reakcí a v automatickém doplňování \":\", když je tento balíček ve vašem seznamu emoji. Soukromá emoji jsou šifrovaně uložena na relayích a viditelná pouze pro vás. Objeví se ve vaší nabídce reakcí a v automatickém doplňování \":\" stejně jako veřejná. Gif + Naplánované příspěvky + Naplánovat + Naplánovaný čas + Příspěvky se publikují přibližně do 15 minut od naplánovaného času. + Vyberte naplánovaný čas + Naplánovat na… + Publikováno za %1$s + Mělo být před %1$s + Čas + Naplánovat příspěvek + Zrušit plánování + Trvalá oznámení vypnuta + Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI. + Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Naplánované příspěvky jiných účtů se nespustí, dokud je aktivní tento účet. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI. + Za 1 hodinu + Zítra v 9:00 + Příští pondělí v 9:00 + Povolit trvalá oznámení? + Naplánované příspěvky se spolehlivě publikují pouze tehdy, když jsou povolena trvalá oznámení. Jinak se nemusí spustit, dokud aplikaci znovu neotevřete. + Otevřít nastavení + Přesto pokračovat + %1$s · za %2$s + %1$s · před %2$s + Zítra + Odhlášeno + Odhlášeno · smazáno %1$d naplánovaných příspěvků + Odeslat hned + Žádné naplánované příspěvky + Napište poznámku a klepněte na ikonu hodin pro naplánování na později. + Chyba: %1$s + Naplánováno + Odesílá se… + Selhalo + Odesláno + Zrušeno + Máte %1$d naplánovaných příspěvků, které ještě nebyly publikovány. Odhlášením budou trvale smazány. + Naplánovaný příspěvek publikován + Naplánovaný příspěvek selhal + Naplánované příspěvky + Oznámení, když je naplánovaný příspěvek publikován nebo selže při publikaci. + + %d ve frontě + %d ve frontě + %d ve frontě + %d ve frontě + + + · 1 do 1 h + · %d do 1 h + · %d do 1 h + · %d do 1 h + + + na 1 relay + na %d relaye + na %d relayů + na %d relayů + + ID příspěvku zkopírováno diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index a908fea70f..1f17c0c978 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2431,4 +2431,57 @@ anz der Bedingungen ist erforderlich Öffentliche Emojis sind für alle sichtbar und erscheinen in deinem Reaktionsmenü und in der \":\"-Autovervollständigungsauswahl, wenn dieses Paket in deiner Emoji-Liste ist. Private Emojis werden verschlüsselt auf Relays gespeichert und sind nur für dich sichtbar. Sie erscheinen in deinem Reaktionsmenü und in der \":\"-Autovervollständigung wie öffentliche. Gif + Geplante Beiträge + Planen + Geplante Zeit + Beiträge werden innerhalb von ~15 Minuten nach der geplanten Zeit veröffentlicht. + Geplante Zeit auswählen + Planen für… + Veröffentlicht in %1$s + Fällig vor %1$s + Zeit + Beitrag planen + Planung abbrechen + Dauerbenachrichtigungen deaktiviert + Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App das nächste Mal öffnest. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung. + Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App wieder öffnest. Geplante Beiträge anderer Konten werden nicht ausgelöst, solange dieses Konto aktiv ist. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung. + In 1 Stunde + Morgen 9 Uhr + Nächsten Montag 9 Uhr + Dauerbenachrichtigungen aktivieren? + Geplante Beiträge werden zuverlässig nur veröffentlicht, wenn Dauerbenachrichtigungen aktiviert sind. Andernfalls werden sie möglicherweise erst beim nächsten Öffnen der App ausgelöst. + Einstellungen öffnen + Trotzdem fortfahren + %1$s · in %2$s + %1$s · vor %2$s + Morgen + Abgemeldet + Abgemeldet · %1$d geplante(n) Beitrag/Beiträge gelöscht + Jetzt senden + Keine geplanten Beiträge + Verfasse eine Notiz und tippe auf das Uhr-Symbol, um sie für später zu planen. + Fehler: %1$s + Geplant + Wird gesendet… + Fehlgeschlagen + Gesendet + Abgebrochen + Du hast %1$d geplante(n) Beitrag/Beiträge, der/die noch nicht veröffentlicht wurden. Beim Abmelden werden sie dauerhaft gelöscht. + Geplanter Beitrag veröffentlicht + Geplanter Beitrag fehlgeschlagen + Geplante Beiträge + Benachrichtigungen, wenn ein geplanter Beitrag veröffentlicht wird oder die Veröffentlichung fehlschlägt. + + %d in Warteschlange + %d in Warteschlange + + + · 1 fällig in 1 Std. + · %d fällig in 1 Std. + + + an 1 Relay + an %d Relays + + Beitrags-ID kopiert diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 362c5fdaa1..52702d633f 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -2426,4 +2426,57 @@ Emojis públicos são visíveis para todos e aparecem no seu menu de reações e no seletor de autocompletar \":\" quando este pacote está na sua lista de emojis. Emojis privados são armazenados criptografados em relays e visíveis apenas para você. Eles aparecem no seu menu de reações e no autocompletar \":\" assim como os públicos. Gif + Posts agendados + Agendar + Hora agendada + Posts são publicados em até ~15 minutos após o horário agendado. + Escolher horário agendado + Agendar para… + Publica em %1$s + Devia ter sido publicado há %1$s + Hora + Agendar post + Cancelar agendamento + Notificações sempre ativas desativadas + Posts agendados podem não ser publicados até você reabrir o app. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano. + Posts agendados podem não ser publicados até você reabrir o app. Posts agendados de outras contas não serão disparados enquanto esta conta estiver ativa. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano. + Em 1 hora + Amanhã às 9h + Próxima segunda às 9h + Ativar notificações sempre ativas? + Posts agendados publicam de forma confiável apenas quando notificações sempre ativas estão ativadas. Caso contrário, podem não disparar até você reabrir o app. + Abrir configurações + Continuar mesmo assim + %1$s · em %2$s + %1$s · há %2$s + Amanhã + Desconectado + Desconectado · %1$d post(s) agendado(s) excluído(s) + Enviar agora + Sem posts agendados + Componha uma nota e toque no ícone do relógio para agendá-la. + Erro: %1$s + Agendado + Enviando… + Falhou + Enviado + Cancelado + Você tem %1$d post(s) agendado(s) que ainda não foram publicados. Sair excluirá esses posts permanentemente. + Post agendado publicado + Post agendado falhou + Posts agendados + Notificações quando um post agendado é publicado ou falha ao publicar. + + %d na fila + %d na fila + + + · 1 em 1h + · %d em 1h + + + para 1 relay + para %d relays + + ID do post copiado diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 6b5bd69058..e396c36075 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2425,4 +2425,57 @@ Offentliga emojis är synliga för alla och visas i din reaktionsmeny och i \":\"-autokompletteringen när detta paket finns i din emoji-lista. Privata emojis lagras krypterade på relän och är endast synliga för dig. De visas i din reaktionsmeny och i autokomplettering med \":\" precis som offentliga. Gif + Schemalagda inlägg + Schemalägg + Schemalagd tid + Inlägg publiceras inom ~15 minuter från den schemalagda tiden. + Välj schemalagd tid + Schemalägg för… + Publiceras om %1$s + Skulle ha publicerats för %1$s sedan + Tid + Schemalägg inlägg + Avbryt schemaläggning + Alltid-på-aviseringar avstängda + Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden. + Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Andra kontons schemalagda inlägg utlöses inte medan detta konto är aktivt. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden. + Om 1 timme + Imorgon kl. 09:00 + Nästa måndag kl. 09:00 + Aktivera alltid-på-aviseringar? + Schemalagda inlägg publiceras pålitligt endast när alltid-på-aviseringar är aktiverade. Annars kanske de inte utlöses förrän du öppnar appen igen. + Öppna inställningar + Fortsätt ändå + %1$s · om %2$s + %1$s · för %2$s sedan + Imorgon + Utloggad + Utloggad · %1$d schemalagda inlägg raderade + Skicka nu + Inga schemalagda inlägg + Skriv en anteckning och tryck på klockikonen för att schemalägga den. + Fel: %1$s + Schemalagd + Skickar… + Misslyckades + Skickat + Avbrutet + Du har %1$d schemalagda inlägg som inte har publicerats än. Att logga ut raderar dem permanent. + Schemalagt inlägg publicerat + Schemalagt inlägg misslyckades + Schemalagda inlägg + Aviseringar när ett schemalagt inlägg publiceras eller misslyckas att publicera. + + %d i kö + %d i kö + + + · 1 inom 1 h + · %d inom 1 h + + + till 1 relä + till %d reläer + + Inläggs-ID kopierat diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0502a8da3d..63e4dac1e8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -428,6 +428,60 @@ Move All to New Bookmarks Bookmarks migrated successfully Drafts + Scheduled posts + Schedule + Scheduled time + Posts publish within ~15 minutes of the scheduled time. + Pick scheduled time + Schedule for… + Publishes in %1$s + Was due %1$s ago + Time + Schedule post + Cancel scheduling + Always-on notifications disabled + Scheduled posts may not publish until you next reopen the app. Enable always-on in Settings → UI Preferences for reliable background scheduling. + 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. + In 1 hour + Tomorrow 9 AM + Next Monday 9 AM + Enable always-on notifications? + Scheduled posts publish reliably only when always-on notifications are enabled. Otherwise, they may not fire until you next reopen the app. + Open settings + Continue anyway + %1$s · in %2$s + %1$s · %2$s ago + Tomorrow + Logged out + Logged out · %1$d scheduled post(s) deleted + Scheduled post published + Scheduled post failed + ScheduledPostsID + Scheduled posts + Notifications when a scheduled post is published or fails to publish. + Send now + No scheduled posts + Compose a note and tap the clock icon to schedule it for later. + Error: %1$s + Scheduled + Sending… + Failed + Sent + Cancelled + You have %1$d scheduled post(s) that haven\'t been published yet. Logging out will permanently delete them. + + %d queued + %d queued + + + · 1 due in 1h + · %d due in 1h + + + to 1 relay + to %d relays + + Note ID copied Polls Open Closed diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt new file mode 100644 index 0000000000..ea6e0a09a3 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt @@ -0,0 +1,542 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.scheduledposts + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class ScheduledPostStoreTest { + @get:Rule + val temp = TemporaryFolder() + + private lateinit var file: File + + @Before + fun setUp() { + file = File(temp.root, "scheduled_posts.json") + } + + private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file, now) + + private fun samplePost( + id: String = "id-1", + publishAtSec: Long = 1_000, + accountPubkey: String = "pk1", + ) = ScheduledPost( + id = id, + accountPubkey = accountPubkey, + signedEventJson = "{}", + relayUrls = listOf("wss://relay.example/"), + extraEventsJson = emptyList(), + publishAtSec = publishAtSec, + createdAtSec = 500, + ) + + @Test + fun add_persists_to_disk() = + runTest { + val store = newStore() + store.add(samplePost()) + assertTrue("storage file should exist after add", file.exists()) + + val reloaded = newStore().list() + assertEquals(1, reloaded.size) + assertEquals("id-1", reloaded[0].id) + assertEquals(ScheduledPostStatus.PENDING, reloaded[0].status) + } + + @Test + fun claimDuePosts_returns_only_due_pending_posts() = + runTest { + val store = newStore() + store.add(samplePost(id = "due", publishAtSec = 1000)) + store.add(samplePost(id = "future", publishAtSec = 5000)) + store.add(samplePost(id = "also-due", publishAtSec = 999)) + + val claimed = store.claimDuePosts(nowSec = 1000) + val ids = claimed.map { it.id }.toSet() + assertEquals(setOf("due", "also-due"), ids) + } + + @Test + fun claimDuePosts_flips_status_to_publishing() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 1000)) + + store.claimDuePosts(nowSec = 1000) + + val all = store.list() + assertEquals(ScheduledPostStatus.PUBLISHING, all[0].status) + assertEquals(1, all[0].attemptCount) + assertEquals(1000L, all[0].lastAttemptAtSec) + } + + @Test + fun claimDuePosts_second_call_returns_empty() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 1000)) + + val first = store.claimDuePosts(nowSec = 1000) + val second = store.claimDuePosts(nowSec = 1000) + + assertEquals(1, first.size) + assertEquals(0, second.size) + } + + @Test + fun concurrent_claimDuePosts_only_one_wins() = + runTest { + val store = newStore() + store.add(samplePost(id = "race", publishAtSec = 1000)) + + val results = + (1..10) + .map { async { store.claimDuePosts(nowSec = 1000) } } + .awaitAll() + + val totalClaimed = results.sumOf { it.size } + assertEquals("exactly one concurrent caller should claim the post", 1, totalClaimed) + } + + @Test + fun markSent_updates_status_and_clears_error() = + runTest { + val store = newStore() + store.add(samplePost()) + store.markFailed("id-1", "earlier error") + store.markSent("id-1") + + val all = store.list() + assertEquals(ScheduledPostStatus.SENT, all[0].status) + assertNull(all[0].lastError) + } + + @Test + fun markFailed_records_error() = + runTest { + val store = newStore() + store.add(samplePost()) + store.markFailed("id-1", "boom") + + val all = store.list() + assertEquals(ScheduledPostStatus.FAILED, all[0].status) + assertEquals("boom", all[0].lastError) + } + + @Test + fun releaseClaim_reverts_publishing_to_pending() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 1000)) + store.claimDuePosts(nowSec = 1000) + + store.releaseClaim("id-1") + + val all = store.list() + assertEquals(ScheduledPostStatus.PENDING, all[0].status) + } + + @Test + fun releaseClaim_does_not_touch_non_publishing() = + runTest { + val store = newStore() + store.add(samplePost()) + store.releaseClaim("id-1") + + assertEquals(ScheduledPostStatus.PENDING, store.list()[0].status) + } + + @Test + fun cancel_sets_cancelled_status_and_returns_true() = + runTest { + val store = newStore() + store.add(samplePost()) + + val ok = store.cancel("id-1") + + assertTrue(ok) + assertEquals(ScheduledPostStatus.CANCELLED, store.list()[0].status) + } + + @Test + fun cancel_unknown_id_returns_false() = + runTest { + val store = newStore() + assertEquals(false, store.cancel("nope")) + } + + @Test + fun listFor_filters_by_account() = + runTest { + val store = newStore() + store.add(samplePost(id = "a", accountPubkey = "pk-a")) + store.add(samplePost(id = "b", accountPubkey = "pk-b")) + store.add(samplePost(id = "c", accountPubkey = "pk-a")) + + val filtered = store.listFor("pk-a").map { it.id }.toSet() + assertEquals(setOf("a", "c"), filtered) + } + + @Test + fun cancelled_posts_are_not_claimed() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 1000)) + store.cancel("id-1") + + val claimed = store.claimDuePosts(nowSec = 5000) + + assertEquals(0, claimed.size) + } + + @Test + fun missing_file_loads_as_empty() = + runTest { + assertTrue("file should not exist before first read", !file.exists()) + val store = newStore() + assertEquals(0, store.list().size) + } + + @Test + fun corrupt_file_loads_as_empty() = + runTest { + file.writeText("not valid json {{{") + val store = newStore() + assertEquals(0, store.list().size) + } + + @Test + fun publishNow_sets_publishAtSec_to_now_and_status_pending() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 9_999_999)) + + val ok = store.publishNow("id-1", nowSec = 1234) + + assertTrue(ok) + val updated = store.list().single() + assertEquals(ScheduledPostStatus.PENDING, updated.status) + assertEquals(1234L, updated.publishAtSec) + } + + @Test + fun publishNow_clears_failed_state_for_retry() = + runTest { + val store = newStore() + store.add(samplePost()) + store.markFailed("id-1", "earlier failure") + + store.publishNow("id-1", nowSec = 5000) + + val updated = store.list().single() + assertEquals(ScheduledPostStatus.PENDING, updated.status) + assertNull(updated.lastError) + } + + @Test + fun publishNow_unknown_id_returns_false() = + runTest { + val store = newStore() + assertEquals(false, store.publishNow("nope")) + } + + @Test + fun publishNow_makes_post_immediately_claimable() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 9_999_999)) + assertEquals(0, store.claimDuePosts(nowSec = 1000).size) + + store.publishNow("id-1", nowSec = 1000) + + val claimed = store.claimDuePosts(nowSec = 1000) + assertEquals(1, claimed.size) + assertEquals("id-1", claimed[0].id) + } + + @Test + fun flow_emits_initial_empty_then_post_after_add() = + runTest { + val store = newStore() + assertEquals(0, store.flow.value.size) + + store.add(samplePost()) + + assertEquals(1, store.flow.value.size) + assertEquals("id-1", store.flow.value[0].id) + } + + @Test + fun flow_reflects_status_transitions() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 1000)) + assertEquals(ScheduledPostStatus.PENDING, store.flow.value[0].status) + + store.claimDuePosts(nowSec = 1000) + assertEquals(ScheduledPostStatus.PUBLISHING, store.flow.value[0].status) + + store.markSent("id-1") + assertEquals(ScheduledPostStatus.SENT, store.flow.value[0].status) + } + + @Test + fun flow_seeded_from_disk_on_first_access() = + runTest { + // Pre-populate the file via a first store instance + newStore().add(samplePost()) + + // Second store starts with empty in-memory flow until first access + val store = newStore() + assertEquals(0, store.flow.value.size) + + // Triggering any read method causes ensureLoaded() to seed the flow + store.list() + assertEquals(1, store.flow.value.size) + } + + @Test + fun removeForAccount_removes_all_matching_rows_and_returns_count() = + runTest { + val store = newStore() + store.add(samplePost(id = "a1", accountPubkey = "pk-a")) + store.add(samplePost(id = "a2", accountPubkey = "pk-a")) + store.add(samplePost(id = "b1", accountPubkey = "pk-b")) + + val removed = store.removeForAccount("pk-a") + + assertEquals(2, removed) + val remaining = store.list() + assertEquals(1, remaining.size) + assertEquals("b1", remaining[0].id) + } + + @Test + fun removeForAccount_no_match_returns_zero_and_does_not_persist() = + runTest { + val store = newStore() + store.add(samplePost(accountPubkey = "pk-a")) + val bytesBefore = file.readBytes() + + val removed = store.removeForAccount("pk-other") + + assertEquals(0, removed) + assertEquals(1, store.list().size) + assertTrue("file should not be rewritten on no-op", bytesBefore.contentEquals(file.readBytes())) + } + + @Test + fun removeForAccount_persists_to_disk() = + runTest { + val store = newStore() + store.add(samplePost(id = "a1", accountPubkey = "pk-a")) + store.add(samplePost(id = "b1", accountPubkey = "pk-b")) + + store.removeForAccount("pk-a") + + val reloaded = newStore().list() + assertEquals(1, reloaded.size) + assertEquals("b1", reloaded[0].id) + } + + @Test + fun removeForAccount_purges_terminal_states_too() = + runTest { + val store = newStore() + store.add(samplePost(id = "p1", accountPubkey = "pk-a")) + store.add(samplePost(id = "p2", accountPubkey = "pk-a")) + store.markSent("p1") + store.cancel("p2") + + val removed = store.removeForAccount("pk-a") + + assertEquals(2, removed) + assertEquals(0, store.list().size) + } + + @Test + fun cancel_stamps_terminatedAtSec() = + runTest { + val clock = 1_700_000_000L + val store = newStore { clock } + store.add(samplePost(id = "x")) + + store.cancel("x") + + assertEquals(clock, store.list().single().terminatedAtSec) + } + + @Test + fun markSent_stamps_terminatedAtSec() = + runTest { + val clock = 1_700_000_000L + val store = newStore { clock } + store.add(samplePost(id = "x", publishAtSec = clock)) + store.claimDuePosts(clock) + + store.markSent("x") + + assertEquals(clock, store.list().single().terminatedAtSec) + } + + @Test + fun publishNow_clears_terminatedAtSec() = + runTest { + val clock = 1_700_000_000L + val store = newStore { clock } + store.add(samplePost(id = "x")) + store.cancel("x") // stamps terminatedAtSec + + store.publishNow("x", nowSec = clock + 5) + + assertNull(store.list().single().terminatedAtSec) + } + + @Test + fun ensureLoaded_purges_sent_older_than_seven_days() = + runTest { + val createTime = 1_700_000_000L + newStore { createTime }.also { it.add(samplePost(id = "old-sent", publishAtSec = createTime)) } + newStore { createTime }.also { + it.claimDuePosts(createTime) + it.markSent("old-sent") + } + + val eightDaysLater = createTime + 8L * 24 * 3600 + val reloaded = newStore { eightDaysLater } + assertEquals(0, reloaded.list().size) + } + + @Test + fun ensureLoaded_keeps_recent_sent() = + runTest { + val createTime = 1_700_000_000L + newStore { createTime }.also { it.add(samplePost(id = "fresh", publishAtSec = createTime)) } + newStore { createTime }.also { + it.claimDuePosts(createTime) + it.markSent("fresh") + } + + val sixDaysLater = createTime + 6L * 24 * 3600 + val reloaded = newStore { sixDaysLater } + assertEquals(1, reloaded.list().size) + assertEquals(ScheduledPostStatus.SENT, reloaded.list().single().status) + } + + @Test + fun ensureLoaded_purges_cancelled_older_than_thirty_days() = + runTest { + val createTime = 1_700_000_000L + newStore { createTime }.also { + it.add(samplePost(id = "old-cancel")) + it.cancel("old-cancel") + } + + val thirtyOneDaysLater = createTime + 31L * 24 * 3600 + val reloaded = newStore { thirtyOneDaysLater } + assertEquals(0, reloaded.list().size) + } + + @Test + fun ensureLoaded_keeps_recent_cancelled() = + runTest { + val createTime = 1_700_000_000L + newStore { createTime }.also { + it.add(samplePost(id = "recent-cancel")) + it.cancel("recent-cancel") + } + + val twentyDaysLater = createTime + 20L * 24 * 3600 + val reloaded = newStore { twentyDaysLater } + assertEquals(1, reloaded.list().size) + assertEquals(ScheduledPostStatus.CANCELLED, reloaded.list().single().status) + } + + @Test + fun ensureLoaded_keeps_failed_indefinitely() = + runTest { + val createTime = 1_700_000_000L + newStore { createTime }.also { it.add(samplePost(id = "fail", publishAtSec = createTime)) } + newStore { createTime }.also { + it.claimDuePosts(createTime) + it.markFailed("fail", "boom") + } + + val ninetyDaysLater = createTime + 90L * 24 * 3600 + val reloaded = newStore { ninetyDaysLater } + assertEquals(1, reloaded.list().size) + assertEquals(ScheduledPostStatus.FAILED, reloaded.list().single().status) + } + + @Test + fun ensureLoaded_persists_purge_to_disk() = + runTest { + val createTime = 1_700_000_000L + newStore { createTime }.also { it.add(samplePost(id = "old", publishAtSec = createTime)) } + newStore { createTime }.also { + it.claimDuePosts(createTime) + it.markSent("old") + } + val sizeBefore = file.length() + + val eightDaysLater = createTime + 8L * 24 * 3600 + newStore { eightDaysLater }.list() // triggers ensureLoaded + purge + persist + + assertTrue("file should shrink after purge", file.length() < sizeBefore) + } + + @Test + fun roundtrip_preserves_all_fields() = + runTest { + val original = + ScheduledPost( + id = "roundtrip", + accountPubkey = "pk-x", + signedEventJson = """{"kind":1,"content":"hi"}""", + relayUrls = listOf("wss://a/", "wss://b/"), + extraEventsJson = listOf("{}", "{}"), + publishAtSec = 1_700_000_000, + createdAtSec = 1_699_900_000, + status = ScheduledPostStatus.PENDING, + lastAttemptAtSec = null, + attemptCount = 0, + lastError = null, + ) + newStore().add(original) + + val reloaded = newStore().list().single() + assertEquals(original, reloaded) + assertNotNull(reloaded.relayUrls) + assertEquals(2, reloaded.relayUrls.size) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMediaTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMediaTest.kt new file mode 100644 index 0000000000..c20a9a03be --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMediaTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts + +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScheduledPostMediaTest { + private fun postWithJson(json: String) = + ScheduledPost( + id = "id", + accountPubkey = "pk", + signedEventJson = json, + relayUrls = emptyList(), + extraEventsJson = emptyList(), + publishAtSec = 0, + createdAtSec = 0, + status = ScheduledPostStatus.PENDING, + ) + + @Test + fun extractFirstMediaUrl_returns_null_when_no_imeta() { + val json = """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[],"content":"hi","sig":"x"}""" + assertNull(extractFirstMediaUrl(postWithJson(json))) + } + + @Test + fun extractFirstMediaUrl_returns_image_when_mime_starts_with_image() { + val json = + """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg","m image/jpeg"]],"content":"hi","sig":"x"}""" + val result = extractFirstMediaUrl(postWithJson(json)) + assertTrue(result is MediaUrl.Image) + assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url) + } + + @Test + fun extractFirstMediaUrl_returns_video_when_mime_starts_with_video() { + val json = + """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/v.mp4","m video/mp4"]],"content":"hi","sig":"x"}""" + val result = extractFirstMediaUrl(postWithJson(json)) + assertTrue(result is MediaUrl.Video) + assertEquals("https://x/v.mp4", (result as MediaUrl.Video).url) + } + + @Test + fun extractFirstMediaUrl_defaults_to_image_when_no_mime() { + val json = + """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg"]],"content":"hi","sig":"x"}""" + val result = extractFirstMediaUrl(postWithJson(json)) + assertTrue(result is MediaUrl.Image) + assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url) + } + + @Test + fun extractFirstMediaUrl_picks_first_when_multiple_imeta() { + val json = + """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg","m image/jpeg"],["imeta","url https://y/v.mp4","m video/mp4"]],"content":"hi","sig":"x"}""" + val result = extractFirstMediaUrl(postWithJson(json)) + assertTrue(result is MediaUrl.Image) + assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url) + } + + @Test + fun extractFirstMediaUrl_returns_null_when_json_malformed() { + assertNull(extractFirstMediaUrl(postWithJson("{not json}"))) + } + + @Test + fun extractFirstMediaUrl_returns_null_when_imeta_has_no_url() { + val json = + """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","m image/jpeg"]],"content":"hi","sig":"x"}""" + assertNull(extractFirstMediaUrl(postWithJson(json))) + } + + @Test + fun extractFirstMediaUrl_picks_first_when_video_precedes_image() { + val json = + """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/v.mp4","m video/mp4"],["imeta","url https://y/a.jpg","m image/jpeg"]],"content":"hi","sig":"x"}""" + val result = extractFirstMediaUrl(postWithJson(json)) + assertTrue(result is MediaUrl.Video) + assertEquals("https://x/v.mp4", (result as MediaUrl.Video).url) + } + + @Test + fun extractEventId_returns_id_for_well_formed_json() { + val json = + """{"id":"abc123","pubkey":"p","kind":1,"created_at":0,"tags":[],"content":"hi","sig":"x"}""" + assertEquals("abc123", extractEventId(postWithJson(json))) + } + + @Test + fun extractEventId_returns_null_when_json_malformed() { + assertNull(extractEventId(postWithJson("{not json}"))) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt index 9ef4dead6e..aefb0bb1ee 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt @@ -112,6 +112,8 @@ class CoordinatorPipelineTest { relayList: Set, ) {} + override fun pendingPublishRelaysFor(eventId: HexKey): Set? = null + override fun addConnectionListener(listener: RelayConnectionListener) {} override fun removeConnectionListener(listener: RelayConnectionListener) {} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt index f7b1119504..02b4ee74bc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt @@ -72,6 +72,13 @@ interface INostrClient : AutoCloseable { relayList: Set, ) + /** + * Returns the relays that have not yet acknowledged [eventId] with an OK, + * or null if the event is not tracked (never published, or already fully done). + * Use to poll for delivery confirmation after [publish]. + */ + fun pendingPublishRelaysFor(eventId: HexKey): Set? + fun addConnectionListener(listener: RelayConnectionListener) fun removeConnectionListener(listener: RelayConnectionListener) @@ -123,6 +130,8 @@ class EmptyNostrClient : INostrClient { relayList: Set, ) { } + override fun pendingPublishRelaysFor(eventId: HexKey): Set? = null + override fun addConnectionListener(listener: RelayConnectionListener) {} override fun removeConnectionListener(listener: RelayConnectionListener) {} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 84d2218067..693aa74661 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -358,6 +358,8 @@ class NostrClient( override fun activeOutboxCache(url: NormalizedRelayUrl): Set = eventOutbox.activeOutboxCacheFor(url) + override fun pendingPublishRelaysFor(eventId: HexKey): Set? = eventOutbox.pendingRelaysFor(eventId) + override fun getReqFiltersOrNull(subId: String): Map>? = activeRequests.getSubscriptionFiltersOrNull(subId) override fun getCountFiltersOrNull(subId: String): Map>? = activeCounts.getSubscriptionFiltersOrNull(subId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt index afcace262f..d934b46184 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt @@ -31,6 +31,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update class PoolEventOutbox { + // @Volatile so the polling path (INostrClient.pendingPublishRelaysFor) + // sees current state from threads that didn't write the map. Mutations + // still happen on NostrClient's IO scope; this only closes the + // visibility gap for cross-thread readers. + @Volatile private var eventOutbox = mapOf() val relays = MutableStateFlow(setOf()) @@ -90,6 +95,14 @@ class PoolEventOutbox { return myEvents } + /** + * Returns the relays that have NOT yet acknowledged [eventId] with an OK, or + * null if the event is not currently tracked (never sent or already fully done). + * Callers can poll this after publish to detect when relays ack: the set shrinks + * as OKs arrive, then the entry is removed from the outbox (returns null). + */ + fun pendingRelaysFor(eventId: HexKey): Set? = eventOutbox[eventId]?.relaysLeft() + fun markAsSending( event: Event, relays: Set, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt index f75f3aa6ab..28fb629672 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils class PoolEventOutboxState( val event: Event, - var relaysRemaining: Set, + @Volatile var relaysRemaining: Set, ) { private var failures = mapOf() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt index 47b9376dcc..f7213b655f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt @@ -88,6 +88,8 @@ private class TrackingNostrClient : INostrClient { sentEvents.add(event to relayList) } + override fun pendingPublishRelaysFor(eventId: String): Set? = null + override fun addConnectionListener(listener: RelayConnectionListener) {} override fun removeConnectionListener(listener: RelayConnectionListener) {} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt index 6992e5a8da..85a652b874 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt @@ -207,6 +207,8 @@ private class CountingNostrClient( onPublish() } + override fun pendingPublishRelaysFor(eventId: String): Set? = null + override fun addConnectionListener(listener: RelayConnectionListener) {} override fun removeConnectionListener(listener: RelayConnectionListener) {}