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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Release note structure source: `docs/project/RELEASE_NOTES_TEMPLATE.md`

### Added
- Android: direct messages are now accessible — a new envelope button on the home screen opens a conversation list (avatar, name, last-message preview, unread badge); tapping a conversation opens it like any channel
- Android: message attachments now display — images render as inline thumbnails, other files as a chip with name and size (previously attachments were invisible)
- Android: unread channels now show a count badge and bolder name in the channel list, clearing when you open the channel and re-raising on new messages
- Android: messages now render inline markdown (bold, italic, strikethrough, inline code, and click-to-reveal spoilers) instead of plain text
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.wolftown.kaiku.data.api

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.wolftown.kaiku.domain.model.DmConversation
import javax.inject.Inject

interface DmApi {
/** List the current user's DM conversations, newest activity first. */
suspend fun getDms(): List<DmConversation>

/** Mark a DM as read up to its latest message, clearing the unread count. */
suspend fun markAsRead(channelId: String)
}

class DmApiImpl @Inject constructor(
private val httpClient: HttpClient
) : DmApi {

override suspend fun getDms(): List<DmConversation> {
val response = httpClient.get("/api/dm")

if (!response.status.isSuccess()) {
val errorBody = runCatching { response.body<ApiErrorResponse>() }.getOrNull()
throw ApiException(response.status, errorBody?.message ?: "Failed to load direct messages")
}

return response.body()
}

override suspend fun markAsRead(channelId: String) {
val response = httpClient.post("/api/dm/$channelId/read")

if (!response.status.isSuccess()) {
val errorBody = runCatching { response.body<ApiErrorResponse>() }.getOrNull()
throw ApiException(response.status, errorBody?.message ?: "Failed to mark DM as read")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package io.wolftown.kaiku.data.repository

import io.wolftown.kaiku.data.api.DmApi
import io.wolftown.kaiku.domain.model.DmConversation
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton

/**
* Holds the user's direct-message conversation list.
*
* The list is fetched from the REST API on demand; opening a DM marks it read
* locally (and on the server) so its unread badge clears immediately.
*/
@Singleton
class DmRepository @Inject constructor(
private val dmApi: DmApi,
) {
private val _conversations = MutableStateFlow<List<DmConversation>>(emptyList())
val conversations: StateFlow<List<DmConversation>> = _conversations.asStateFlow()

suspend fun loadDms() {
_conversations.value = dmApi.getDms()
}

/**
* Mark a DM read: clears its badge locally, then tells the server. The
* local clear is optimistic so the UI updates even if the network is slow;
* a server failure is surfaced to the caller.
*/
suspend fun markAsRead(channelId: String) {
_conversations.value = clearUnread(_conversations.value, channelId)
dmApi.markAsRead(channelId)
}

companion object {
/** Reset unread for one conversation in the list (pure, testable). */
fun clearUnread(conversations: List<DmConversation>, channelId: String): List<DmConversation> =
conversations.map { if (it.id == channelId) it.copy(unreadCount = 0) else it }

/**
* Title to show for a conversation from the current user's perspective:
* - 1:1 DM → the other participant's display name
* - group DM → the explicit channel name, falling back to the joined
* names of the other participants
*/
fun conversationTitle(dm: DmConversation, currentUserId: String): String {
val others = dm.participants.filter { it.userId != currentUserId }
return when {
others.size > 1 -> dm.name.ifBlank { others.joinToString(", ") { it.displayName } }
others.size == 1 -> others.first().displayName
else -> dm.name.ifBlank { "Direct Message" }
}
}

/**
* Avatar to show for a conversation: the other participant's avatar for
* a 1:1 DM, or the group icon for a multi-party DM. Null if neither is set.
*/
fun conversationAvatarUrl(dm: DmConversation, currentUserId: String): String? {
val others = dm.participants.filter { it.userId != currentUserId }
return if (others.size == 1) others.first().avatarUrl else dm.iconUrl
}
}
}
18 changes: 18 additions & 0 deletions mobile/android/app/src/main/java/io/wolftown/kaiku/di/DmModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.wolftown.kaiku.di

import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import io.wolftown.kaiku.data.api.DmApi
import io.wolftown.kaiku.data.api.DmApiImpl
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class DmModule {

@Binds
@Singleton
abstract fun bindDmApi(impl: DmApiImpl): DmApi
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package io.wolftown.kaiku.domain.model

import kotlinx.serialization.Serializable

/**
* A direct-message conversation as returned by `GET /api/dm`.
*
* The server flattens the underlying channel fields (id, name, channel_type,
* icon_url, …) into this object alongside the DM-specific participants, last
* message preview, and unread count. Unmodelled channel fields are dropped by
* the JSON parser ([io.wolftown.kaiku.data.KaikuJson] has `ignoreUnknownKeys`).
*
* A DM *is* a channel, so its messages are loaded through the normal message
* API using [id] as the channel id.
*/
@Serializable
data class DmConversation(
val id: String,
val name: String = "",
val channelType: ChannelType = ChannelType.DM,
val iconUrl: String? = null,
val participants: List<DmParticipant> = emptyList(),
val lastMessage: LastMessagePreview? = null,
val unreadCount: Int = 0,
)

@Serializable
data class DmParticipant(
val userId: String,
val username: String,
val displayName: String,
val avatarUrl: String? = null,
val joinedAt: String = "",
)

@Serializable
data class LastMessagePreview(
val id: String,
val content: String,
val userId: String? = null,
val username: String? = null,
val createdAt: String = "",
)
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import io.wolftown.kaiku.ui.auth.QrScannerScreen
import io.wolftown.kaiku.ui.auth.RegisterScreen
import io.wolftown.kaiku.ui.auth.ServerUrlScreen
import io.wolftown.kaiku.ui.channel.TextChannelScreen
import io.wolftown.kaiku.ui.dm.DmListScreen
import io.wolftown.kaiku.ui.home.HomeScreen
import io.wolftown.kaiku.ui.settings.SettingsScreen
import io.wolftown.kaiku.ui.voice.VoiceChannelScreen
Expand Down Expand Up @@ -109,10 +110,22 @@ fun KaikuNavGraph(
},
onNavigateToSettings = {
navController.navigate("settings")
},
onNavigateToDms = {
navController.navigate("dms")
}
)
}

composable("dms") {
DmListScreen(
onOpenDm = { channelId ->
navController.navigate("channel/$channelId")
},
onNavigateBack = { navController.popBackStack() }
)
}

composable("channel/{channelId}") {
TextChannelScreen(
currentUserId = currentUserId ?: "",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package io.wolftown.kaiku.ui.dm

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil3.compose.AsyncImage
import io.wolftown.kaiku.data.repository.DmRepository
import io.wolftown.kaiku.domain.model.DmConversation

/**
* Direct-message conversation list.
*
* Each row shows the conversation avatar, its title (the other participant for
* a 1:1 DM, or the group name), a one-line last-message preview, and an unread
* badge. Tapping a row opens the DM, which is just a channel — [onOpenDm]
* navigates to the existing text channel screen by channel id.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DmListScreen(
onOpenDm: (channelId: String) -> Unit,
onNavigateBack: () -> Unit,
viewModel: DmListViewModel = hiltViewModel()
) {
val conversations by viewModel.conversations.collectAsState()
val currentUserId by viewModel.currentUserId.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()

Scaffold(
topBar = {
TopAppBar(
title = { Text("Direct Messages") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
when {
isLoading && conversations.isEmpty() -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}

error != null && conversations.isEmpty() -> {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = error ?: "An error occurred",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.refresh() }) {
Text("Retry")
}
}
}

conversations.isEmpty() -> {
Text(
text = "No direct messages yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.Center)
)
}

else -> {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(conversations, key = { it.id }) { dm ->
DmConversationRow(
dm = dm,
currentUserId = currentUserId,
onClick = {
viewModel.onOpenConversation(dm.id)
onOpenDm(dm.id)
}
)
HorizontalDivider()
}
}
}
}
}
}
}

@Composable
private fun DmConversationRow(
dm: DmConversation,
currentUserId: String,
onClick: () -> Unit
) {
val title = DmRepository.conversationTitle(dm, currentUserId)
val avatarUrl = DmRepository.conversationAvatarUrl(dm, currentUserId)
val hasUnread = dm.unreadCount > 0

Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = avatarUrl,
contentDescription = "$title avatar",
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
)

Spacer(modifier = Modifier.width(12.dp))

Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (hasUnread) FontWeight.SemiBold else FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val preview = dm.lastMessage?.let { last ->
val sender = last.username?.let { "$it: " } ?: ""
"$sender${last.content}"
}
if (preview != null) {
Text(
text = preview,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

if (hasUnread) {
Spacer(modifier = Modifier.width(8.dp))
Badge {
Text(text = if (dm.unreadCount > 99) "99+" else dm.unreadCount.toString())
}
}
}
}
Loading
Loading