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).
This commit is contained in:
+2
@@ -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(
|
||||
|
||||
+44
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+43
-1
@@ -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))
|
||||
|
||||
+78
-1
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+56
@@ -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
|
||||
|
||||
+16
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+60
-11
@@ -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
|
||||
|
||||
+90
-37
@@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
+192
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user