- 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).
This commit is contained in:
davotoula
2026-05-07 13:34:12 +02:00
parent 214a35d620
commit 0bbbdc2f65
11 changed files with 622 additions and 57 deletions
@@ -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(
@@ -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
}
}
@@ -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))
@@ -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())
}
}
}
@@ -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
@@ -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)
}
}
}
@@ -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
@@ -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<String?>(null) }
var pendingCancelId by remember { mutableStateOf<String?>(null) }
// Tick once per minute so relative-time strings ("publishes in 2h 13m")
// refresh on a long-open list instead of being frozen at first composition.
val nowSec by produceState(initialValue = System.currentTimeMillis() / 1000) {
while (true) {
delay(60_000)
value = System.currentTimeMillis() / 1000
}
}
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(),
),
)
}
}
}
@@ -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<ScheduledPost>,
)
/**
* 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<List<ScheduledPostDayGroup>> =
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)
+14
View File
@@ -442,6 +442,19 @@
<string name="schedule_post_warning_title">Always-on notifications disabled</string>
<string name="schedule_post_warning_single">Scheduled posts may not publish until you next reopen the app. Enable always-on in Settings → UI Preferences for reliable background scheduling.</string>
<string name="schedule_post_warning_multi">Scheduled posts may not publish until you reopen the app. Other accounts\' scheduled posts won\'t fire while this account is active. Enable always-on in Settings → UI Preferences for reliable background scheduling.</string>
<string name="schedule_post_preset_in_one_hour">In 1 hour</string>
<string name="schedule_post_preset_tomorrow_morning">Tomorrow 9 AM</string>
<string name="schedule_post_preset_next_monday_morning">Next Monday 9 AM</string>
<string name="schedule_post_always_on_prompt_title">Enable always-on notifications?</string>
<string name="schedule_post_always_on_prompt_message">Scheduled posts publish reliably only when always-on notifications are enabled. Otherwise, they may not fire until you next reopen the app.</string>
<string name="schedule_post_always_on_prompt_open_settings">Open settings</string>
<string name="schedule_post_always_on_prompt_continue">Continue anyway</string>
<string name="scheduled_posts_at_time">%1$s · in %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · %2$s ago</string>
<string name="scheduled_posts_day_today">Today</string>
<string name="scheduled_posts_day_tomorrow">Tomorrow</string>
<string name="scheduled_posts_logout_toast_zero">Logged out</string>
<string name="scheduled_posts_logout_toast">Logged out · %1$d scheduled post(s) deleted</string>
<string name="scheduled_posts_send_now_title">Send now?</string>
<string name="scheduled_posts_send_now_message">This post will publish to relays immediately. The original schedule will be discarded.</string>
<string name="scheduled_posts_send_now_confirm">Send</string>
@@ -458,6 +471,7 @@
<string name="scheduled_posts_status_failed">Failed</string>
<string name="scheduled_posts_status_sent">Sent</string>
<string name="scheduled_posts_status_cancelled">Cancelled</string>
<string name="scheduled_posts_logout_warning">You have %1$d scheduled post(s) that haven\'t been published yet. Logging out will permanently delete them.</string>
<string name="polls">Polls</string>
<string name="open_polls">Open</string>
<string name="closed_polls">Closed</string>
@@ -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 {