From 2bf9a32ead38f73d8d597bcae8c425b1173c11fc Mon Sep 17 00:00:00 2001 From: RomanNum3ral Date: Sat, 18 Jul 2026 13:29:15 -0400 Subject: [PATCH] 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. --- .../java/com/homelab/ncal/NcalApplication.kt | 5 + .../homelab/ncal/data/network/TalkClient.kt | 123 ++++++++++++++++++ .../ncal/data/repository/TalkRepository.kt | 38 ++++++ .../homelab/ncal/ui/TalkViewModelFactory.kt | 20 +++ .../homelab/ncal/ui/agenda/AgendaScreen.kt | 5 +- .../com/homelab/ncal/ui/nav/NcalNavGraph.kt | 38 +++++- .../homelab/ncal/ui/talk/TalkChatScreen.kt | 114 ++++++++++++++++ .../homelab/ncal/ui/talk/TalkChatViewModel.kt | 101 ++++++++++++++ .../ncal/ui/talk/TalkRoomListScreen.kt | 87 +++++++++++++ .../ncal/ui/talk/TalkRoomListViewModel.kt | 33 +++++ 10 files changed, 562 insertions(+), 2 deletions(-) create mode 100644 ncal/app/src/main/java/com/homelab/ncal/data/network/TalkClient.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/data/repository/TalkRepository.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/TalkViewModelFactory.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkChatScreen.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkChatViewModel.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkRoomListScreen.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkRoomListViewModel.kt diff --git a/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt b/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt index 0cd14a5..2217f8b 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt @@ -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) diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/network/TalkClient.kt b/ncal/app/src/main/java/com/homelab/ncal/data/network/TalkClient.kt new file mode 100644 index 0000000..82b1d6f --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/network/TalkClient.kt @@ -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 { + 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 { + 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() ?: "" + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/repository/TalkRepository.kt b/ncal/app/src/main/java/com/homelab/ncal/data/repository/TalkRepository.kt new file mode 100644 index 0000000..d179e3a --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/repository/TalkRepository.kt @@ -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 = withContext(Dispatchers.IO) { + client().listRooms() + } + + suspend fun getMessages(token: String, lastKnownMessageId: Int?, lookIntoFuture: Boolean): List = + withContext(Dispatchers.IO) { + client().getMessages(token, lastKnownMessageId, lookIntoFuture) + } + + suspend fun sendMessage(token: String, message: String): TalkMessage = withContext(Dispatchers.IO) { + client().sendMessage(token, message) + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/TalkViewModelFactory.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/TalkViewModelFactory.kt new file mode 100644 index 0000000..e6044f4 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/TalkViewModelFactory.kt @@ -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 create(modelClass: Class, extras: CreationExtras): T { + val handle = extras.createSavedStateHandle() + @Suppress("UNCHECKED_CAST") + return create(app.talkRepository, handle) as T + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt index 4e8211a..5b6c2d2 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt @@ -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") } diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt index 53525d6..09b836d 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt @@ -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( diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkChatScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkChatScreen.kt new file mode 100644 index 0000000..16a9fea --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkChatScreen.kt @@ -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")) diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkChatViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkChatViewModel.kt new file mode 100644 index 0000000..fe0b52a --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkChatViewModel.kt @@ -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 = 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(savedStateHandle["token"]), "UTF-8") + + private val _state = MutableStateFlow(TalkChatUiState()) + val state: StateFlow = _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() + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkRoomListScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkRoomListScreen.kt new file mode 100644 index 0000000..046a0c8 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkRoomListScreen.kt @@ -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() + } + } + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkRoomListViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkRoomListViewModel.kt new file mode 100644 index 0000000..8afc257 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/talk/TalkRoomListViewModel.kt @@ -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 = emptyList(), + val loading: Boolean = false, + val error: String? = null +) + +class TalkRoomListViewModel(private val repo: TalkRepository) : ViewModel() { + + private val _state = MutableStateFlow(TalkRoomListUiState()) + val state: StateFlow = _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") + } + } + } +}