Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
17 changes: 15 additions & 2 deletions sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
}
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -329,6 +331,7 @@ private fun OfflineSyncChip(
private fun AnnouncementCard(
announcement: AnnouncementPublic,
onDismiss: () -> Unit,
onDontShowAgain: () -> Unit,
modifier: Modifier = Modifier,
) {
val warningColors = LocalWarningColors.current
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,7 +50,11 @@ data class HomeUiState(
// so the carousel still renders rather than vanishing silently.
val topFronters: List<MemberRead> = emptyList(),
val announcements: List<AnnouncementPublic> = emptyList(),
// Session-only dismissals (the X): cleared on process death.
val dismissedAnnouncementIds: Set<String> = emptySet(),
// "Don't show again": persisted in the "android" client settings blob
// (dismissed_announcements), so it survives restarts and syncs.
val permanentlyDismissedAnnouncementIds: Set<String> = emptySet(),
val isLoading: Boolean = false,
val isSwitching: Boolean = false,
val error: String? = null,
Expand Down Expand Up @@ -79,7 +84,9 @@ data class HomeUiState(
val refreshFailed: Boolean = false,
) {
val visibleAnnouncements: List<AnnouncementPublic>
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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"
}
}
Loading