diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b4571..c1a261a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to the Sheaf Android client are recorded here. Format loosel follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project uses semantic versioning (`MAJOR.MINOR.PATCH`). +## [Unreleased] + +### Added + +- **Announcements: "Don't show again" and clickable links.** Home-screen + announcements now offer a "Don't show again" action that keeps one + hidden across restarts (the X still dismisses just for the session), + and their text renders inline markdown links (plus basic bold / italic + / code / strikethrough) so an admin can link out to a full write-up. + ## [1.2.0] - 2026-06-24 ### Added diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/InlineMarkdownText.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/InlineMarkdownText.kt new file mode 100644 index 0000000..d9e4ddb --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/InlineMarkdownText.kt @@ -0,0 +1,82 @@ +package systems.lupine.sheaf.ui.components + +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle + +/** + * Renders a small subset of *inline* markdown: links (clickable), **bold**, + * *italic*, ~~strikethrough~~, and `code`. Block markdown (headings, lists, + * images, code blocks) is intentionally NOT handled - the text is treated as a + * single inline run, so this stays safe on compact surfaces like announcement + * banners where full markdown would break the layout. For rich bodies use + * [SheafMarkdownText] instead. + * + * Only http(s) and mailto link targets are honoured; anything else (including + * javascript:/data:) renders as plain text, so a link can't smuggle in an + * unsafe scheme. Links open via the platform URI handler. + */ +@Composable +fun InlineMarkdownText( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + linkColor: Color = color, + style: TextStyle = LocalTextStyle.current, +) { + val annotated = remember(text, linkColor) { parseInlineMarkdown(text, linkColor) } + Text(text = annotated, modifier = modifier, color = color, style = style) +} + +// Links first so a URL's contents aren't re-parsed as emphasis; `**` before a +// lone `*` so bold wins over italic. Delimited runs can't span the delimiter +// character, which keeps the matcher simple and predictable for short bodies. +private val INLINE_MARKDOWN = Regex( + """\[([^\]]+)\]\((https?://[^)\s]+|mailto:[^)\s]+)\)""" + // 1=label 2=url + """|\*\*([^*]+)\*\*""" + // 3=bold + """|~~([^~]+)~~""" + // 4=strikethrough + """|`([^`]+)`""" + // 5=code + """|\*([^*]+)\*""" + // 6=italic (*) + """|_([^_]+)_""", // 7=italic (_) +) + +private fun parseInlineMarkdown(text: String, linkColor: Color): AnnotatedString = + buildAnnotatedString { + var last = 0 + for (m in INLINE_MARKDOWN.findAll(text)) { + if (m.range.first > last) append(text.substring(last, m.range.first)) + val g = m.groups + when { + g[1] != null && g[2] != null -> withLink( + LinkAnnotation.Url( + g[2]!!.value, + TextLinkStyles( + SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), + ), + ), + ) { append(g[1]!!.value) } + g[3] != null -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(g[3]!!.value) } + g[4] != null -> withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { append(g[4]!!.value) } + g[5] != null -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(g[5]!!.value) } + g[6] != null -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(g[6]!!.value) } + g[7] != null -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(g[7]!!.value) } + } + last = m.range.last + 1 + } + if (last < text.length) append(text.substring(last)) + } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt index a83ba23..406d249 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt @@ -197,6 +197,7 @@ fun HomeScreen( AnnouncementCard( announcement = announcement, onDismiss = { viewModel.dismissAnnouncement(announcement.id) }, + onDontShowAgain = { viewModel.dontShowAgainAnnouncement(announcement.id) }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), ) } @@ -224,6 +225,7 @@ fun HomeScreen( AnnouncementCard( announcement = announcement, onDismiss = { viewModel.dismissAnnouncement(announcement.id) }, + onDontShowAgain = { viewModel.dontShowAgainAnnouncement(announcement.id) }, ) } items(state.frontingMembers, key = { it.id }) { member -> @@ -329,6 +331,7 @@ private fun OfflineSyncChip( private fun AnnouncementCard( announcement: AnnouncementPublic, onDismiss: () -> Unit, + onDontShowAgain: () -> Unit, modifier: Modifier = Modifier, ) { val warningColors = LocalWarningColors.current @@ -356,11 +359,21 @@ private fun AnnouncementCard( style = MaterialTheme.typography.titleSmall, color = contentColor, ) - Text( - announcement.body, + InlineMarkdownText( + text = announcement.body, style = MaterialTheme.typography.bodySmall, color = contentColor, + linkColor = contentColor, ) + if (announcement.dismissible) { + TextButton( + onClick = onDontShowAgain, + contentPadding = PaddingValues(vertical = 4.dp), + colors = ButtonDefaults.textButtonColors(contentColor = contentColor), + ) { + Text("Don't show again", style = MaterialTheme.typography.labelMedium) + } + } } if (announcement.dismissible) { IconButton(onClick = onDismiss) { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeViewModel.kt index f058175..6377f5c 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeViewModel.kt @@ -9,6 +9,7 @@ import systems.lupine.sheaf.data.db.PendingFrontRemoval import systems.lupine.sheaf.data.db.PendingFrontSwitch import systems.lupine.sheaf.data.db.PendingOperationsDao import systems.lupine.sheaf.data.model.AnnouncementPublic +import systems.lupine.sheaf.data.model.ClientSettingsBody import systems.lupine.sheaf.data.model.FrontCreate import systems.lupine.sheaf.data.model.FrontRead import systems.lupine.sheaf.data.model.FrontUpdate @@ -49,7 +50,11 @@ data class HomeUiState( // so the carousel still renders rather than vanishing silently. val topFronters: List = emptyList(), val announcements: List = emptyList(), + // Session-only dismissals (the X): cleared on process death. val dismissedAnnouncementIds: Set = emptySet(), + // "Don't show again": persisted in the "android" client settings blob + // (dismissed_announcements), so it survives restarts and syncs. + val permanentlyDismissedAnnouncementIds: Set = emptySet(), val isLoading: Boolean = false, val isSwitching: Boolean = false, val error: String? = null, @@ -79,7 +84,9 @@ data class HomeUiState( val refreshFailed: Boolean = false, ) { val visibleAnnouncements: List - get() = announcements.filter { it.id !in dismissedAnnouncementIds } + get() = announcements.filter { + it.id !in dismissedAnnouncementIds && it.id !in permanentlyDismissedAnnouncementIds + } /** * What the home carousel actually renders. Prefers the server-ranked @@ -177,6 +184,8 @@ class HomeViewModel @Inject constructor( // Fetched alongside the rest so the quick-switch carousel // hydrates without an extra round-trip after first paint. val topFrontersD = async { runCatching { api.getTopFronters() } } + // Carries dismissed_announcements ("don't show again" set). + val clientSettingsD = async { runCatching { api.getClientSettings(ANDROID_CLIENT_ID) } } val fronts = frontsD.await() val members = membersD.await() @@ -186,6 +195,15 @@ class HomeViewModel @Inject constructor( val retention = retentionD.await() val user = userD.await() val topFronters = topFrontersD.await() + val clientSettings = clientSettingsD.await() + // Server-side "don't show again" set. Merge (union) with the + // local set so an optimistic dismissal isn't lost if this + // refresh raced the PATCH that persisted it. + val serverDismissed = clientSettings.getOrNull()?.settings + ?.get("dismissed_announcements") + ?.let { (it as? List<*>)?.mapNotNull { v -> v as? String } } + ?.toSet() + ?: emptySet() val criticalFailures = listOf(fronts, members, system).count { it.isFailure } val anyCriticalFailed = criticalFailures > 0 @@ -263,6 +281,7 @@ class HomeViewModel @Inject constructor( allMembers = newMembers, topFronters = newTopFronters, announcements = newAnnouncements, + permanentlyDismissedAnnouncementIds = it.permanentlyDismissedAnnouncementIds + serverDismissed, pendingSafetyActions = safetyResp?.pendingActions ?: it.pendingSafetyActions, pendingSafetyChanges = safetyResp?.pendingChanges ?: it.pendingSafetyChanges, pendingTrimNotice = trimNotice, @@ -310,6 +329,30 @@ class HomeViewModel @Inject constructor( _state.update { it.copy(dismissedAnnouncementIds = it.dismissedAnnouncementIds + id) } } + /** + * "Don't show again": persist [id] into the android client-settings + * dismissed_announcements list so it stays hidden across restarts (and + * syncs), mirroring web. Optimistic + also session-dismisses; on failure + * the session dismissal still hides it for now. + */ + fun dontShowAgainAnnouncement(id: String) { + _state.update { + it.copy( + dismissedAnnouncementIds = it.dismissedAnnouncementIds + id, + permanentlyDismissedAnnouncementIds = it.permanentlyDismissedAnnouncementIds + id, + ) + } + viewModelScope.launch { + val ids = _state.value.permanentlyDismissedAnnouncementIds.toList() + runCatching { + api.patchClientSettings( + ANDROID_CLIENT_ID, + ClientSettingsBody(mapOf("dismissed_announcements" to ids)), + ) + } + } + } + fun openSwitchSheet() { val s = _state.value val currentIds = s.currentFronts.flatMap { it.memberIds }.toSet() @@ -514,5 +557,8 @@ class HomeViewModel @Inject constructor( // responses can finish in tens of ms, leaving the user staring at // the screen wondering if the pull-to-refresh registered at all. const val MIN_REFRESH_VISIBLE_MS = 600L + // Client-settings blob this device reads/writes (theme, dismissed + // announcements, ...). Matches SettingsViewModel's CLIENT_ID. + const val ANDROID_CLIENT_ID = "android" } }