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