Live updates over WebSocket + clickable links

- Connect to the server's /ws multiplexed socket (chat/notifications/board) for
  the whole authenticated session. Auth rides the existing cookie jar; no server
  changes needed (the server allows clients that send no Origin header).
- Chat now updates live: ConversationViewModel merges messages pushed over the
  socket for the open conversation (no more leaving/re-entering to see replies).
- Notifications are now real-time in-app (bell updates instantly); when the app is
  backgrounded, incoming alerts and messages raise a system notification via the
  same socket. The 15-min WorkManager poll remains as a catch-up safety net.
- Links are now clickable in chat messages, task comments, and descriptions via a
  new RichText component (handles <a> anchors and bare http/www URLs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vojtěch Maršál
2026-06-13 14:50:24 +02:00
parent d0b1529c3f
commit 9e2dd40af0
9 changed files with 322 additions and 4 deletions
Binary file not shown.
@@ -57,4 +57,14 @@ class MainActivity : ComponentActivity() {
val container = (application as BountyBoardApp).container
intent.getStringExtra(PushNotifier.EXTRA_ROUTE)?.let { container.setDeepLink(it) }
}
override fun onStart() {
super.onStart()
(application as BountyBoardApp).container.appForeground = true
}
override fun onStop() {
super.onStop()
(application as BountyBoardApp).container.appForeground = false
}
}
@@ -63,6 +63,13 @@ class AppContainer(
.crossfade(true)
.build()
/** The live-update WebSocket (chat + notifications + board). */
val live = com.anypreta.bountyboard.data.ws.LiveSocket(okHttp, gson) { _baseUrl.value }
/** Whether an Activity is currently in the foreground (drives background notifications). */
@Volatile
var appForeground: Boolean = false
/** A pending in-app navigation target (set when the user taps a system notification). */
val pendingDeepLink = MutableStateFlow<String?>(null)
@@ -0,0 +1,136 @@
package com.anypreta.bountyboard.data.ws
import com.anypreta.bountyboard.data.model.Message
import com.anypreta.bountyboard.data.model.Notification
import com.google.gson.Gson
import com.google.gson.JsonObject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
* The single multiplexed live-update WebSocket (server §2.2): one connection per
* session carrying `chat`, `board` and `notifications` channels. Auth rides on the
* shared cookie jar; the server allows clients that send no Origin header (we don't),
* so no server changes are required. Auto-reconnects with backoff while [connect]ed.
*/
class LiveSocket(
baseClient: OkHttpClient,
private val gson: Gson,
private val baseUrlProvider: () -> String,
) {
private val client = baseClient.newBuilder()
.pingInterval(20, TimeUnit.SECONDS) // keep the socket alive through proxies
.readTimeout(0, TimeUnit.SECONDS)
.build()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val running = AtomicBoolean(false)
@Volatile private var socket: WebSocket? = null
@Volatile private var reconnectJob: kotlinx.coroutines.Job? = null
private val _chat = MutableSharedFlow<Message>(extraBufferCapacity = 64)
val chatMessages: SharedFlow<Message> = _chat.asSharedFlow()
private val _notifications = MutableSharedFlow<Notification>(extraBufferCapacity = 64)
val notifications: SharedFlow<Notification> = _notifications.asSharedFlow()
private val _board = MutableSharedFlow<BoardEvent>(extraBufferCapacity = 64)
val boardEvents: SharedFlow<BoardEvent> = _board.asSharedFlow()
private val _connected = MutableSharedFlow<Boolean>(replay = 1, extraBufferCapacity = 1)
val connected: SharedFlow<Boolean> = _connected.asSharedFlow()
fun connect() {
if (!running.compareAndSet(false, true)) return
open()
}
fun disconnect() {
running.set(false)
reconnectJob?.cancel()
socket?.close(1000, "bye")
socket = null
}
private fun open() {
if (!running.get()) return
val url = baseUrlProvider().trimEnd('/') + "/ws"
val request = Request.Builder().url(url).build()
socket = client.newWebSocket(request, listener)
}
private fun scheduleReconnect() {
if (!running.get()) return
if (reconnectJob?.isActive == true) return
reconnectJob = scope.launch {
var attempt = 0
while (isActive && running.get() && socket == null) {
val backoff = minOf(30_000L, 1_000L * (1L shl minOf(attempt, 4)))
delay(backoff)
attempt++
open()
// give the open a moment; if it failed, onFailure nulls socket again
delay(2_000)
}
}
}
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
_connected.tryEmit(true)
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val env = gson.fromJson(text, JsonObject::class.java) ?: return
val channel = env.get("channel")?.asString ?: return
val event = env.get("event")?.asString ?: return
val data = env.get("data")
when (channel) {
"chat" -> if (event == "message" && data != null) {
runCatching { gson.fromJson(data, Message::class.java) }
.getOrNull()?.let { _chat.tryEmit(it) }
}
"notifications" -> if (event == "notification" && data != null) {
runCatching { gson.fromJson(data, Notification::class.java) }
.getOrNull()?.let { _notifications.tryEmit(it) }
}
"board" -> {
val taskId = data?.asJsonObject?.get("taskId")?.asString
_board.tryEmit(BoardEvent(event, taskId))
}
}
} catch (_: Exception) {
// ignore malformed frames
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
socket = null
_connected.tryEmit(false)
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
socket = null
_connected.tryEmit(false)
scheduleReconnect()
}
}
}
data class BoardEvent(val event: String, val taskId: String?)
@@ -0,0 +1,105 @@
package com.anypreta.bountyboard.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.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.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import com.anypreta.bountyboard.ui.theme.BB
/**
* Renders server-sanitized rich text (or plain text) with **clickable links** —
* both `<a href>` anchors and bare http(s)/www URLs become tappable, opening in the
* browser via the platform UriHandler. Used for chat messages, comments, and
* task descriptions.
*/
@Composable
fun RichText(
text: String,
modifier: Modifier = Modifier,
color: Color = BB.colors.text,
linkColor: Color = BB.colors.accent,
style: TextStyle = LocalTextStyle.current,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
) {
val tokens = remember(text) { tokenize(text) }
val annotated = buildAnnotatedString {
val linkStyles = TextLinkStyles(
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
)
tokens.forEach { t ->
when (t) {
is Token.Plain -> append(t.text)
is Token.Link -> withLink(LinkAnnotation.Url(t.url, linkStyles)) { append(t.label) }
}
}
}
Text(
annotated,
modifier = modifier,
color = color,
style = style,
maxLines = maxLines,
overflow = overflow,
)
}
private sealed interface Token {
data class Plain(val text: String) : Token
data class Link(val label: String, val url: String) : Token
}
private val anchorRegex =
Regex("""<a\b[^>]*?href\s*=\s*["']([^"']+)["'][^>]*>(.*?)</a>""", RegexOption.DOT_MATCHES_ALL)
private val urlRegex = Regex("""(https?://[^\s<]+)|(www\.[^\s<]+)""")
private fun tokenize(html: String): List<Token> {
val out = mutableListOf<Token>()
var last = 0
for (m in anchorRegex.findAll(html)) {
if (m.range.first > last) {
addPlainWithUrls(out, stripTags(html.substring(last, m.range.first)))
}
val url = normalizeUrl(m.groupValues[1])
val label = stripTags(m.groupValues[2]).ifBlank { m.groupValues[1] }
out += Token.Link(label, url)
last = m.range.last + 1
}
if (last < html.length) {
addPlainWithUrls(out, stripTags(html.substring(last)))
}
return out.ifEmpty { listOf(Token.Plain(stripTags(html))) }
}
/** Split a plain string into text + auto-detected bare-URL link tokens. */
private fun addPlainWithUrls(out: MutableList<Token>, text: String) {
if (text.isEmpty()) return
var last = 0
for (m in urlRegex.findAll(text)) {
if (m.range.first > last) out += Token.Plain(text.substring(last, m.range.first))
val raw = m.value.trimEnd('.', ',', ')', ']', '!', '?', ';', ':')
out += Token.Link(raw, normalizeUrl(raw))
last = m.range.first + raw.length
}
if (last < text.length) out += Token.Plain(text.substring(last))
}
private fun normalizeUrl(u: String): String =
if (u.startsWith("http://") || u.startsWith("https://")) u else "https://$u"
private fun stripTags(s: String): String =
s.replace(Regex("<br\\s*/?>"), "\n")
.replace(Regex("</p>|</li>|</blockquote>"), "\n")
.replace(Regex("<[^>]+>"), "")
.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
.replace("&quot;", "\"").replace("&#39;", "'").replace("&nbsp;", " ")
@@ -89,6 +89,21 @@ class ConversationViewModel(
init {
load()
// Live: merge messages pushed over the WebSocket for this conversation.
viewModelScope.launch {
container.live.chatMessages.collect { m ->
if (m.conversationId == conversationId) {
mergeMessage(m)
markRead()
}
}
}
}
private fun mergeMessage(m: Message) {
if (messages.any { it.id == m.id }) return
messages = (messages + m).sortedBy { it.id }
resolveNames(setOf(m.senderId))
}
fun load() {
@@ -327,10 +342,11 @@ private fun MessageBubble(
}
}
if (message.body.isNotBlank()) {
Text(
htmlToPlain(message.body),
com.anypreta.bountyboard.ui.components.RichText(
message.body,
style = MaterialTheme.typography.bodyMedium,
color = if (mine) p.onAccent else p.text,
linkColor = if (mine) p.onAccent else p.accent,
)
}
}
@@ -39,9 +39,11 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -57,6 +59,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.anypreta.bountyboard.data.push.PushNotifier
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.admin.AdminAuditScreen
@@ -118,6 +121,45 @@ fun MainShell() {
if (pushEnabled) container.schedulePush() else container.cancelPush()
}
// Live updates: keep the WebSocket connected for the whole authenticated session.
val context = LocalContext.current
DisposableEffect(Unit) {
container.live.connect()
onDispose { /* kept alive across navigation; closed on logout */ }
}
LaunchedEffect(Unit) {
// Live notifications → bump the bell, and (when backgrounded) raise a system alert.
launch {
container.live.notifications.collect { n ->
shellVm.refreshUnread()
if (!container.appForeground) {
PushNotifier.notifyAlert(
context,
id = ("a" + n.id).hashCode(),
title = n.title.ifBlank { "New alert" },
body = com.anypreta.bountyboard.ui.util.htmlToPlain(n.body).ifBlank { "You have a new alert." },
route = PushNotifier.routeForServerLink(n.link),
)
}
}
}
// Live chat → when backgrounded and it isn't our own message, raise a system alert.
launch {
container.live.chatMessages.collect { m ->
val mine = m.senderId == container.session.user.value?.id
if (!container.appForeground && !mine) {
PushNotifier.notifyMessage(
context,
id = ("m" + m.conversationId).hashCode(),
title = "New message",
body = com.anypreta.bountyboard.ui.util.htmlToPlain(m.body).ifBlank { "New message" },
route = "conversation/${m.conversationId}",
)
}
}
}
}
// Deep link from a tapped system notification.
val deepLink by container.pendingDeepLink.collectAsStateWithLifecycle()
LaunchedEffect(deepLink) {
@@ -48,6 +48,7 @@ class ShellViewModel(private val container: AppContainer) : ViewModel() {
container.repo.action { logout() }
} catch (_: Exception) {
}
container.live.disconnect()
container.cancelPush()
container.clearPushState()
container.cookieJar.clear()
@@ -46,6 +46,7 @@ import com.anypreta.bountyboard.ui.components.Hairline
import com.anypreta.bountyboard.ui.components.MessageBox
import com.anypreta.bountyboard.ui.components.MessageKind
import com.anypreta.bountyboard.ui.components.NoteDialog
import com.anypreta.bountyboard.ui.components.RichText
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.components.SectionLabel
import com.anypreta.bountyboard.ui.components.StatusBadge
@@ -177,7 +178,7 @@ fun TaskDetailScreen(taskId: String, onBack: () -> Unit, onOpenProfile: (String)
// Description
if (task.description.isNotBlank()) {
SectionCard("Description") {
Text(htmlToPlain(task.description), style = MaterialTheme.typography.bodyLarge, color = p.text)
RichText(task.description, style = MaterialTheme.typography.bodyLarge, color = p.text)
}
}
@@ -260,7 +261,7 @@ fun TaskDetailScreen(taskId: String, onBack: () -> Unit, onOpenProfile: (String)
Text(vm.nameFor(c.authorId), style = MaterialTheme.typography.titleSmall, color = p.text)
Text(relativeTime(c.at), style = MaterialTheme.typography.labelSmall, color = p.muted)
}
Text(htmlToPlain(c.body), style = MaterialTheme.typography.bodyMedium, color = p.text)
RichText(c.body, style = MaterialTheme.typography.bodyMedium, color = p.text)
}
}
}