Add Nextcloud Talk text chat (read/send, no calls)

New Talk section reachable from Agenda's top bar: a conversation
list and a per-room chat screen with send + live updates via the
Talk chat API's long-poll (lookIntoFuture=1). No call/WebRTC
signaling - text only, per the scoped-down request for this item.

Uses the same OCS app-password auth as CalDAV. One real gotcha:
Nextcloud Talk versions each feature area of its OCS API
independently - Room/Call are at api/v4 on this server, but the
Chat endpoints are still api/v1; hitting v4/chat/{token} returns a
misleading generic "invalid query" 404 even fully authenticated.
This commit is contained in:
RomanNum3ral 2026-07-18 13:29:15 -04:00
parent 33d6469bbe
commit 2bf9a32ead
Signed by: RomanNum3ral
GPG Key ID: B94562C9B7535805
10 changed files with 562 additions and 2 deletions

View File

@ -5,6 +5,7 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import androidx.glance.appwidget.updateAll
import com.homelab.ncal.data.repository.NextcloudRepository
import com.homelab.ncal.data.repository.TalkRepository
import com.homelab.ncal.notifications.CHANNEL_ID
import com.homelab.ncal.notifications.ReminderScheduler
import com.homelab.ncal.widget.MonthGridWidget
@ -18,6 +19,10 @@ class NcalApplication : Application() {
lateinit var repository: NextcloudRepository
private set
// Talk has no local cache to set up eagerly (see TalkRepository), so this is lazy rather
// than constructed alongside `repository` in onCreate().
val talkRepository: TalkRepository by lazy { TalkRepository(this) }
override fun onCreate() {
super.onCreate()
SQLiteDatabase.loadLibs(this)

View File

@ -0,0 +1,123 @@
package com.homelab.ncal.data.network
import okhttp3.Credentials
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
data class TalkRoom(
val token: String,
val displayName: String,
val type: Int,
val unreadMessages: Int,
val lastMessagePreview: String?,
val lastActivity: Long
)
data class TalkMessage(
val id: Int,
val actorDisplayName: String,
val message: String,
val timestamp: Long,
val isSystemMessage: Boolean
)
/**
* Speaks just enough of Nextcloud Talk's OCS API (the `spreed` app) to list conversations and
* read/send chat messages - text only, no call signaling. Uses the same Basic Auth app password
* as CalDAV, since Talk is just another first-party Nextcloud app behind the same OCS layer.
*/
class TalkClient(
private val serverUrl: String,
private val username: String,
private val appPassword: String
) {
private val http = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
// Chat history/history reads are quick, but a long-poll (lookIntoFuture=1) can hold the
// connection open server-side for ~30s waiting for a new message - allow for that.
.readTimeout(35, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.build()
private val authHeader = Credentials.basic(username, appPassword)
private val base = "${serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4"
// Nextcloud Talk versions each feature area of the OCS API independently - Room/Call are at
// v4 on this server, but Chat is still v1 (confirmed empirically: v4/chat/{token} 404s with
// a generic "invalid query" body even fully authenticated, v1/chat/{token} works).
private val chatBase = "${serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v1"
fun listRooms(): List<TalkRoom> {
val request = ocsRequest("$base/room?format=json").get().build()
val data = JSONObject(execute(request)).getJSONObject("ocs").getJSONArray("data")
return (0 until data.length()).map { i ->
val o = data.getJSONObject(i)
val lastMessage = o.optJSONObject("lastMessage")
TalkRoom(
token = o.getString("token"),
displayName = o.getString("displayName"),
type = o.getInt("type"),
unreadMessages = o.optInt("unreadMessages", 0),
lastMessagePreview = lastMessage?.optString("message")?.takeIf { it.isNotBlank() },
lastActivity = o.optLong("lastActivity", 0L) * 1000
)
}.sortedByDescending { it.lastActivity }
}
/**
* Fetches chat messages. With [lookIntoFuture] false, returns the most recent history (or
* everything after [lastKnownMessageId] if given). With it true, long-polls - blocks
* server-side for new messages after [lastKnownMessageId] until one arrives or ~30s elapses
* (a 304 on timeout, translated here to an empty list so callers can just loop).
*/
fun getMessages(token: String, lastKnownMessageId: Int?, lookIntoFuture: Boolean): List<TalkMessage> {
val url = buildString {
append("$chatBase/chat/$token?format=json&limit=100")
append("&lookIntoFuture=").append(if (lookIntoFuture) 1 else 0)
append("&lastKnownMessageId=").append(lastKnownMessageId ?: 0)
}
val request = ocsRequest(url).get().build()
http.newCall(request).execute().use { resp ->
if (resp.code == 304) return emptyList()
if (!resp.isSuccessful) {
val detail = resp.body?.string()?.take(300)
throw CalDavException("HTTP ${resp.code} for GET ${request.url}: ${detail ?: resp.message}", resp.code)
}
val body = resp.body?.string() ?: return emptyList()
val data = JSONObject(body).getJSONObject("ocs").getJSONArray("data")
return (0 until data.length()).map { i -> parseMessage(data.getJSONObject(i)) }
}
}
fun sendMessage(token: String, message: String): TalkMessage {
val body = FormBody.Builder().add("message", message).build()
val request = ocsRequest("$chatBase/chat/$token?format=json").post(body).build()
val data = JSONObject(execute(request)).getJSONObject("ocs").getJSONObject("data")
return parseMessage(data)
}
private fun parseMessage(o: JSONObject) = TalkMessage(
id = o.getInt("id"),
actorDisplayName = o.optString("actorDisplayName").ifBlank { o.optString("actorId") },
message = o.optString("message"),
timestamp = o.optLong("timestamp", 0L) * 1000,
isSystemMessage = o.optString("systemMessage").isNotBlank()
)
private fun ocsRequest(url: String) = Request.Builder()
.url(url)
.header("Authorization", authHeader)
.header("OCS-APIRequest", "true")
private fun execute(request: Request): String {
http.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
val detail = resp.body?.string()?.take(300)
throw CalDavException("HTTP ${resp.code} for ${request.method} ${request.url}: ${detail ?: resp.message}", resp.code)
}
return resp.body?.string() ?: ""
}
}
}

View File

@ -0,0 +1,38 @@
package com.homelab.ncal.data.repository
import android.content.Context
import com.homelab.ncal.data.network.TalkClient
import com.homelab.ncal.data.network.TalkMessage
import com.homelab.ncal.data.network.TalkRoom
import com.homelab.ncal.data.prefs.SecurePrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Talk has no local cache/offline story (unlike [NextcloudRepository]'s CalDAV data) - chat is
* inherently live, so this is a thin network pass-through using the same account credentials.
*/
class TalkRepository(context: Context) {
private val prefs = SecurePrefs(context)
private fun client(): TalkClient {
val server = prefs.serverUrl ?: error("Not logged in")
val user = prefs.username ?: error("Not logged in")
val pass = prefs.appPassword ?: error("Not logged in")
return TalkClient(server, user, pass)
}
suspend fun listRooms(): List<TalkRoom> = withContext(Dispatchers.IO) {
client().listRooms()
}
suspend fun getMessages(token: String, lastKnownMessageId: Int?, lookIntoFuture: Boolean): List<TalkMessage> =
withContext(Dispatchers.IO) {
client().getMessages(token, lastKnownMessageId, lookIntoFuture)
}
suspend fun sendMessage(token: String, message: String): TalkMessage = withContext(Dispatchers.IO) {
client().sendMessage(token, message)
}
}

View File

@ -0,0 +1,20 @@
package com.homelab.ncal.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.CreationExtras
import com.homelab.ncal.NcalApplication
import com.homelab.ncal.data.repository.TalkRepository
class TalkViewModelFactory(
private val app: NcalApplication,
private val create: (TalkRepository, SavedStateHandle) -> ViewModel
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val handle = extras.createSavedStateHandle()
@Suppress("UNCHECKED_CAST")
return create(app.talkRepository, handle) as T
}
}

View File

@ -12,6 +12,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.RadioButtonUnchecked
@ -56,7 +57,8 @@ fun AgendaScreen(
onOpenCollections: () -> Unit,
onOpenMonth: () -> Unit,
onOpenWeek: () -> Unit,
onOpenSearch: () -> Unit
onOpenSearch: () -> Unit,
onOpenTalk: () -> Unit
) {
val state by viewModel.state.collectAsState()
@ -69,6 +71,7 @@ fun AgendaScreen(
CenterAlignedTopAppBar(
title = { Text("Agenda") },
actions = {
IconButton(onClick = onOpenTalk) { Icon(Icons.Filled.Chat, "Talk") }
IconButton(onClick = onOpenSearch) { Icon(Icons.Filled.Search, "Search") }
IconButton(onClick = onOpenWeek) { Icon(Icons.Filled.ViewWeek, "Week view") }
IconButton(onClick = onOpenMonth) { Icon(Icons.Filled.CalendarMonth, "Month view") }

View File

@ -25,6 +25,7 @@ import androidx.navigation.navArgument
import com.homelab.ncal.NcalApplication
import com.homelab.ncal.data.model.CalendarItem
import com.homelab.ncal.ui.NcalViewModelFactory
import com.homelab.ncal.ui.TalkViewModelFactory
import com.homelab.ncal.ui.agenda.AgendaScreen
import com.homelab.ncal.ui.agenda.AgendaViewModel
import com.homelab.ncal.ui.collections.CollectionsScreen
@ -37,10 +38,15 @@ import com.homelab.ncal.ui.month.MonthScreen
import com.homelab.ncal.ui.month.MonthViewModel
import com.homelab.ncal.ui.search.SearchScreen
import com.homelab.ncal.ui.search.SearchViewModel
import com.homelab.ncal.ui.talk.TalkChatScreen
import com.homelab.ncal.ui.talk.TalkChatViewModel
import com.homelab.ncal.ui.talk.TalkRoomListScreen
import com.homelab.ncal.ui.talk.TalkRoomListViewModel
import com.homelab.ncal.ui.tasks.TasksScreen
import com.homelab.ncal.ui.tasks.TasksViewModel
import com.homelab.ncal.ui.week.WeekScreen
import com.homelab.ncal.ui.week.WeekViewModel
import java.net.URLDecoder
import java.net.URLEncoder
private object Routes {
@ -52,6 +58,8 @@ private object Routes {
const val COLLECTIONS = "collections"
const val SEARCH = "search"
const val EDIT = "edit/{type}/{href}/{calendarUrl}"
const val TALK_ROOMS = "talk"
const val TALK_CHAT = "talk/{token}/{name}"
}
private fun editRoute(type: String, href: String?, calendarUrl: String? = null): String {
@ -60,6 +68,12 @@ private fun editRoute(type: String, href: String?, calendarUrl: String? = null):
return "edit/$type/$encodedHref/$encodedCalUrl"
}
private fun talkChatRoute(token: String, name: String): String {
val encodedToken = URLEncoder.encode(token, "UTF-8")
val encodedName = URLEncoder.encode(name, "UTF-8")
return "talk/$encodedToken/$encodedName"
}
@Composable
fun NcalNavGraph(
app: NcalApplication,
@ -121,7 +135,8 @@ fun NcalNavGraph(
onOpenCollections = { navController.navigate(Routes.COLLECTIONS) },
onOpenMonth = { navController.navigate(Routes.MONTH) },
onOpenWeek = { navController.navigate(Routes.WEEK) },
onOpenSearch = { navController.navigate(Routes.SEARCH) }
onOpenSearch = { navController.navigate(Routes.SEARCH) },
onOpenTalk = { navController.navigate(Routes.TALK_ROOMS) }
)
}
@ -169,6 +184,27 @@ fun NcalNavGraph(
CollectionsScreen(viewModel = vm, onBack = { navController.popBackStack() })
}
composable(Routes.TALK_ROOMS) {
val vm: TalkRoomListViewModel = viewModel(factory = TalkViewModelFactory(app) { repo, _ -> TalkRoomListViewModel(repo) })
TalkRoomListScreen(
viewModel = vm,
onOpenRoom = { room -> navController.navigate(talkChatRoute(room.token, room.displayName)) },
onBack = { navController.popBackStack() }
)
}
composable(
route = Routes.TALK_CHAT,
arguments = listOf(
navArgument("token") { type = NavType.StringType },
navArgument("name") { type = NavType.StringType }
)
) { backStackEntry ->
val vm: TalkChatViewModel = viewModel(factory = TalkViewModelFactory(app) { repo, handle -> TalkChatViewModel(repo, handle) })
val roomName = URLDecoder.decode(backStackEntry.arguments?.getString("name") ?: "", "UTF-8")
TalkChatScreen(viewModel = vm, roomName = roomName, onBack = { navController.popBackStack() })
}
composable(
route = Routes.EDIT,
arguments = listOf(

View File

@ -0,0 +1,114 @@
package com.homelab.ncal.ui.talk
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.homelab.ncal.data.network.TalkMessage
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TalkChatScreen(
viewModel: TalkChatViewModel,
roomName: String,
onBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
val listState = rememberLazyListState()
LaunchedEffect(Unit) { viewModel.start() }
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.size - 1)
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text(roomName) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
}
)
}
) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp))
}
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(12.dp)
) {
items(state.messages, key = { it.id }) { message ->
ChatBubble(message)
}
}
Row(
modifier = Modifier.fillMaxWidth().imePadding().padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = state.draft,
onValueChange = viewModel::onDraftChange,
modifier = Modifier.weight(1f),
placeholder = { Text("Message") }
)
IconButton(onClick = viewModel::send, enabled = state.draft.isNotBlank() && !state.sending) {
Icon(Icons.Filled.Send, "Send")
}
}
}
}
}
@Composable
private fun ChatBubble(message: TalkMessage) {
if (message.isSystemMessage) {
Text(
message.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
)
return
}
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Text(
"${message.actorDisplayName} · ${formatTime(message.timestamp)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(message.message, style = MaterialTheme.typography.bodyMedium)
}
}
private fun formatTime(millis: Long): String =
Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("h:mm a"))

