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:
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("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace(""", "\"").replace("'", "'").replace(" ", " ")
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user