From 8e5e3c634aeef18d0c5b6e1022f8232d0b098453 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 7 May 2026 08:13:08 +0200 Subject: [PATCH 01/11] feat(scheduled-posts): warn when scheduling without always-on notifications feat(scheduled-posts): add screen + drawer entry to view, push, or delete feat(scheduled-posts): use scheduled time as created_at + add diagnostic logs feat(scheduled-posts): add picker UI and toolbar toggle to post composer feat(scheduled-posts): wire schedule branch into ShortNotePostViewModel feat(scheduled-posts): add storage + worker for delayed post publishing --- .../com/vitorpamplona/amethyst/AppModules.kt | 13 + .../service/scheduledposts/ScheduledPost.kt | 48 +++ .../scheduledposts/ScheduledPostStore.kt | 218 +++++++++++ .../scheduledposts/ScheduledPostWorker.kt | 168 +++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/bottombars/NavBarItem.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../creators/scheduling/ScheduleAtButton.kt | 46 +++ .../creators/scheduling/ScheduleAtPicker.kt | 259 +++++++++++++ .../loggedIn/BottomBarFeedPreloaders.kt | 1 + .../loggedIn/home/ShortNotePostScreen.kt | 35 ++ .../loggedIn/home/ShortNotePostViewModel.kt | 39 ++ .../scheduledposts/ScheduledPostsScreen.kt | 342 +++++++++++++++++ .../scheduledposts/ScheduledPostsViewModel.kt | 91 +++++ amethyst/src/main/res/values/strings.xml | 1 + .../scheduledposts/ScheduledPostStoreTest.kt | 352 ++++++++++++++++++ 16 files changed, 1626 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt 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..91ba77a9d9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.scheduledposts + +enum class ScheduledPostStatus { + PENDING, + PUBLISHING, + SENT, + FAILED, + CANCELLED, +} + +data class ScheduledPost( + val id: String, + val accountPubkey: String, + val signedEventJson: String, + val relayUrls: List, + 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, +) + +data class ScheduledPostFile( + val version: Int = 1, + val posts: List = emptyList(), +) 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..888c69e444 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.scheduledposts + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File + +class ScheduledPostStore( + private val storageFile: File, +) { + private val mapper = + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + private val mutex = Mutex() + private var loaded = false + private var posts: MutableList = 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 updated = mutate(id) { it.copy(status = ScheduledPostStatus.CANCELLED) } + 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() + if (mutate(id) { it.copy(status = ScheduledPostStatus.SENT, lastError = null) }) persist() + } + + suspend fun markFailed( + id: String, + error: String?, + ) = mutex.withLock { + ensureLoaded() + if (mutate(id) { it.copy(status = ScheduledPostStatus.FAILED, lastError = error) }) persist() + } + + /** + * Force a post to publish immediately by setting its publishAtSec to [nowSec] + * and resetting status to PENDING. Handles two cases with one method: + * - PENDING (future-scheduled): user wants to push it out now + * - FAILED: user wants to retry + * Caller is expected to enqueue ScheduledPostWorker.scheduleCatchUp() afterwards + * so the worker picks it up promptly. + */ + suspend fun publishNow( + id: String, + nowSec: Long = System.currentTimeMillis() / 1000, + ): Boolean = + mutex.withLock { + ensureLoaded() + val updated = + mutate(id) { + it.copy( + publishAtSec = nowSec, + status = ScheduledPostStatus.PENDING, + lastError = null, + ) + } + if (updated) persist() + updated + } + + /** + * Revert a PUBLISHING claim back to PENDING (e.g. when the account is not + * loaded at fire time, so we should retry on the next cycle rather than + * marking the post failed permanently). + */ + suspend fun releaseClaim(id: String) = + mutex.withLock { + ensureLoaded() + val changed = + mutate(id) { + if (it.status == ScheduledPostStatus.PUBLISHING) { + it.copy(status = ScheduledPostStatus.PENDING) + } else { + it + } + } + if (changed) persist() + } + + private fun mutate( + id: String, + transform: (ScheduledPost) -> ScheduledPost, + ): Boolean { + val idx = posts.indexOfFirst { it.id == id } + if (idx < 0) return false + val before = posts[idx] + val after = transform(before) + if (after === before) return false + posts[idx] = after + return true + } + + private fun ensureLoaded() { + if (loaded) return + posts = + try { + if (storageFile.exists() && storageFile.length() > 0) { + mapper.readValue(storageFile).posts.toMutableList() + } else { + mutableListOf() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load scheduled posts from $storageFile", e) + mutableListOf() + } + loaded = true + _flow.value = posts.toList() + } + + private fun persist() { + val snapshot = posts.toList() + _flow.value = snapshot + val parent = storageFile.parentFile + if (parent != null && !parent.exists()) parent.mkdirs() + val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") + try { + mapper.writeValue(tmp, ScheduledPostFile(version = 1, posts = snapshot)) + if (!tmp.renameTo(storageFile)) { + storageFile.delete() + if (!tmp.renameTo(storageFile)) { + Log.e(TAG, "Failed to rename $tmp to $storageFile") + tmp.delete() + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to persist scheduled posts to $storageFile", e) + tmp.delete() + } + } + + companion object { + private const val TAG = "ScheduledPostStore" + const val FILE_NAME = "scheduled_posts.json" + } +} 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..b2d6332d0c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.scheduledposts + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import java.util.concurrent.TimeUnit + +/** + * Scans the scheduled-post store and publishes posts whose publish time has arrived. + * + * - schedule(context): periodic, every 15 min (WorkManager minimum). + * - scheduleCatchUp(context): one-time, on app start, to flush posts that came + * due while the device was off or while WorkManager + * was deferred by Doze. + */ +class ScheduledPostWorker( + appContext: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + init { + // Logs every worker instantiation. Without this, "doWork never ran" looks + // identical to "constructor never invoked" — and the latter means the OS + // (Doze, battery-opt, JobScheduler quotas) never woke us at all. + Log.d(TAG) { "Worker instantiated (runAttempt=${workerParams.runAttemptCount}, tags=${workerParams.tags})" } + } + + companion object { + private const val TAG = "ScheduledPostWorker" + private const val WORK_NAME = "scheduled_post_worker" + private const val WORK_NAME_CATCH_UP = "scheduled_post_worker_catch_up" + + fun schedule(context: Context) { + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = + PeriodicWorkRequestBuilder(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) + + store.markSent(post.id) + Log.d(TAG) { "client.publish(${post.id}) done; marked SENT" } + } catch (e: Exception) { + Log.e(TAG, "Failed to publish scheduled post ${post.id}", e) + store.markFailed(post.id, e.message) + } + } + + Log.d(TAG) { "doWork() EXIT success" } + Result.success() + } catch (e: Exception) { + Log.e(TAG, "doWork() unexpected failure", e) + Result.retry() + } + } +} 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/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..d7d1a36335 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtButton.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.scheduling + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols + +@Composable +fun ScheduleAtButton( + isActive: Boolean, + onClick: () -> Unit, +) { + IconButton(onClick = { onClick() }) { + Icon( + symbol = MaterialSymbols.Schedule, + contentDescription = if (isActive) "Cancel scheduling" else "Schedule post", + modifier = Modifier.size(20.dp), + tint = if (isActive) Color(0xFF1E88E5) else MaterialTheme.colorScheme.onBackground, + ) + } +} 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..dfa539212f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.scheduling + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.utils.TimeUtils +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset + +/** + * Two-stage date + time picker for scheduling a post for future publication. + * + * The selected time is rounded up to the next quarter-hour to set realistic + * expectations: the periodic worker that fires scheduled posts runs at + * WorkManager's minimum 15-min interval, so per-minute precision is misleading. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduleAtPicker( + scheduledForSec: Long, + onChanged: (Long) -> Unit, + alwaysOnEnabled: Boolean = true, + hasMultipleAccounts: Boolean = false, +) { + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + + val currentTime = + Instant + .ofEpochMilli(scheduledForSec * 1000) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + + val datePickerState = + rememberDatePickerState( + initialSelectedDateMillis = scheduledForSec * 1000, + yearRange = currentTime.year..2050, + selectableDates = + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= System.currentTimeMillis() - 86_400_000 + }, + ) + + val timePickerState = + rememberTimePickerState( + initialHour = currentTime.hour, + initialMinute = currentTime.minute, + is24Hour = false, + ) + + val context = LocalContext.current + + Column(Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 5.dp), + ) { + Icon( + symbol = MaterialSymbols.Timer, + contentDescription = "Scheduled time", + modifier = Modifier.size(20.dp), + tint = Color(0xFF1E88E5), + ) + + Text( + text = "Schedule", + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + } + + HorizontalDivider(thickness = DividerThickness) + + Text( + text = "Posts publish within ~15 minutes of the scheduled time.", + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), + ) + + if (!alwaysOnEnabled) { + ReliabilityWarning(hasMultipleAccounts = hasMultipleAccounts) + } + + OutlinedCard( + onClick = { showDatePicker = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(MaterialSymbols.Timer, contentDescription = "Pick scheduled time") + Spacer(Modifier.width(12.dp)) + + if (scheduledForSec < TimeUtils.oneMinuteFromNow()) { + Text("Schedule for…", style = MaterialTheme.typography.bodyLarge) + } else { + Text( + text = "Publishes in ${timeAheadNoDot(scheduledForSec, context)}", + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + } + + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + showDatePicker = false + showTimePicker = true + }) { Text("Next") } + }, + ) { + DatePicker(state = datePickerState) + } + } + + if (showTimePicker) { + TimePickerDialog( + title = { Text("Time") }, + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton( + onClick = { + val datetimeLocalTimeZone = + datePickerState.selectedDateMillis?.let { localDayAtZeroHourMillis -> + (localDayAtZeroHourMillis / 1000) + + (timePickerState.hour * TimeUtils.ONE_HOUR) + + (timePickerState.minute * TimeUtils.ONE_MINUTE) + } ?: TimeUtils.oneDayAhead() + + val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) + val rawSec = datetimeLocalTimeZone - offset.totalSeconds + + onChanged(roundUpToNextQuarterHour(rawSec)) + showTimePicker = false + }, + ) { Text("Confirm") } + }, + ) { + TimePicker(state = timePickerState) + } + } +} + +@Composable +private fun ReliabilityWarning(hasMultipleAccounts: Boolean) { + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.Timer, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = "Always-on notifications disabled", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(start = 8.dp), + ) + } + Text( + text = + if (hasMultipleAccounts) { + "Scheduled posts may not publish until you reopen the app. Other accounts' scheduled posts won't fire while this account is active. Enable always-on in Settings → UI Preferences for reliable background scheduling." + } else { + "Scheduled posts may not publish until you next reopen the app. Enable always-on in Settings → UI Preferences for reliable background scheduling." + }, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(top = 6.dp), + ) + } + } +} + +/** + * Rounds [epochSec] up to the next 15-minute boundary. If already on a boundary, + * returns the boundary itself. Edge case: if rounding yields a moment in the past + * (rare — only if the user picks the exact current quarter-hour), bump forward + * one slot. + */ +internal fun roundUpToNextQuarterHour(epochSec: Long): Long { + val quarter = 15 * 60L + val rounded = ((epochSec + quarter - 1) / quarter) * quarter + val nowSec = System.currentTimeMillis() / 1000 + return if (rounded <= nowSec) rounded + quarter else rounded +} 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..0d700e842c 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 @@ -52,6 +52,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier @@ -61,6 +62,7 @@ import androidx.compose.ui.unit.dp import androidx.core.content.IntentCompat import androidx.core.net.toUri import androidx.core.util.Consumer +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -97,6 +99,9 @@ import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews +import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtButton +import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtPicker +import com.vitorpamplona.amethyst.ui.note.creators.scheduling.roundUpToNextQuarterHour import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription @@ -404,6 +409,26 @@ private fun NewPostScreenBody( } } + postViewModel.scheduledForSec?.let { current -> + val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService + .collectAsStateWithLifecycle() + val savedAccounts by com.vitorpamplona.amethyst.LocalPreferences + .accountsFlow() + .collectAsStateWithLifecycle() + val hasMultipleAccounts = (savedAccounts?.size ?: 0) > 1 + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), + ) { + ScheduleAtPicker( + scheduledForSec = current, + onChanged = { postViewModel.scheduledForSec = it }, + alwaysOnEnabled = alwaysOnEnabled, + hasMultipleAccounts = hasMultipleAccounts, + ) + } + } + if (postViewModel.wantsToAddGeoHash) { Row( verticalAlignment = CenterVertically, @@ -656,6 +681,16 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { postViewModel.toggleExpirationDate() } + ScheduleAtButton(postViewModel.scheduledForSec != null) { + postViewModel.scheduledForSec = + if (postViewModel.scheduledForSec != null) { + null + } else { + // Default to 1 hour from now, rounded up to the next 15-min slot + roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60) + } + } + AddGeoHashButton(postViewModel.wantsToAddGeoHash) { postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash } 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/ScheduledPostsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt new file mode 100644 index 0000000000..7eda5a5ea0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt @@ -0,0 +1,342 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduledPostsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val accountPubkey = accountViewModel.account.signer.pubKey + val viewModel: ScheduledPostsViewModel = + viewModel(key = "scheduled-posts-$accountPubkey") { + ScheduledPostsViewModel.create(accountPubkey) + } + val posts by viewModel.posts.collectAsStateWithLifecycle() + val context = LocalContext.current + + var pendingPublishId by remember { mutableStateOf(null) } + var pendingCancelId by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + ShorterTopAppBar( + title = { Text("Scheduled posts") }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() } + }, + ) + }, + ) { padding -> + if (posts.isEmpty()) { + EmptyState(modifier = Modifier.padding(padding)) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(posts, key = { it.id }) { post -> + ScheduledPostRow( + post = post, + onPublishNow = { pendingPublishId = post.id }, + onCancel = { pendingCancelId = post.id }, + ) + } + } + } + } + + pendingPublishId?.let { id -> + ConfirmDialog( + title = "Send now?", + message = "This post will publish to relays immediately. The original schedule will be discarded.", + confirmLabel = "Send", + onConfirm = { + viewModel.publishNow(id, context) + pendingPublishId = null + }, + onDismiss = { pendingPublishId = null }, + ) + } + + pendingCancelId?.let { id -> + ConfirmDialog( + title = "Delete scheduled post?", + message = "The post will not be published. This cannot be undone.", + confirmLabel = "Delete", + destructive = true, + onConfirm = { + viewModel.cancel(id) + pendingCancelId = null + }, + onDismiss = { pendingCancelId = null }, + ) + } +} + +@Composable +private fun ScheduledPostRow( + post: ScheduledPost, + onPublishNow: () -> Unit, + onCancel: () -> Unit, +) { + val context = LocalContext.current + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.outlinedCardColors(), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + StatusChip(post.status) + Text( + text = formatPublishMoment(post.publishAtSec, context), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + } + + Text( + text = extractPreview(post), + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + + if (post.status == ScheduledPostStatus.FAILED && post.lastError != null) { + Text( + text = "Error: ${post.lastError}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth(), + ) { + IconButton(onClick = onCancel) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = "Delete", + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + IconButton(onClick = onPublishNow) { + Icon( + symbol = MaterialSymbols.AutoMirrored.Send, + contentDescription = "Send now", + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} + +@Composable +private fun StatusChip(status: ScheduledPostStatus) { + val (label, tint) = + when (status) { + ScheduledPostStatus.PENDING -> "Scheduled" to Color(0xFF1E88E5) + ScheduledPostStatus.PUBLISHING -> "Sending…" to Color(0xFFFFA000) + ScheduledPostStatus.FAILED -> "Failed" to MaterialTheme.colorScheme.error + ScheduledPostStatus.SENT -> "Sent" to Color(0xFF43A047) + ScheduledPostStatus.CANCELLED -> "Cancelled" to MaterialTheme.colorScheme.onSurfaceVariant + } + AssistChip( + onClick = {}, + label = { Text(label, fontWeight = FontWeight.Medium) }, + colors = + AssistChipDefaults.assistChipColors( + labelColor = tint, + ), + ) +} + +@Composable +private fun EmptyState(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize().padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + symbol = MaterialSymbols.Schedule, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "No scheduled posts", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "Compose a note and tap the clock icon to schedule it for later.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ConfirmDialog( + title: String, + message: String, + confirmLabel: String, + destructive: Boolean = false, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + confirmLabel, + color = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +private fun extractPreview(post: ScheduledPost): String { + val json = post.signedEventJson + val needle = "\"content\":\"" + val start = json.indexOf(needle) + if (start < 0) return "" + val from = start + needle.length + val sb = StringBuilder() + var i = from + while (i < json.length) { + val c = json[i] + if (c == '\\' && i + 1 < json.length) { + when (json[i + 1]) { + 'n' -> sb.append('\n') + 't' -> sb.append('\t') + '\\' -> sb.append('\\') + '"' -> sb.append('"') + else -> sb.append(json[i + 1]) + } + i += 2 + } else if (c == '"') { + break + } else { + sb.append(c) + i++ + } + if (sb.length > 200) break + } + return sb.toString().trim() +} + +private fun formatPublishMoment( + publishAtSec: Long, + context: android.content.Context, +): String { + val nowSec = System.currentTimeMillis() / 1000 + val deltaSec = publishAtSec - nowSec + return when { + deltaSec > 0 -> { + "Publishes in ${timeAheadNoDot(publishAtSec, context)}" + } + + else -> { + val ago = -deltaSec + val mins = TimeUnit.SECONDS.toMinutes(ago) + when { + mins < 1 -> "Due now" + mins < 60 -> "Was due ${mins}m ago" + else -> "Was due ${TimeUnit.SECONDS.toHours(ago)}h ago" + } + } + } +} 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..82f7c35c60 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Drives the "Scheduled posts" screen for a single account. Filters the global + * ScheduledPostStore down to posts owned by [accountPubkey] that are still + * "in progress" — PENDING, PUBLISHING, or FAILED. SENT and CANCELLED rows are + * hidden from the list (they're "done"). + */ +class ScheduledPostsViewModel( + private val store: ScheduledPostStore, + private val accountPubkey: String, +) : ViewModel() { + private val activeStatuses = + setOf( + ScheduledPostStatus.PENDING, + ScheduledPostStatus.PUBLISHING, + ScheduledPostStatus.FAILED, + ) + + val posts: StateFlow> = + store.flow + .map { all -> + all + .filter { it.accountPubkey == accountPubkey && it.status in activeStatuses } + .sortedBy { it.publishAtSec } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + fun cancel(id: String) { + viewModelScope.launch(Dispatchers.IO) { + store.cancel(id) + } + } + + fun publishNow( + id: String, + context: Context, + ) { + viewModelScope.launch(Dispatchers.IO) { + if (store.publishNow(id)) { + ScheduledPostWorker.scheduleCatchUp(context) + } + } + } + + companion object { + fun create(accountPubkey: String): ScheduledPostsViewModel = + ScheduledPostsViewModel( + store = Amethyst.instance.scheduledPostStore, + accountPubkey = accountPubkey, + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0502a8da3d..ac0d94e023 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -428,6 +428,7 @@ Move All to New Bookmarks Bookmarks migrated successfully Drafts + Scheduled posts 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..f1c70c50bd --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt @@ -0,0 +1,352 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.scheduledposts + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class ScheduledPostStoreTest { + @get:Rule + val temp = TemporaryFolder() + + private lateinit var file: File + + @Before + fun setUp() { + file = File(temp.root, "scheduled_posts.json") + } + + private fun newStore() = ScheduledPostStore(file) + + private fun samplePost( + id: String = "id-1", + publishAtSec: Long = 1_000, + accountPubkey: String = "pk1", + ) = ScheduledPost( + id = id, + accountPubkey = accountPubkey, + signedEventJson = "{}", + relayUrls = listOf("wss://relay.example/"), + extraEventsJson = emptyList(), + publishAtSec = publishAtSec, + createdAtSec = 500, + ) + + @Test + fun add_persists_to_disk() = + runTest { + val store = newStore() + store.add(samplePost()) + assertTrue("storage file should exist after add", file.exists()) + + val reloaded = newStore().list() + assertEquals(1, reloaded.size) + assertEquals("id-1", reloaded[0].id) + assertEquals(ScheduledPostStatus.PENDING, reloaded[0].status) + } + + @Test + fun claimDuePosts_returns_only_due_pending_posts() = + runTest { + val store = newStore() + store.add(samplePost(id = "due", publishAtSec = 1000)) + store.add(samplePost(id = "future", publishAtSec = 5000)) + store.add(samplePost(id = "also-due", publishAtSec = 999)) + + val claimed = store.claimDuePosts(nowSec = 1000) + val ids = claimed.map { it.id }.toSet() + assertEquals(setOf("due", "also-due"), ids) + } + + @Test + fun claimDuePosts_flips_status_to_publishing() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 1000)) + + store.claimDuePosts(nowSec = 1000) + + val all = store.list() + assertEquals(ScheduledPostStatus.PUBLISHING, all[0].status) + assertEquals(1, all[0].attemptCount) + assertEquals(1000L, all[0].lastAttemptAtSec) + } + + @Test + fun claimDuePosts_second_call_returns_empty() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 1000)) + + val first = store.claimDuePosts(nowSec = 1000) + val second = store.claimDuePosts(nowSec = 1000) + + assertEquals(1, first.size) + assertEquals(0, second.size) + } + + @Test + fun concurrent_claimDuePosts_only_one_wins() = + runTest { + val store = newStore() + store.add(samplePost(id = "race", publishAtSec = 1000)) + + val results = + (1..10) + .map { async { store.claimDuePosts(nowSec = 1000) } } + .awaitAll() + + val totalClaimed = results.sumOf { it.size } + assertEquals("exactly one concurrent caller should claim the post", 1, totalClaimed) + } + + @Test + fun markSent_updates_status_and_clears_error() = + runTest { + val store = newStore() + store.add(samplePost()) + store.markFailed("id-1", "earlier error") + store.markSent("id-1") + + val all = store.list() + assertEquals(ScheduledPostStatus.SENT, all[0].status) + assertNull(all[0].lastError) + } + + @Test + fun markFailed_records_error() = + runTest { + val store = newStore() + store.add(samplePost()) + store.markFailed("id-1", "boom") + + val all = store.list() + assertEquals(ScheduledPostStatus.FAILED, all[0].status) + assertEquals("boom", all[0].lastError) + } + + @Test + fun releaseClaim_reverts_publishing_to_pending() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 1000)) + store.claimDuePosts(nowSec = 1000) + + store.releaseClaim("id-1") + + val all = store.list() + assertEquals(ScheduledPostStatus.PENDING, all[0].status) + } + + @Test + fun releaseClaim_does_not_touch_non_publishing() = + runTest { + val store = newStore() + store.add(samplePost()) + store.releaseClaim("id-1") + + assertEquals(ScheduledPostStatus.PENDING, store.list()[0].status) + } + + @Test + fun cancel_sets_cancelled_status_and_returns_true() = + runTest { + val store = newStore() + store.add(samplePost()) + + val ok = store.cancel("id-1") + + assertTrue(ok) + assertEquals(ScheduledPostStatus.CANCELLED, store.list()[0].status) + } + + @Test + fun cancel_unknown_id_returns_false() = + runTest { + val store = newStore() + assertEquals(false, store.cancel("nope")) + } + + @Test + fun listFor_filters_by_account() = + runTest { + val store = newStore() + store.add(samplePost(id = "a", accountPubkey = "pk-a")) + store.add(samplePost(id = "b", accountPubkey = "pk-b")) + store.add(samplePost(id = "c", accountPubkey = "pk-a")) + + val filtered = store.listFor("pk-a").map { it.id }.toSet() + assertEquals(setOf("a", "c"), filtered) + } + + @Test + fun cancelled_posts_are_not_claimed() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 1000)) + store.cancel("id-1") + + val claimed = store.claimDuePosts(nowSec = 5000) + + assertEquals(0, claimed.size) + } + + @Test + fun missing_file_loads_as_empty() = + runTest { + assertTrue("file should not exist before first read", !file.exists()) + val store = newStore() + assertEquals(0, store.list().size) + } + + @Test + fun corrupt_file_loads_as_empty() = + runTest { + file.writeText("not valid json {{{") + val store = newStore() + assertEquals(0, store.list().size) + } + + @Test + fun publishNow_sets_publishAtSec_to_now_and_status_pending() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 9_999_999)) + + val ok = store.publishNow("id-1", nowSec = 1234) + + assertTrue(ok) + val updated = store.list().single() + assertEquals(ScheduledPostStatus.PENDING, updated.status) + assertEquals(1234L, updated.publishAtSec) + } + + @Test + fun publishNow_clears_failed_state_for_retry() = + runTest { + val store = newStore() + store.add(samplePost()) + store.markFailed("id-1", "earlier failure") + + store.publishNow("id-1", nowSec = 5000) + + val updated = store.list().single() + assertEquals(ScheduledPostStatus.PENDING, updated.status) + assertNull(updated.lastError) + } + + @Test + fun publishNow_unknown_id_returns_false() = + runTest { + val store = newStore() + assertEquals(false, store.publishNow("nope")) + } + + @Test + fun publishNow_makes_post_immediately_claimable() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 9_999_999)) + assertEquals(0, store.claimDuePosts(nowSec = 1000).size) + + store.publishNow("id-1", nowSec = 1000) + + val claimed = store.claimDuePosts(nowSec = 1000) + assertEquals(1, claimed.size) + assertEquals("id-1", claimed[0].id) + } + + @Test + fun flow_emits_initial_empty_then_post_after_add() = + runTest { + val store = newStore() + assertEquals(0, store.flow.value.size) + + store.add(samplePost()) + + assertEquals(1, store.flow.value.size) + assertEquals("id-1", store.flow.value[0].id) + } + + @Test + fun flow_reflects_status_transitions() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 1000)) + assertEquals(ScheduledPostStatus.PENDING, store.flow.value[0].status) + + store.claimDuePosts(nowSec = 1000) + assertEquals(ScheduledPostStatus.PUBLISHING, store.flow.value[0].status) + + store.markSent("id-1") + assertEquals(ScheduledPostStatus.SENT, store.flow.value[0].status) + } + + @Test + fun flow_seeded_from_disk_on_first_access() = + runTest { + // Pre-populate the file via a first store instance + newStore().add(samplePost()) + + // Second store starts with empty in-memory flow until first access + val store = newStore() + assertEquals(0, store.flow.value.size) + + // Triggering any read method causes ensureLoaded() to seed the flow + store.list() + assertEquals(1, store.flow.value.size) + } + + @Test + fun roundtrip_preserves_all_fields() = + runTest { + val original = + ScheduledPost( + id = "roundtrip", + accountPubkey = "pk-x", + signedEventJson = """{"kind":1,"content":"hi"}""", + relayUrls = listOf("wss://a/", "wss://b/"), + extraEventsJson = listOf("{}", "{}"), + publishAtSec = 1_700_000_000, + createdAtSec = 1_699_900_000, + status = ScheduledPostStatus.PENDING, + lastAttemptAtSec = null, + attemptCount = 0, + lastError = null, + ) + newStore().add(original) + + val reloaded = newStore().list().single() + assertEquals(original, reloaded) + assertNotNull(reloaded.relayUrls) + assertEquals(2, reloaded.relayUrls.size) + } +} From 214a35d620ba052b04c7f71dbfd2e037ca4c8684 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 7 May 2026 12:40:33 +0200 Subject: [PATCH 02/11] Code review - catch CancellationException explicitly - document the deliberate tradeoff of holding the data mutex across the disk write in persist() - migrate hardcoded strings to R.string.* - Store.mutate: use value equality (==) instead of reference (===) - Store.persist: drop the redundant parent.exists() check - ViewModel: drop the Context parameter from publishNow(id) - Screen: memoize extractPreview via remember(post.id) so JSON scanning doesn't run on every recomposition of a row. - Screen: replace hardcoded Color(0xFF...) literals - Screen.formatPublishMoment: delegate the past-tense branch to the existing timeAgoNoDot() helper instead of hand-rolled TimeUnit math. --- .../scheduledposts/ScheduledPostStore.kt | 16 ++- .../scheduledposts/ScheduledPostWorker.kt | 5 + .../creators/scheduling/ScheduleAtButton.kt | 12 +- .../creators/scheduling/ScheduleAtPicker.kt | 44 +++---- .../loggedIn/home/ShortNotePostScreen.kt | 12 +- .../scheduledposts/ScheduledPostsScreen.kt | 109 +++++++----------- .../scheduledposts/ScheduledPostsViewModel.kt | 11 +- amethyst/src/main/res/values/strings.xml | 29 +++++ 8 files changed, 128 insertions(+), 110 deletions(-) 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 index 888c69e444..82a8d63c9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt @@ -168,7 +168,7 @@ class ScheduledPostStore( if (idx < 0) return false val before = posts[idx] val after = transform(before) - if (after === before) return false + if (after == before) return false posts[idx] = after return true } @@ -190,11 +190,21 @@ class ScheduledPostStore( _flow.value = posts.toList() } + /** + * 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 - val parent = storageFile.parentFile - if (parent != null && !parent.exists()) parent.mkdirs() + storageFile.parentFile?.mkdirs() val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") try { mapper.writeValue(tmp, ScheduledPostFile(version = 1, posts = snapshot)) 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 index b2d6332d0c..d96a3838b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException import java.util.concurrent.TimeUnit /** @@ -152,6 +153,8 @@ class ScheduledPostWorker( store.markSent(post.id) Log.d(TAG) { "client.publish(${post.id}) done; marked SENT" } + } 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) @@ -160,6 +163,8 @@ class ScheduledPostWorker( 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() 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 index d7d1a36335..1c5440e9b6 100644 --- 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 @@ -25,22 +25,26 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.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() }) { + IconButton(onClick = onClick) { Icon( symbol = MaterialSymbols.Schedule, - contentDescription = if (isActive) "Cancel scheduling" else "Schedule post", + 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) Color(0xFF1E88E5) else MaterialTheme.colorScheme.onBackground, + 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 index dfa539212f..13ac2731c3 100644 --- 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.scheduling +import android.text.format.DateFormat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -49,14 +50,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.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 @@ -98,15 +100,15 @@ fun ScheduleAtPicker( }, ) + val context = LocalContext.current + val timePickerState = rememberTimePickerState( initialHour = currentTime.hour, initialMinute = currentTime.minute, - is24Hour = false, + is24Hour = DateFormat.is24HourFormat(context), ) - val context = LocalContext.current - Column(Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, @@ -117,13 +119,13 @@ fun ScheduleAtPicker( ) { Icon( symbol = MaterialSymbols.Timer, - contentDescription = "Scheduled time", + contentDescription = stringRes(R.string.schedule_post_time_label), modifier = Modifier.size(20.dp), - tint = Color(0xFF1E88E5), + tint = MaterialTheme.colorScheme.primary, ) Text( - text = "Schedule", + text = stringRes(R.string.schedule_post), fontSize = 20.sp, fontWeight = FontWeight.W500, modifier = Modifier.padding(start = 10.dp), @@ -133,7 +135,7 @@ fun ScheduleAtPicker( HorizontalDivider(thickness = DividerThickness) Text( - text = "Posts publish within ~15 minutes of the scheduled time.", + text = stringRes(R.string.schedule_post_helper), color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(vertical = 10.dp), ) @@ -150,14 +152,14 @@ fun ScheduleAtPicker( modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon(MaterialSymbols.Timer, contentDescription = "Pick scheduled time") + Icon(MaterialSymbols.Timer, contentDescription = stringRes(R.string.schedule_post_pick_time)) Spacer(Modifier.width(12.dp)) if (scheduledForSec < TimeUtils.oneMinuteFromNow()) { - Text("Schedule for…", style = MaterialTheme.typography.bodyLarge) + Text(stringRes(R.string.schedule_post_pick_label), style = MaterialTheme.typography.bodyLarge) } else { Text( - text = "Publishes in ${timeAheadNoDot(scheduledForSec, context)}", + text = stringRes(R.string.schedule_post_publishes_in, timeAheadNoDot(scheduledForSec, context)), style = MaterialTheme.typography.bodyLarge, ) } @@ -172,7 +174,7 @@ fun ScheduleAtPicker( TextButton(onClick = { showDatePicker = false showTimePicker = true - }) { Text("Next") } + }) { Text(stringRes(R.string.next)) } }, ) { DatePicker(state = datePickerState) @@ -181,7 +183,7 @@ fun ScheduleAtPicker( if (showTimePicker) { TimePickerDialog( - title = { Text("Time") }, + title = { Text(stringRes(R.string.schedule_post_picker_time_title)) }, onDismissRequest = { showTimePicker = false }, confirmButton = { TextButton( @@ -199,7 +201,7 @@ fun ScheduleAtPicker( onChanged(roundUpToNextQuarterHour(rawSec)) showTimePicker = false }, - ) { Text("Confirm") } + ) { Text(stringRes(R.string.confirm)) } }, ) { TimePicker(state = timePickerState) @@ -225,7 +227,7 @@ private fun ReliabilityWarning(hasMultipleAccounts: Boolean) { tint = MaterialTheme.colorScheme.onErrorContainer, ) Text( - text = "Always-on notifications disabled", + text = stringRes(R.string.schedule_post_warning_title), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.padding(start = 8.dp), @@ -233,11 +235,13 @@ private fun ReliabilityWarning(hasMultipleAccounts: Boolean) { } Text( text = - if (hasMultipleAccounts) { - "Scheduled posts may not publish until you reopen the app. Other accounts' scheduled posts won't fire while this account is active. Enable always-on in Settings → UI Preferences for reliable background scheduling." - } else { - "Scheduled posts may not publish until you next reopen the app. Enable always-on in Settings → UI Preferences for reliable background scheduling." - }, + 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), ) 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 0d700e842c..d8e6c211ef 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 @@ -409,13 +409,13 @@ 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 -> - val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService - .collectAsStateWithLifecycle() - val savedAccounts by com.vitorpamplona.amethyst.LocalPreferences - .accountsFlow() - .collectAsStateWithLifecycle() - val hasMultipleAccounts = (savedAccounts?.size ?: 0) > 1 Row( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), 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 index 7eda5a5ea0..33a2814e1c 100644 --- 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 @@ -49,23 +49,26 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.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.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar 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 java.util.concurrent.TimeUnit +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Event @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -87,7 +90,7 @@ fun ScheduledPostsScreen( Scaffold( topBar = { ShorterTopAppBar( - title = { Text("Scheduled posts") }, + title = { Text(stringRes(R.string.scheduled_posts)) }, navigationIcon = { IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() } }, @@ -115,11 +118,12 @@ fun ScheduledPostsScreen( pendingPublishId?.let { id -> ConfirmDialog( - title = "Send now?", - message = "This post will publish to relays immediately. The original schedule will be discarded.", - confirmLabel = "Send", + title = stringRes(R.string.scheduled_posts_send_now_title), + message = stringRes(R.string.scheduled_posts_send_now_message), + confirmLabel = stringRes(R.string.scheduled_posts_send_now_confirm), onConfirm = { - viewModel.publishNow(id, context) + viewModel.publishNow(id) + ScheduledPostWorker.scheduleCatchUp(context) pendingPublishId = null }, onDismiss = { pendingPublishId = null }, @@ -128,9 +132,9 @@ fun ScheduledPostsScreen( pendingCancelId?.let { id -> ConfirmDialog( - title = "Delete scheduled post?", - message = "The post will not be published. This cannot be undone.", - confirmLabel = "Delete", + title = stringRes(R.string.scheduled_posts_delete_title), + message = stringRes(R.string.scheduled_posts_delete_message), + confirmLabel = stringRes(R.string.scheduled_posts_delete_confirm), destructive = true, onConfirm = { viewModel.cancel(id) @@ -148,6 +152,7 @@ private fun ScheduledPostRow( onCancel: () -> Unit, ) { val context = LocalContext.current + val preview = remember(post) { extractPreview(post) } Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.outlinedCardColors(), @@ -171,7 +176,7 @@ private fun ScheduledPostRow( } Text( - text = extractPreview(post), + text = preview, style = MaterialTheme.typography.bodyMedium, maxLines = 3, overflow = TextOverflow.Ellipsis, @@ -179,7 +184,7 @@ private fun ScheduledPostRow( if (post.status == ScheduledPostStatus.FAILED && post.lastError != null) { Text( - text = "Error: ${post.lastError}", + text = stringRes(R.string.scheduled_posts_error_prefix, post.lastError), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, maxLines = 2, @@ -194,7 +199,7 @@ private fun ScheduledPostRow( IconButton(onClick = onCancel) { Icon( symbol = MaterialSymbols.Delete, - contentDescription = "Delete", + contentDescription = stringRes(R.string.scheduled_posts_action_delete), modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.error, ) @@ -202,7 +207,7 @@ private fun ScheduledPostRow( IconButton(onClick = onPublishNow) { Icon( symbol = MaterialSymbols.AutoMirrored.Send, - contentDescription = "Send now", + contentDescription = stringRes(R.string.scheduled_posts_action_send_now), modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.primary, ) @@ -214,17 +219,17 @@ private fun ScheduledPostRow( @Composable private fun StatusChip(status: ScheduledPostStatus) { - val (label, tint) = + val (labelRes, tint) = when (status) { - ScheduledPostStatus.PENDING -> "Scheduled" to Color(0xFF1E88E5) - ScheduledPostStatus.PUBLISHING -> "Sending…" to Color(0xFFFFA000) - ScheduledPostStatus.FAILED -> "Failed" to MaterialTheme.colorScheme.error - ScheduledPostStatus.SENT -> "Sent" to Color(0xFF43A047) - ScheduledPostStatus.CANCELLED -> "Cancelled" to MaterialTheme.colorScheme.onSurfaceVariant + ScheduledPostStatus.PENDING -> R.string.scheduled_posts_status_pending to MaterialTheme.colorScheme.primary + ScheduledPostStatus.PUBLISHING -> R.string.scheduled_posts_status_publishing to MaterialTheme.colorScheme.tertiary + ScheduledPostStatus.FAILED -> R.string.scheduled_posts_status_failed to MaterialTheme.colorScheme.error + ScheduledPostStatus.SENT -> R.string.scheduled_posts_status_sent to MaterialTheme.colorScheme.tertiary + ScheduledPostStatus.CANCELLED -> R.string.scheduled_posts_status_cancelled to MaterialTheme.colorScheme.onSurfaceVariant } AssistChip( onClick = {}, - label = { Text(label, fontWeight = FontWeight.Medium) }, + label = { Text(stringRes(labelRes), fontWeight = FontWeight.Medium) }, colors = AssistChipDefaults.assistChipColors( labelColor = tint, @@ -249,11 +254,11 @@ private fun EmptyState(modifier: Modifier = Modifier) { tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = "No scheduled posts", + text = stringRes(R.string.scheduled_posts_empty_title), style = MaterialTheme.typography.titleMedium, ) Text( - text = "Compose a note and tap the clock icon to schedule it for later.", + text = stringRes(R.string.scheduled_posts_empty_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -283,60 +288,28 @@ private fun ConfirmDialog( } }, dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } }, ) } -private fun extractPreview(post: ScheduledPost): String { - val json = post.signedEventJson - val needle = "\"content\":\"" - val start = json.indexOf(needle) - if (start < 0) return "" - val from = start + needle.length - val sb = StringBuilder() - var i = from - while (i < json.length) { - val c = json[i] - if (c == '\\' && i + 1 < json.length) { - when (json[i + 1]) { - 'n' -> sb.append('\n') - 't' -> sb.append('\t') - '\\' -> sb.append('\\') - '"' -> sb.append('"') - else -> sb.append(json[i + 1]) - } - i += 2 - } else if (c == '"') { - break - } else { - sb.append(c) - i++ - } - if (sb.length > 200) break - } - return sb.toString().trim() -} +private fun extractPreview(post: ScheduledPost): String = + runCatching { + Event + .fromJson(post.signedEventJson) + .content + .take(200) + .trim() + }.getOrDefault("") private fun formatPublishMoment( publishAtSec: Long, context: android.content.Context, ): String { val nowSec = System.currentTimeMillis() / 1000 - val deltaSec = publishAtSec - nowSec - return when { - deltaSec > 0 -> { - "Publishes in ${timeAheadNoDot(publishAtSec, context)}" - } - - else -> { - val ago = -deltaSec - val mins = TimeUnit.SECONDS.toMinutes(ago) - when { - mins < 1 -> "Due now" - mins < 60 -> "Was due ${mins}m ago" - else -> "Was due ${TimeUnit.SECONDS.toHours(ago)}h ago" - } - } + return if (publishAtSec > nowSec) { + stringRes(context, R.string.schedule_post_publishes_in, timeAheadNoDot(publishAtSec, context)) + } else { + stringRes(context, R.string.schedule_post_was_due, timeAgoNoDot(publishAtSec, context).trim()) } } 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 index 82f7c35c60..d9cbab00cf 100644 --- 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 @@ -20,14 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts -import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -70,14 +68,9 @@ class ScheduledPostsViewModel( } } - fun publishNow( - id: String, - context: Context, - ) { + fun publishNow(id: String) { viewModelScope.launch(Dispatchers.IO) { - if (store.publishNow(id)) { - ScheduledPostWorker.scheduleCatchUp(context) - } + store.publishNow(id) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ac0d94e023..6a7e4264f8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -429,6 +429,35 @@ 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. + Send now? + This post will publish to relays immediately. The original schedule will be discarded. + Send + Delete scheduled post? + The post will not be published. This cannot be undone. + Delete + Delete + 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 Polls Open Closed From 0bbbdc2f65ecc54d8786c7b3265467d6f67ca5b7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 7 May 2026 13:34:12 +0200 Subject: [PATCH 03/11] Polish: - Delete scheduled posts on logout - presets, list grouping, drawer badge, always-on prompt, logout toast - bech hardening, retention, live countdown - Replace `bechToBytes()` chains at three account sites with the null-safe `decodePrivateKeyAsHexOrNull` / `decodePublicKeyAsHexOrNull`. A malformed npub no longer crashes the LogoutButton composable tree or leaves the logoff path half-cleaned (deleted account row but cache + scheduled posts still in memory). --- .../service/scheduledposts/ScheduledPost.kt | 2 + .../scheduledposts/ScheduledPostStore.kt | 46 ++++- .../drawer/AccountSwitchBottomSheet.kt | 44 +++- .../ui/navigation/drawer/DrawerContent.kt | 79 ++++++- .../creators/scheduling/ScheduleAtPicker.kt | 56 +++++ .../ui/screen/AccountSessionManager.kt | 21 +- .../loggedIn/home/ShortNotePostScreen.kt | 71 ++++++- .../scheduledposts/ScheduledPostsScreen.kt | 127 ++++++++---- .../scheduledposts/ScheduledPostsViewModel.kt | 27 +++ amethyst/src/main/res/values/strings.xml | 14 ++ .../scheduledposts/ScheduledPostStoreTest.kt | 192 ++++++++++++++++++ 11 files changed, 622 insertions(+), 57 deletions(-) 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 index 91ba77a9d9..320782d470 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt @@ -40,6 +40,8 @@ data class ScheduledPost( 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( 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 index 82a8d63c9f..285286826f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt @@ -33,6 +33,7 @@ import java.io.File class ScheduledPostStore( private val storageFile: File, + private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 }, ) { private val mapper = jacksonObjectMapper() @@ -57,7 +58,8 @@ class ScheduledPostStore( suspend fun cancel(id: String): Boolean = mutex.withLock { ensureLoaded() - val updated = mutate(id) { it.copy(status = ScheduledPostStatus.CANCELLED) } + val now = nowSec() + val updated = mutate(id) { it.copy(status = ScheduledPostStatus.CANCELLED, terminatedAtSec = now) } if (updated) persist() updated } @@ -104,7 +106,8 @@ class ScheduledPostStore( suspend fun markSent(id: String) = mutex.withLock { ensureLoaded() - if (mutate(id) { it.copy(status = ScheduledPostStatus.SENT, lastError = null) }) persist() + val now = nowSec() + if (mutate(id) { it.copy(status = ScheduledPostStatus.SENT, lastError = null, terminatedAtSec = now) }) persist() } suspend fun markFailed( @@ -135,12 +138,28 @@ class ScheduledPostStore( 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 @@ -187,7 +206,28 @@ class ScheduledPostStore( 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 -> + 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 } /** @@ -224,5 +264,7 @@ class ScheduledPostStore( 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/ui/navigation/drawer/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt index 3560b2f1f2..d0b92f582d 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,55 @@ 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 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..049b2ef6e8 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 @@ -604,7 +604,84 @@ 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 com.vitorpamplona.amethyst.Amethyst + .instance.scheduledPostStore.flow + .collectAsStateWithLifecycle() + val pendingCount by remember(accountHex) { + derivedStateOf { + allPosts.count { + it.accountPubkey == accountHex && + ( + it.status == com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus.PENDING || + it.status == com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus.PUBLISHING || + it.status == com.vitorpamplona.amethyst.service.scheduledposts.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) { + androidx.compose.material3.Badge { + Text(badgeCount.toString()) } } } 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 index 13ac2731c3..fc90827347 100644 --- 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 @@ -21,6 +21,8 @@ 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 @@ -28,6 +30,8 @@ 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 @@ -62,9 +66,13 @@ 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. @@ -144,6 +152,8 @@ fun ScheduleAtPicker( ReliabilityWarning(hasMultipleAccounts = hasMultipleAccounts) } + PresetChips(onPick = onChanged) + OutlinedCard( onClick = { showDatePicker = true }, modifier = Modifier.fillMaxWidth(), @@ -249,6 +259,52 @@ private fun ReliabilityWarning(hasMultipleAccounts: Boolean) { } } +@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 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/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index d8e6c211ef..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,12 +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 @@ -81,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 @@ -599,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 = @@ -681,15 +738,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { postViewModel.toggleExpirationDate() } - ScheduleAtButton(postViewModel.scheduledForSec != null) { - postViewModel.scheduledForSec = - if (postViewModel.scheduledForSec != null) { - null - } else { - // Default to 1 hour from now, rounded up to the next 15-min slot - roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60) - } - } + 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/scheduledposts/ScheduledPostsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt index 33a2814e1c..fdfc50fb1d 100644 --- 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 @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -40,11 +42,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -61,6 +65,7 @@ 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.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon @@ -69,8 +74,13 @@ import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlinx.coroutines.delay +import java.text.DateFormat +import java.time.LocalDate +import java.time.ZoneId +import java.util.Date -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ScheduledPostsScreen( accountViewModel: AccountViewModel, @@ -82,10 +92,19 @@ fun ScheduledPostsScreen( ScheduledPostsViewModel.create(accountPubkey) } val posts by viewModel.posts.collectAsStateWithLifecycle() + val groups by viewModel.groupedPosts.collectAsStateWithLifecycle() val context = LocalContext.current var pendingPublishId by remember { mutableStateOf(null) } - var pendingCancelId 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 + } + } Scaffold( topBar = { @@ -102,15 +121,25 @@ fun ScheduledPostsScreen( } else { LazyColumn( modifier = Modifier.fillMaxSize().padding(padding), - contentPadding = PaddingValues(12.dp), + contentPadding = PaddingValues(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(posts, key = { it.id }) { post -> - ScheduledPostRow( - post = post, - onPublishNow = { pendingPublishId = post.id }, - onCancel = { pendingCancelId = post.id }, - ) + groups.forEach { group -> + stickyHeader(key = "header-${group.day}") { + DayHeader(group.day, context) + } + items(group.posts, key = { it.id }) { post -> + SwipeToDeleteWithConfirmation( + modifier = Modifier.fillMaxWidth().animateContentSize(), + onDelete = { viewModel.cancel(post.id) }, + ) { + ScheduledPostRow( + post = post, + nowSec = nowSec, + onPublishNow = { pendingPublishId = post.id }, + ) + } + } } } } @@ -129,27 +158,13 @@ fun ScheduledPostsScreen( onDismiss = { pendingPublishId = null }, ) } - - pendingCancelId?.let { id -> - ConfirmDialog( - title = stringRes(R.string.scheduled_posts_delete_title), - message = stringRes(R.string.scheduled_posts_delete_message), - confirmLabel = stringRes(R.string.scheduled_posts_delete_confirm), - destructive = true, - onConfirm = { - viewModel.cancel(id) - pendingCancelId = null - }, - onDismiss = { pendingCancelId = null }, - ) - } } @Composable private fun ScheduledPostRow( post: ScheduledPost, + nowSec: Long, onPublishNow: () -> Unit, - onCancel: () -> Unit, ) { val context = LocalContext.current val preview = remember(post) { extractPreview(post) } @@ -168,7 +183,7 @@ private fun ScheduledPostRow( ) { StatusChip(post.status) Text( - text = formatPublishMoment(post.publishAtSec, context), + text = formatAtTime(post.publishAtSec, nowSec, context), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), @@ -196,14 +211,6 @@ private fun ScheduledPostRow( horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth(), ) { - IconButton(onClick = onCancel) { - Icon( - symbol = MaterialSymbols.Delete, - contentDescription = stringRes(R.string.scheduled_posts_action_delete), - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.error, - ) - } IconButton(onClick = onPublishNow) { Icon( symbol = MaterialSymbols.AutoMirrored.Send, @@ -217,6 +224,25 @@ private fun ScheduledPostRow( } } +@Composable +private fun DayHeader( + day: LocalDate, + context: android.content.Context, +) { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = formatDayHeader(day, context), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } +} + @Composable private fun StatusChip(status: ScheduledPostStatus) { val (labelRes, tint) = @@ -302,14 +328,41 @@ private fun extractPreview(post: ScheduledPost): String = .trim() }.getOrDefault("") -private fun formatPublishMoment( +private fun formatAtTime( publishAtSec: Long, + nowSec: Long, context: android.content.Context, ): String { - val nowSec = System.currentTimeMillis() / 1000 + val timeFormat = DateFormat.getTimeInstance(DateFormat.SHORT) + val absolute = timeFormat.format(Date(publishAtSec * 1000)) return if (publishAtSec > nowSec) { - stringRes(context, R.string.schedule_post_publishes_in, timeAheadNoDot(publishAtSec, context)) + stringRes(context, R.string.scheduled_posts_at_time, absolute, timeAheadNoDot(publishAtSec, context)) } else { - stringRes(context, R.string.schedule_post_was_due, timeAgoNoDot(publishAtSec, context).trim()) + stringRes(context, R.string.scheduled_posts_at_time_past, absolute, timeAgoNoDot(publishAtSec, context).trim()) + } +} + +private fun formatDayHeader( + day: LocalDate, + context: android.content.Context, +): String { + val today = LocalDate.now(ZoneId.systemDefault()) + return when (day) { + today -> { + stringRes(context, R.string.scheduled_posts_day_today) + } + + today.plusDays(1) -> { + stringRes(context, R.string.scheduled_posts_day_tomorrow) + } + + else -> { + val fullFormat = DateFormat.getDateInstance(DateFormat.FULL) + fullFormat.format( + Date.from( + day.atStartOfDay(ZoneId.systemDefault()).toInstant(), + ), + ) + } } } 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 index d9cbab00cf..e0aa58998b 100644 --- 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 @@ -32,6 +32,15 @@ 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 @@ -62,6 +71,24 @@ class ScheduledPostsViewModel( initialValue = emptyList(), ) + /** + * Posts grouped by local-day, sorted ascending. The UI uses this as the + * source for sticky-header sections. + */ + val groupedPosts: StateFlow> = + posts + .map { sorted -> + val zone = ZoneId.systemDefault() + sorted + .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(), + ) + fun cancel(id: String) { viewModelScope.launch(Dispatchers.IO) { store.cancel(id) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6a7e4264f8..e0f925a226 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -442,6 +442,19 @@ 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 + Today + Tomorrow + Logged out + Logged out · %1$d scheduled post(s) deleted Send now? This post will publish to relays immediately. The original schedule will be discarded. Send @@ -458,6 +471,7 @@ Failed Sent Cancelled + You have %1$d scheduled post(s) that haven\'t been published yet. Logging out will permanently delete them. 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 index f1c70c50bd..b0947747b4 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt @@ -46,6 +46,8 @@ class ScheduledPostStoreTest { private fun newStore() = ScheduledPostStore(file) + private fun newStore(now: () -> Long) = ScheduledPostStore(file, now) + private fun samplePost( id: String = "id-1", publishAtSec: Long = 1_000, @@ -325,6 +327,196 @@ class ScheduledPostStoreTest { 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 { From a3024d855d135d0ea88f2243a929a581024b4656 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 7 May 2026 15:14:30 +0200 Subject: [PATCH 04/11] =?UTF-8?q?Code=20review:=20-=20Switch=20list-screen?= =?UTF-8?q?=20formatters=20from=20java.text.DateFormat=20to=20=20=20java.t?= =?UTF-8?q?ime.format.DateTimeFormatter.=20DateFormat=20/=20SimpleDateForm?= =?UTF-8?q?at=20=20=20are=20documented=20not-thread-safe;=20the=20file-sco?= =?UTF-8?q?pe=20singletons=20are=20at=20=20=20risk=20under=20any=20future?= =?UTF-8?q?=20multi-threaded=20recomposition=20path.=20=20=20DateTimeForma?= =?UTF-8?q?tter=20is=20immutable=20and=20thread-safe.=20-=20LogoutButton:?= =?UTF-8?q?=20when=20decodePublicKeyAsHexOrNull=20fails,=20bail=20before?= =?UTF-8?q?=20=20=20the=20Toast=20fires=20so=20we=20don't=20claim=20"Logge?= =?UTF-8?q?d=20out"=20while=20logOff's=20=20=20coroutine=20has=20aborted?= =?UTF-8?q?=20its=20cleanup.=20Theoretical=20case=20(npub=20from=20=20=20p?= =?UTF-8?q?refs=20is=20well-formed=20in=20practice)=20but=20the=20previous?= =?UTF-8?q?=20flow=20was=20=20=20misleading=20on=20the=20failure=20path.?= =?UTF-8?q?=20-=20ScheduledPostStore.purgeStale:=20document=20the=20=20=20?= =?UTF-8?q?terminatedAtSec=20=3F:=20lastAttemptAtSec=20=3F:=20createdAtSec?= =?UTF-8?q?=20fallback=20so=20=20=20the=20legacy-row=20migration=20semanti?= =?UTF-8?q?cs=20are=20clear=20from=20the=20source.=20-=20DrawerContent:=20?= =?UTF-8?q?drop=20fully-qualified=20inline=20references,=20add=20proper=20?= =?UTF-8?q?=20=20imports=20for=20Amethyst,=20ScheduledPostStatus,=20and=20?= =?UTF-8?q?Material3=20Badge.=20-=20ScheduledPostsScreen:=20drop=20the=20r?= =?UTF-8?q?edundant=20`posts`=20collection=20=E2=80=94=20derive=20=20=20th?= =?UTF-8?q?e=20empty-state=20branch=20from=20`groups`=20directly.=20Use=20?= =?UTF-8?q?`group.day`=20as=20the=20=20=20sticky-header=20key=20instead=20?= =?UTF-8?q?of=20an=20interpolated=20string.=20Cache=20the=20SHORT=20=20=20?= =?UTF-8?q?time=20and=20FULL=20date=20`DateFormat`=20instances=20at=20file?= =?UTF-8?q?=20scope=20(matches=20the=20=20=20pattern=20in=20TimeAgoFormatt?= =?UTF-8?q?er).=20Pass=20`today`=20into=20`DayHeader`=20so=20we=20=20=20do?= =?UTF-8?q?n't=20call=20`LocalDate.now()`=20per=20recomposition.=20-=20Sch?= =?UTF-8?q?eduledPostsViewModel:=20drop=20the=20intermediate=20public=20`p?= =?UTF-8?q?osts`=20=20=20StateFlow=20now=20that=20no=20consumer=20needs=20?= =?UTF-8?q?it;=20fold=20the=20filter+sort=20into=20=20=20the=20`groupedPos?= =?UTF-8?q?ts`=20chain.=20-=20ScheduledPostStoreTest:=20collapse=20the=20t?= =?UTF-8?q?wo=20`newStore()`=20overloads=20into=20=20=20one=20with=20a=20d?= =?UTF-8?q?efaulted=20`now:=20()=20->=20Long`=20parameter.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduledposts/ScheduledPostStore.kt | 4 ++ .../drawer/AccountSwitchBottomSheet.kt | 3 ++ .../ui/navigation/drawer/DrawerContent.kt | 16 +++--- .../scheduledposts/ScheduledPostsScreen.kt | 53 +++++++++---------- .../scheduledposts/ScheduledPostsViewModel.kt | 19 ++----- .../scheduledposts/ScheduledPostStoreTest.kt | 4 +- 6 files changed, 43 insertions(+), 56 deletions(-) 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 index 285286826f..325ad10ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt @@ -220,6 +220,10 @@ class ScheduledPostStore( 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 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 d0b92f582d..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 @@ -303,6 +303,9 @@ private fun LogoutButton( // 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) { 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 049b2ef6e8..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 @@ -622,17 +625,16 @@ private fun ScheduledPostsNavigationRow( nav: INav, ) { val accountHex = accountViewModel.account.signer.pubKey - val allPosts by com.vitorpamplona.amethyst.Amethyst - .instance.scheduledPostStore.flow + val allPosts by Amethyst.instance.scheduledPostStore.flow .collectAsStateWithLifecycle() val pendingCount by remember(accountHex) { derivedStateOf { allPosts.count { it.accountPubkey == accountHex && ( - it.status == com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus.PENDING || - it.status == com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus.PUBLISHING || - it.status == com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus.FAILED + it.status == ScheduledPostStatus.PENDING || + it.status == ScheduledPostStatus.PUBLISHING || + it.status == ScheduledPostStatus.FAILED ) } } @@ -680,9 +682,7 @@ private fun IconRowWithBadge( fontSize = Font18SP, ) if (badgeCount > 0) { - androidx.compose.material3.Badge { - Text(badgeCount.toString()) - } + Badge { Text(badgeCount.toString()) } } } } 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 index fdfc50fb1d..6022cc3d37 100644 --- 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 @@ -75,10 +75,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.Event import kotlinx.coroutines.delay -import java.text.DateFormat +import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.util.Date +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -91,7 +92,6 @@ fun ScheduledPostsScreen( viewModel(key = "scheduled-posts-$accountPubkey") { ScheduledPostsViewModel.create(accountPubkey) } - val posts by viewModel.posts.collectAsStateWithLifecycle() val groups by viewModel.groupedPosts.collectAsStateWithLifecycle() val context = LocalContext.current @@ -116,17 +116,18 @@ fun ScheduledPostsScreen( ) }, ) { padding -> - if (posts.isEmpty()) { + if (groups.isEmpty()) { EmptyState(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 = "header-${group.day}") { - DayHeader(group.day, context) + stickyHeader(key = group.day) { + DayHeader(group.day, today, context) } items(group.posts, key = { it.id }) { post -> SwipeToDeleteWithConfirmation( @@ -227,6 +228,7 @@ private fun ScheduledPostRow( @Composable private fun DayHeader( day: LocalDate, + today: LocalDate, context: android.content.Context, ) { Surface( @@ -234,7 +236,7 @@ private fun DayHeader( modifier = Modifier.fillMaxWidth(), ) { Text( - text = formatDayHeader(day, context), + text = formatDayHeader(day, today, context), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -328,13 +330,20 @@ private fun extractPreview(post: ScheduledPost): String = .trim() }.getOrDefault("") +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 timeFormat = DateFormat.getTimeInstance(DateFormat.SHORT) - val absolute = timeFormat.format(Date(publishAtSec * 1000)) + 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 { @@ -344,25 +353,11 @@ private fun formatAtTime( private fun formatDayHeader( day: LocalDate, + today: LocalDate, context: android.content.Context, -): String { - val today = LocalDate.now(ZoneId.systemDefault()) - return when (day) { - today -> { - stringRes(context, R.string.scheduled_posts_day_today) - } - - today.plusDays(1) -> { - stringRes(context, R.string.scheduled_posts_day_tomorrow) - } - - else -> { - val fullFormat = DateFormat.getDateInstance(DateFormat.FULL) - fullFormat.format( - Date.from( - day.atStartOfDay(ZoneId.systemDefault()).toInstant(), - ), - ) - } +): String = + when (day) { + today -> stringRes(context, R.string.scheduled_posts_day_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 index e0aa58998b..8855a5ca1b 100644 --- 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 @@ -59,27 +59,14 @@ class ScheduledPostsViewModel( ScheduledPostStatus.FAILED, ) - val posts: StateFlow> = + /** 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 } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), - ) - - /** - * Posts grouped by local-day, sorted ascending. The UI uses this as the - * source for sticky-header sections. - */ - val groupedPosts: StateFlow> = - posts - .map { sorted -> - val zone = ZoneId.systemDefault() - sorted .groupBy { Instant.ofEpochSecond(it.publishAtSec).atZone(zone).toLocalDate() } .map { (day, list) -> ScheduledPostDayGroup(day, list) } .sortedBy { it.day } 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 index b0947747b4..ea6e0a09a3 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt @@ -44,9 +44,7 @@ class ScheduledPostStoreTest { file = File(temp.root, "scheduled_posts.json") } - private fun newStore() = ScheduledPostStore(file) - - private fun newStore(now: () -> Long) = ScheduledPostStore(file, now) + private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file, now) private fun samplePost( id: String = "id-1", From 28defaf96e162bf069eec3cbec046e581924aee9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 7 May 2026 15:58:50 +0200 Subject: [PATCH 05/11] Translate 44 new strings into cs-rCZ, de-rDE, pt-rBR, sv-rSE --- .../src/main/res/values-cs-rCZ/strings.xml | 44 +++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 44 +++++++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 44 +++++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 44 +++++++++++++++++++ 4 files changed, 176 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 56116c8782..d2071a5228 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -2440,4 +2440,48 @@ 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 + Dnes + Zítra + Odhlášeno + Odhlášeno · smazáno %1$d naplánovaných příspěvků + Odeslat hned? + Tento příspěvek bude okamžitě odeslán na relays. Původní plán bude zahozen. + Odeslat + Smazat naplánovaný příspěvek? + Příspěvek nebude publikován. Tuto akci nelze vrátit zpět. + Smazat + Smazat + 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. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index a908fea70f..820972ad22 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2431,4 +2431,48 @@ 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 + Heute + Morgen + Abgemeldet + Abgemeldet · %1$d geplante(n) Beitrag/Beiträge gelöscht + Jetzt senden? + Dieser Beitrag wird sofort an Relays veröffentlicht. Der ursprüngliche Plan wird verworfen. + Senden + Geplanten Beitrag löschen? + Der Beitrag wird nicht veröffentlicht. Dies kann nicht rückgängig gemacht werden. + Löschen + Löschen + 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. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 362c5fdaa1..9c59c2ce6f 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -2426,4 +2426,48 @@ 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 + Hoje + Amanhã + Desconectado + Desconectado · %1$d post(s) agendado(s) excluído(s) + Enviar agora? + Este post será publicado em relays imediatamente. O agendamento original será descartado. + Enviar + Excluir post agendado? + O post não será publicado. Isso não pode ser desfeito. + Excluir + Excluir + 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. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 6b5bd69058..da590d0f15 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2425,4 +2425,48 @@ 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 + Idag + Imorgon + Utloggad + Utloggad · %1$d schemalagda inlägg raderade + Skicka nu? + Detta inlägg publiceras till relayer omedelbart. Det ursprungliga schemat ignoreras. + Skicka + Radera schemalagt inlägg? + Inlägget publiceras inte. Detta kan inte ångras. + Radera + Radera + 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. From f6991bee15a86305e53af121e71b8d807132b8f4 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 7 May 2026 16:20:10 +0200 Subject: [PATCH 06/11] Wait for relay OK ack before marking SENT - Quartz: expose pendingPublishRelaysFor(eventId) on INostrClient - Worker: after publish(), poll pendingPublishRelaysFor every 500ms up to OK_TIMEOUT_SEC=30s - notify user when a scheduled post fires or fails --- .../scheduledposts/ScheduledPostNotifier.kt | 138 ++++++++++++++++++ .../scheduledposts/ScheduledPostWorker.kt | 43 +++++- .../src/main/res/values-cs-rCZ/strings.xml | 4 + .../src/main/res/values-de-rDE/strings.xml | 4 + .../src/main/res/values-pt-rBR/strings.xml | 4 + .../src/main/res/values-sv-rSE/strings.xml | 4 + amethyst/src/main/res/values/strings.xml | 5 + .../nip01Core/relay/client/INostrClient.kt | 9 ++ .../nip01Core/relay/client/NostrClient.kt | 2 + .../relay/client/pool/PoolEventOutbox.kt | 8 + 10 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt 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..7748ff1c6c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt @@ -0,0 +1,138 @@ +/* + * 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.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Event + +/** + * 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 { + 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 = previewOf(post), + ) + } + + fun notifyFailed( + context: Context, + post: ScheduledPost, + error: String?, + ) { + ensureChannel(context) + val snippet = previewOf(post) + 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!!) + } + + private fun previewOf(post: ScheduledPost): String = + runCatching { + Event + .fromJson(post.signedEventJson) + .content + .take(120) + .trim() + }.getOrDefault("") + + // 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/ScheduledPostWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt index d96a3838b0..d32293035b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt @@ -32,9 +32,11 @@ 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 /** @@ -60,6 +62,8 @@ class ScheduledPostWorker( 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 = @@ -151,13 +155,23 @@ class ScheduledPostWorker( account.client.publish(event, relays) account.consumePostEvent(event, relays, extras) - store.markSent(post.id) - Log.d(TAG) { "client.publish(${post.id}) done; marked SENT" } + 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) } } @@ -170,4 +184,29 @@ class ScheduledPostWorker( 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/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index d2071a5228..6a2356bebd 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -2484,4 +2484,8 @@ 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. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 820972ad22..1aa4e51996 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2475,4 +2475,8 @@ anz der Bedingungen ist erforderlich 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. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 9c59c2ce6f..30788e286f 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -2470,4 +2470,8 @@ 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. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index da590d0f15..1465ce3355 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2469,4 +2469,8 @@ 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. diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e0f925a226..c9a050e43a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -455,6 +455,11 @@ 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? This post will publish to relays immediately. The original schedule will be discarded. Send 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..4ff97faed7 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 @@ -90,6 +90,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, From c9f6762751182601f0d136ff3e31177c1b4ee5c9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 7 May 2026 18:43:27 +0200 Subject: [PATCH 07/11] Screen re-design --- .../scheduledposts/ScheduledPostNotifier.kt | 15 +- .../amethyst/ui/components/SwipeToDelete.kt | 7 +- .../scheduledposts/ScheduledPostMedia.kt | 139 +++++ .../scheduledposts/ScheduledPostsScreen.kt | 516 +++++++++++++----- .../scheduledposts/ScheduledPostsViewModel.kt | 9 + .../src/main/res/values-cs-rCZ/strings.xml | 19 + .../src/main/res/values-de-rDE/strings.xml | 13 + .../src/main/res/values-pt-rBR/strings.xml | 13 + .../src/main/res/values-sv-rSE/strings.xml | 13 + amethyst/src/main/res/values/strings.xml | 13 + .../scheduledposts/ScheduledPostMediaTest.kt | 117 ++++ 11 files changed, 737 insertions(+), 137 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMedia.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMediaTest.kt 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 index 7748ff1c6c..7bbb64c277 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt @@ -29,8 +29,8 @@ 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 -import com.vitorpamplona.quartz.nip01Core.core.Event /** * Posts user-visible system notifications when a scheduled post completes @@ -51,7 +51,7 @@ object ScheduledPostNotifier { context = context, notId = idFor(post.id), title = stringRes(context, R.string.scheduled_posts_notification_sent_title), - body = previewOf(post), + body = extractContentPreview(post, 120), ) } @@ -61,7 +61,7 @@ object ScheduledPostNotifier { error: String?, ) { ensureChannel(context) - val snippet = previewOf(post) + val snippet = extractContentPreview(post, 120) val body = if (error.isNullOrBlank()) { snippet @@ -124,15 +124,6 @@ object ScheduledPostNotifier { nm.createNotificationChannel(channel!!) } - private fun previewOf(post: ScheduledPost): String = - runCatching { - Event - .fromJson(post.signedEventJson) - .content - .take(120) - .trim() - }.getOrDefault("") - // 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/ui/components/SwipeToDelete.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt index 8e8dc20d35..d740a6d543 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 @@ -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/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 index 6022cc3d37..a034d52d8f 100644 --- 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 @@ -20,32 +20,48 @@ */ 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.material3.AlertDialog -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults +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.material3.TextButton 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 @@ -53,10 +69,24 @@ 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.drawBehind +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.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 @@ -67,19 +97,21 @@ 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 com.vitorpamplona.quartz.nip01Core.core.Event 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 +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -93,9 +125,9 @@ fun ScheduledPostsScreen( ScheduledPostsViewModel.create(accountPubkey) } val groups by viewModel.groupedPosts.collectAsStateWithLifecycle() + val totalActive by viewModel.totalActive.collectAsStateWithLifecycle() val context = LocalContext.current - - var pendingPublishId by remember { mutableStateOf(null) } + 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. @@ -106,10 +138,48 @@ fun ScheduledPostsScreen( } } + 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( - title = { Text(stringRes(R.string.scheduled_posts)) }, + 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() } }, @@ -117,7 +187,7 @@ fun ScheduledPostsScreen( }, ) { padding -> if (groups.isEmpty()) { - EmptyState(modifier = Modifier.padding(padding)) + EmptyState(onCompose = { nav.nav(Route.NewShortNote()) }, modifier = Modifier.padding(padding)) } else { val today = remember(nowSec) { LocalDate.now(ZoneId.systemDefault()) } LazyColumn( @@ -130,101 +200,274 @@ fun ScheduledPostsScreen( DayHeader(group.day, today, context) } items(group.posts, key = { it.id }) { post -> - SwipeToDeleteWithConfirmation( - modifier = Modifier.fillMaxWidth().animateContentSize(), - onDelete = { viewModel.cancel(post.id) }, - ) { - ScheduledPostRow( - post = post, - nowSec = nowSec, - onPublishNow = { pendingPublishId = post.id }, - ) + 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.scheduled_posts_action_delete, + ) { + ScheduledPostCardCollapsed( + post = post, + nowSec = nowSec, + onClick = { expandedId = post.id }, + ) + } + } } } } } } } +} - pendingPublishId?.let { id -> - ConfirmDialog( - title = stringRes(R.string.scheduled_posts_send_now_title), - message = stringRes(R.string.scheduled_posts_send_now_message), - confirmLabel = stringRes(R.string.scheduled_posts_send_now_confirm), - onConfirm = { - viewModel.publishNow(id) - ScheduledPostWorker.scheduleCatchUp(context) - pendingPublishId = null - }, - onDismiss = { pendingPublishId = null }, +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.drawBehind { + drawRect( + brush = brush, + topLeft = Offset(0f, 8.dp.toPx()), + size = Size(3.dp.toPx(), size.height - 16.dp.toPx()), ) } } @Composable -private fun ScheduledPostRow( +private fun ScheduledPostCardCollapsed( post: ScheduledPost, nowSec: Long, - onPublishNow: () -> Unit, + onClick: () -> Unit, ) { val context = LocalContext.current - val preview = remember(post) { extractPreview(post) } + 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( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.outlinedCardColors(), + onClick = onClick, + modifier = + Modifier + .fillMaxWidth() + .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(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { Row( + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - StatusChip(post.status) + StatusPill(post.status) Text( text = formatAtTime(post.publishAtSec, nowSec, context), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - } - - Text( - text = preview, - style = MaterialTheme.typography.bodyMedium, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - - if (post.status == ScheduledPostStatus.FAILED && post.lastError != null) { - Text( - text = stringRes(R.string.scheduled_posts_error_prefix, post.lastError), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - overflow = TextOverflow.Ellipsis, ) } Row( - horizontalArrangement = Arrangement.End, + horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth(), ) { - IconButton(onClick = onPublishNow) { - Icon( - symbol = MaterialSymbols.AutoMirrored.Send, - contentDescription = stringRes(R.string.scheduled_posts_action_send_now), - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.primary, - ) + 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.scheduled_posts_action_delete)) } } } } +@Composable +private fun SectionLabel(text: String) { + Text( + text = text.uppercase(Locale.getDefault()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) +} + @Composable private fun DayHeader( day: LocalDate, @@ -246,41 +489,101 @@ private fun DayHeader( } @Composable -private fun StatusChip(status: ScheduledPostStatus) { - val (labelRes, tint) = +private fun StatusPill(status: ScheduledPostStatus) { + val (labelRes, color, pulse) = when (status) { - ScheduledPostStatus.PENDING -> R.string.scheduled_posts_status_pending to MaterialTheme.colorScheme.primary - ScheduledPostStatus.PUBLISHING -> R.string.scheduled_posts_status_publishing to MaterialTheme.colorScheme.tertiary - ScheduledPostStatus.FAILED -> R.string.scheduled_posts_status_failed to MaterialTheme.colorScheme.error - ScheduledPostStatus.SENT -> R.string.scheduled_posts_status_sent to MaterialTheme.colorScheme.tertiary - ScheduledPostStatus.CANCELLED -> R.string.scheduled_posts_status_cancelled to MaterialTheme.colorScheme.onSurfaceVariant + 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) + } } - AssistChip( - onClick = {}, - label = { Text(stringRes(labelRes), fontWeight = FontWeight.Medium) }, - colors = - AssistChipDefaults.assistChipColors( - labelColor = tint, - ), - ) + 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(modifier: Modifier = Modifier) { +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(12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Icon( - symbol = MaterialSymbols.Schedule, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + 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, @@ -290,46 +593,13 @@ private fun EmptyState(modifier: Modifier = Modifier) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Button(onClick = onCompose) { + Text(stringRes(R.string.new_post)) + } } } } -@Composable -private fun ConfirmDialog( - title: String, - message: String, - confirmLabel: String, - destructive: Boolean = false, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { Text(message) }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text( - confirmLabel, - color = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } - }, - ) -} - -private fun extractPreview(post: ScheduledPost): String = - runCatching { - Event - .fromJson(post.signedEventJson) - .content - .take(200) - .trim() - }.getOrDefault("") - private val shortTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) private val fullDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL) 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 index 8855a5ca1b..cf24d233a0 100644 --- 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 @@ -76,6 +76,15 @@ class ScheduledPostsViewModel( 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) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 6a2356bebd..878eed2285 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -2488,4 +2488,23 @@ 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 1aa4e51996..333704cfd6 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2479,4 +2479,17 @@ anz der Bedingungen ist erforderlich 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 30788e286f..64c830bb13 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -2474,4 +2474,17 @@ 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 1465ce3355..5b6d4feb59 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2473,4 +2473,17 @@ 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 c9a050e43a..530343b670 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -477,6 +477,19 @@ 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/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}"))) + } +} From 474a957f2b62d902f1fd4cd4df53f1fa2d222341 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 7 May 2026 20:52:52 +0200 Subject: [PATCH 08/11] Code review: - @Volatile ScheduledPostNotifier.channel: two workers firing in the same window can race on ensureChannel from different IO threads. createNotificationChannel is idempotent, but the field write needs a visibility fence so both threads see the cached reference. - @Volatile PoolEventOutbox.eventOutbox map ref + PoolEventOutboxState .relaysRemaining set ref. The new pendingPublishRelaysFor polling path reads these from the WorkManager IO thread; mutations still happen on NostrClient's IO scope. Pre-existing visibility gap that this poll surface exposed; @Volatile is the minimal fix. - ScheduledPostsScreen.SectionLabel: read the locale from LocalConfiguration.current.locales[0] (the Compose-resolved locale) rather than Locale.getDefault() (the system locale, which can drift from app config and breaks Turkish I/i casing). --- .../amethyst/service/scheduledposts/ScheduledPostNotifier.kt | 4 ++++ .../screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt | 5 +++-- .../quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt | 5 +++++ .../nip01Core/relay/client/pool/PoolEventOutboxState.kt | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) 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 index 7bbb64c277..37f57c45e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt @@ -39,6 +39,10 @@ import com.vitorpamplona.amethyst.ui.stringRes * 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 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 index a034d52d8f..32c5c617f3 100644 --- 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 @@ -78,6 +78,7 @@ 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 @@ -111,7 +112,6 @@ import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -import java.util.Locale @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -460,8 +460,9 @@ private fun ScheduledPostCardExpandedPanel( @Composable private fun SectionLabel(text: String) { + val locale = LocalConfiguration.current.locales[0] Text( - text = text.uppercase(Locale.getDefault()), + text = text.uppercase(locale), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 4.dp), 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 4ff97faed7..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()) 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() From 1c4159f3b0633af095194ff4536a31b20568ec29 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 7 May 2026 21:10:27 +0200 Subject: [PATCH 09/11] Consolidate strings --- .../loggedIn/scheduledposts/ScheduledPostsScreen.kt | 6 +++--- amethyst/src/main/res/values-cs-rCZ/strings.xml | 8 -------- amethyst/src/main/res/values-de-rDE/strings.xml | 8 -------- amethyst/src/main/res/values-pt-rBR/strings.xml | 8 -------- amethyst/src/main/res/values-sv-rSE/strings.xml | 8 -------- amethyst/src/main/res/values/strings.xml | 8 -------- 6 files changed, 3 insertions(+), 43 deletions(-) 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 index 32c5c617f3..0d74a803ee 100644 --- 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 @@ -235,7 +235,7 @@ fun ScheduledPostsScreen( SwipeToDeleteWithConfirmation( modifier = Modifier.fillMaxWidth().animateContentSize(), onDelete = { viewModel.cancel(post.id) }, - confirmLabelRes = R.string.scheduled_posts_action_delete, + confirmLabelRes = R.string.quick_action_delete, ) { ScheduledPostCardCollapsed( post = post, @@ -452,7 +452,7 @@ private fun ScheduledPostCardExpandedPanel( contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringRes(R.string.scheduled_posts_action_delete)) + Text(stringRes(R.string.quick_action_delete)) } } } @@ -628,7 +628,7 @@ private fun formatDayHeader( context: android.content.Context, ): String = when (day) { - today -> stringRes(context, R.string.scheduled_posts_day_today) + 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/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 878eed2285..9caae76f19 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -2463,17 +2463,9 @@ Přesto pokračovat %1$s · za %2$s %1$s · před %2$s - Dnes Zítra Odhlášeno Odhlášeno · smazáno %1$d naplánovaných příspěvků - Odeslat hned? - Tento příspěvek bude okamžitě odeslán na relays. Původní plán bude zahozen. - Odeslat - Smazat naplánovaný příspěvek? - Příspěvek nebude publikován. Tuto akci nelze vrátit zpět. - Smazat - Smazat 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. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 333704cfd6..1f17c0c978 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2454,17 +2454,9 @@ anz der Bedingungen ist erforderlich Trotzdem fortfahren %1$s · in %2$s %1$s · vor %2$s - Heute Morgen Abgemeldet Abgemeldet · %1$d geplante(n) Beitrag/Beiträge gelöscht - Jetzt senden? - Dieser Beitrag wird sofort an Relays veröffentlicht. Der ursprüngliche Plan wird verworfen. - Senden - Geplanten Beitrag löschen? - Der Beitrag wird nicht veröffentlicht. Dies kann nicht rückgängig gemacht werden. - Löschen - Löschen Jetzt senden Keine geplanten Beiträge Verfasse eine Notiz und tippe auf das Uhr-Symbol, um sie für später zu planen. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 64c830bb13..52702d633f 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -2449,17 +2449,9 @@ Continuar mesmo assim %1$s · em %2$s %1$s · há %2$s - Hoje Amanhã Desconectado Desconectado · %1$d post(s) agendado(s) excluído(s) - Enviar agora? - Este post será publicado em relays imediatamente. O agendamento original será descartado. - Enviar - Excluir post agendado? - O post não será publicado. Isso não pode ser desfeito. - Excluir - Excluir Enviar agora Sem posts agendados Componha uma nota e toque no ícone do relógio para agendá-la. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 5b6d4feb59..e396c36075 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2448,17 +2448,9 @@ Fortsätt ändå %1$s · om %2$s %1$s · för %2$s sedan - Idag Imorgon Utloggad Utloggad · %1$d schemalagda inlägg raderade - Skicka nu? - Detta inlägg publiceras till relayer omedelbart. Det ursprungliga schemat ignoreras. - Skicka - Radera schemalagt inlägg? - Inlägget publiceras inte. Detta kan inte ångras. - Radera - Radera Skicka nu Inga schemalagda inlägg Skriv en anteckning och tryck på klockikonen för att schemalägga den. diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 530343b670..63e4dac1e8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -451,7 +451,6 @@ Continue anyway %1$s · in %2$s %1$s · %2$s ago - Today Tomorrow Logged out Logged out · %1$d scheduled post(s) deleted @@ -460,13 +459,6 @@ ScheduledPostsID Scheduled posts Notifications when a scheduled post is published or fails to publish. - Send now? - This post will publish to relays immediately. The original schedule will be discarded. - Send - Delete scheduled post? - The post will not be published. This cannot be undone. - Delete - Delete Send now No scheduled posts Compose a note and tap the clock icon to schedule it for later. From d9f1237ff311565e065d91f3bf53328d8772b50b Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 7 May 2026 21:23:55 +0200 Subject: [PATCH 10/11] Fix scheduled-post card edges around the rounded corners --- .../vitorpamplona/amethyst/ui/components/SwipeToDelete.kt | 2 +- .../screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 d740a6d543..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 @@ -166,7 +166,7 @@ fun ConfirmDeleteBackground( if (!settled) { Color(0xFFFF1744) } else { - MaterialTheme.colorScheme.surfaceVariant + Color.Transparent }, label = "ConfirmDeleteBackground", ) 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 index 0d74a803ee..ee383a3622 100644 --- 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 @@ -71,7 +71,7 @@ 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.drawBehind +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush @@ -267,7 +267,8 @@ private fun Modifier.urgentEdge(enabled: Boolean): Modifier { remember(gradientStart, gradientEnd) { Brush.verticalGradient(listOf(gradientStart, gradientEnd)) } - return this.drawBehind { + return this.drawWithContent { + drawContent() drawRect( brush = brush, topLeft = Offset(0f, 8.dp.toPx()), @@ -318,6 +319,7 @@ private fun ScheduledPostCardCollapsed( 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), From a2d3c478d2283e751a8b3c7059a25fc3bb315d36 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 7 May 2026 21:37:25 +0200 Subject: [PATCH 11/11] Add pendingPublishRelaysFor stub to desktopApp StubNostrClient Add pendingPublishRelaysFor stub to test NostrClient fakes --- .../amethyst/desktop/cache/CoordinatorPipelineTest.kt | 2 ++ .../nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt | 2 ++ .../nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt | 2 ++ 3 files changed, 6 insertions(+) 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/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) {}