View File

@ -0,0 +1,101 @@
package com.homelab.ncal.ui.talk
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homelab.ncal.data.network.TalkMessage
import com.homelab.ncal.data.repository.TalkRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.net.URLDecoder
data class TalkChatUiState(
val messages: List<TalkMessage> = emptyList(),
val draft: String = "",
val loading: Boolean = false,
val sending: Boolean = false,
val error: String? = null
)
class TalkChatViewModel(
private val repo: TalkRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val token: String = URLDecoder.decode(checkNotNull<String>(savedStateHandle["token"]), "UTF-8")
private val _state = MutableStateFlow(TalkChatUiState())
val state: StateFlow<TalkChatUiState> = _state
private var pollJob: Job? = null
private var started = false
/** Idempotent - safe to call from LaunchedEffect(Unit) on every recomposition of the screen. */
fun start() {
if (started) return
started = true
loadHistory()
}
private fun loadHistory() {
_state.value = _state.value.copy(loading = true, error = null)
viewModelScope.launch {
try {
val messages = repo.getMessages(token, lastKnownMessageId = null, lookIntoFuture = false)
_state.value = _state.value.copy(messages = messages.sortedBy { it.id }, loading = false)
startPolling()
} catch (e: Exception) {
_state.value = _state.value.copy(loading = false, error = e.message ?: "Couldn't load messages")
}
}
}
/** Long-polls (lookIntoFuture=1) in a tight loop - each call blocks server-side for up to
* ~30s, so this isn't a busy-wait; it only re-issues the request after one actually returns. */
private fun startPolling() {
pollJob?.cancel()
pollJob = viewModelScope.launch {
while (true) {
val lastId = _state.value.messages.lastOrNull()?.id
try {
val newMessages = repo.getMessages(token, lastKnownMessageId = lastId, lookIntoFuture = true)
if (newMessages.isNotEmpty()) {
val merged = (_state.value.messages + newMessages).distinctBy { it.id }.sortedBy { it.id }
_state.value = _state.value.copy(messages = merged)
}
} catch (e: Exception) {
// Transient network hiccup mid-poll - back off briefly rather than surfacing
// an error for every blip, since the loop just keeps going regardless.
delay(3000)
}
}
}
}
fun onDraftChange(text: String) {
_state.value = _state.value.copy(draft = text)
}
fun send() {
val text = _state.value.draft.trim()
if (text.isBlank()) return
_state.value = _state.value.copy(sending = true, draft = "")
viewModelScope.launch {
try {
val sent = repo.sendMessage(token, text)
val merged = (_state.value.messages + sent).distinctBy { it.id }.sortedBy { it.id }
_state.value = _state.value.copy(messages = merged, sending = false)
} catch (e: Exception) {
_state.value = _state.value.copy(sending = false, error = e.message ?: "Couldn't send message")
}
}
}
override fun onCleared() {
super.onCleared()
pollJob?.cancel()
}
}

