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 285286826..325ad10ab 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 d0b92f582..cd818fcd8 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 049b2ef6e..a4ba39705 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 fdfc50fb1..6022cc3d3 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 e0aa58998..8855a5ca1 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 b0947747b..ea6e0a09a 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",