View File

@ -0,0 +1,87 @@
package com.homelab.ncal.ui.talk
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Badge
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.homelab.ncal.data.network.TalkRoom
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TalkRoomListScreen(
viewModel: TalkRoomListViewModel,
onOpenRoom: (TalkRoom) -> Unit,
onBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) { viewModel.refresh() }
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Talk") },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
}
)
}
) { padding ->
PullToRefreshBox(
isRefreshing = state.loading,
onRefresh = viewModel::refresh,
modifier = Modifier.fillMaxSize().padding(padding)
) {
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp))
}
if (state.rooms.isEmpty() && !state.loading && state.error == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No conversations yet.")
}
}
LazyColumn {
items(state.rooms, key = { it.token }) { room ->
ListItem(
modifier = Modifier.clickable { onOpenRoom(room) },
headlineContent = { Text(room.displayName) },
supportingContent = {
room.lastMessagePreview?.let {
Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
},
trailingContent = {
if (room.unreadMessages > 0) {
Badge { Text(room.unreadMessages.toString()) }
}
}
)
HorizontalDivider()
}
}
}
}
}

View File

@ -0,0 +1,33 @@
package com.homelab.ncal.ui.talk
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homelab.ncal.data.network.TalkRoom
import com.homelab.ncal.data.repository.TalkRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class TalkRoomListUiState(
val rooms: List<TalkRoom> = emptyList(),
val loading: Boolean = false,
val error: String? = null
)
class TalkRoomListViewModel(private val repo: TalkRepository) : ViewModel() {
private val _state = MutableStateFlow(TalkRoomListUiState())
val state: StateFlow<TalkRoomListUiState> = _state
fun refresh() {
_state.value = _state.value.copy(loading = true, error = null)
viewModelScope.launch {
try {
val rooms = repo.listRooms()
_state.value = _state.value.copy(rooms = rooms, loading = false)
} catch (e: Exception) {
_state.value = _state.value.copy(loading = false, error = e.message ?: "Couldn't load conversations")
}
}
}
}