Implements message loading and display
This commit is contained in:
@@ -36,10 +36,10 @@ fun KordophoneApp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable(Destination.MessageList.route) {
|
composable(Destination.MessageList.route) {
|
||||||
val conversationID = it.arguments?.getString("id")
|
val conversationID = it.arguments?.getString("id")!!
|
||||||
MessageListScreen(messages = listOf()) {
|
MessageListScreen(conversationGUID = conversationID, backAction = {
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package net.buzzert.kordophonedroid.ui.messagelist
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Surface
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageEntry(
|
||||||
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
onTextChanged: (TextFieldValue) -> Unit,
|
||||||
|
textFieldValue: TextFieldValue,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
border = BorderStroke(1.dp, color = MaterialTheme.colors.onBackground.copy(0.4f))
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.weight(1f)
|
||||||
|
.align(Alignment.Bottom)
|
||||||
|
.imePadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
) {
|
||||||
|
BasicTextField(
|
||||||
|
value = textFieldValue,
|
||||||
|
onValueChange = { onTextChanged(it) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colors.onBackground),
|
||||||
|
textStyle = MaterialTheme.typography.body1.copy(MaterialTheme.colors.onBackground),
|
||||||
|
decorationBox = { textContent ->
|
||||||
|
if (textFieldValue.text.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Message",
|
||||||
|
style = MaterialTheme.typography.body1.copy(
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
textContent()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(onClick = onSend) {
|
||||||
|
Text(text = "Send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,17 @@
|
|||||||
package net.buzzert.kordophonedroid.ui.messagelist
|
package net.buzzert.kordophonedroid.ui.messagelist
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.ime
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -22,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyListState
|
|||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.Button
|
import androidx.compose.material.Button
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
import androidx.compose.material.IconButton
|
import androidx.compose.material.IconButton
|
||||||
@@ -34,74 +30,115 @@ import androidx.compose.material.TopAppBar
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import net.buzzert.kordophone.backend.model.GUID
|
||||||
|
import net.buzzert.kordophone.backend.model.Message
|
||||||
|
|
||||||
@Immutable
|
private val IncomingChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
|
||||||
data class Message(
|
private val OutgoingChatBubbleShape = RoundedCornerShape(20.dp, 4.dp, 20.dp, 20.dp)
|
||||||
val content: String,
|
|
||||||
|
data class MessageListViewItem(
|
||||||
|
val text: String,
|
||||||
|
val fromMe: Boolean,
|
||||||
|
val delivered: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageListScreen(
|
fun MessageListScreen(
|
||||||
messages: List<Message>,
|
conversationGUID: GUID,
|
||||||
backAction: () -> Unit
|
backAction: () -> Unit,
|
||||||
|
viewModel: MessageListViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
viewModel.conversationGUID = conversationGUID
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
Log.d("MessageListScreen", "Launched effect")
|
||||||
|
viewModel.synchronize()
|
||||||
|
}
|
||||||
|
|
||||||
|
val messages by viewModel.messages.collectAsStateWithLifecycle(initialValue = listOf())
|
||||||
|
|
||||||
|
val messageItems = messages.map {
|
||||||
|
MessageListViewItem(
|
||||||
|
text = it.text,
|
||||||
|
fromMe = it.sender == null,
|
||||||
|
delivered = !viewModel.isPendingMessage(it)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(topBar = { MessagesListTopAppBar(title = viewModel.title, backAction = backAction) }) { padding ->
|
||||||
|
MessageTranscript(
|
||||||
|
messages = messageItems,
|
||||||
|
paddingValues = padding,
|
||||||
|
onSendMessage = { text ->
|
||||||
|
viewModel.enqueueOutgoingMessage(text)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessagesListTopAppBar(title: String, backAction: () -> Unit) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = backAction) {
|
||||||
|
Icon(Icons.Filled.ArrowBack, null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageTranscript(
|
||||||
|
messages: List<MessageListViewItem>,
|
||||||
|
paddingValues: PaddingValues,
|
||||||
|
onSendMessage: (text: String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberLazyListState()
|
val scrollState = rememberLazyListState()
|
||||||
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||||
mutableStateOf(TextFieldValue())
|
mutableStateOf(TextFieldValue())
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Column(
|
||||||
topBar = {
|
Modifier
|
||||||
TopAppBar(
|
.fillMaxSize()
|
||||||
title = { Text("Messages") },
|
.padding(paddingValues)) {
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = backAction) {
|
|
||||||
Icon(Icons.Filled.ArrowBack, null)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
|
|
||||||
})
|
Messages(messages = messages, modifier = Modifier.weight(1f), scrollState = scrollState)
|
||||||
}
|
|
||||||
) { paddingValues ->
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(paddingValues)) {
|
|
||||||
|
|
||||||
Messages(messages = messages, modifier = Modifier.weight(1f), scrollState = scrollState)
|
MessageEntry(
|
||||||
|
onTextChanged = { textState = it },
|
||||||
|
textFieldValue = textState,
|
||||||
|
onSend = {
|
||||||
|
onSendMessage(textState.text)
|
||||||
|
|
||||||
MessageEntry(
|
// Clear text state
|
||||||
onTextChanged = { textState = it },
|
textState = TextFieldValue()
|
||||||
textFieldValue = textState,
|
},
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Messages(
|
fun Messages(
|
||||||
messages: List<Message>,
|
messages: List<MessageListViewItem>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
scrollState: LazyListState
|
scrollState: LazyListState
|
||||||
) {
|
) {
|
||||||
@@ -117,97 +154,59 @@ fun Messages(
|
|||||||
for (index in messages.indices) {
|
for (index in messages.indices) {
|
||||||
val content = messages[index]
|
val content = messages[index]
|
||||||
item {
|
item {
|
||||||
MessageBubble(content)
|
MessageBubble(
|
||||||
|
text = content.text,
|
||||||
|
mine = content.fromMe,
|
||||||
|
modifier = Modifier.alpha(if (!content.delivered) 0.5F else 1.0f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageBubble(message: Message) {
|
fun MessageBubble(
|
||||||
val backgroundBubbleColor = MaterialTheme.colors.primary
|
text: String,
|
||||||
|
mine: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val backgroundBubbleColor = if (mine) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||||
|
|
||||||
Column(modifier = Modifier.padding(end = 16.dp)) {
|
Column() {
|
||||||
Surface(
|
Row(
|
||||||
color = backgroundBubbleColor,
|
modifier = modifier.fillMaxWidth(),
|
||||||
shape = ChatBubbleShape
|
horizontalArrangement = if (mine) Arrangement.End else Arrangement.Start,
|
||||||
) {
|
) {
|
||||||
Text(
|
Surface(
|
||||||
text = message.content,
|
color = backgroundBubbleColor,
|
||||||
style = MaterialTheme.typography.body1,
|
shape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape,
|
||||||
modifier = Modifier
|
) {
|
||||||
.padding(16.dp)
|
Text(
|
||||||
)
|
text = text,
|
||||||
|
style = MaterialTheme.typography.body1,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MessageEntry(
|
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
|
||||||
onTextChanged: (TextFieldValue) -> Unit,
|
|
||||||
textFieldValue: TextFieldValue,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
border = BorderStroke(1.dp, color = MaterialTheme.colors.onBackground.copy(0.4f))
|
|
||||||
) {
|
|
||||||
Row(modifier = Modifier
|
|
||||||
.padding(horizontal = 8.dp)
|
|
||||||
.weight(1f)
|
|
||||||
.align(Alignment.Bottom)
|
|
||||||
.imePadding()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
) {
|
|
||||||
BasicTextField(
|
|
||||||
value = textFieldValue,
|
|
||||||
onValueChange = { onTextChanged(it) },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
.align(Alignment.CenterVertically)
|
|
||||||
.padding(horizontal = 8.dp),
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colors.onBackground),
|
|
||||||
textStyle = MaterialTheme.typography.body1.copy(MaterialTheme.colors.onBackground),
|
|
||||||
decorationBox = { textContent ->
|
|
||||||
if (textFieldValue.text.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = "Message",
|
|
||||||
style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
textContent()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Button(onClick = { /*TODO*/ }) {
|
|
||||||
Text(text = "Send")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -
|
// -
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageListScreenPreview() {
|
private fun MessageListScreenPreview() {
|
||||||
val messages = listOf<Message>(
|
val messages = listOf<MessageListViewItem>(
|
||||||
Message(content = "Hello"),
|
MessageListViewItem(text = "Hello", fromMe = false),
|
||||||
Message(content = "Hi there"),
|
MessageListViewItem(text = "Hey there", fromMe = true),
|
||||||
)
|
MessageListViewItem(text = "How's it going", fromMe = true, delivered = false)
|
||||||
|
).reversed()
|
||||||
|
|
||||||
MessageListScreen(messages = messages) {}
|
Scaffold() {
|
||||||
|
MessageTranscript(messages = messages, paddingValues = it, onSendMessage = {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package net.buzzert.kordophonedroid.ui.messagelist
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.channels.consumeEach
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import net.buzzert.kordophone.backend.model.Conversation
|
||||||
|
import net.buzzert.kordophone.backend.model.GUID
|
||||||
|
import net.buzzert.kordophone.backend.model.Message
|
||||||
|
import net.buzzert.kordophone.backend.server.ChatRepository
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class MessageListViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
) : ViewModel()
|
||||||
|
{
|
||||||
|
var conversationGUID: GUID? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
value?.let {
|
||||||
|
conversation = repository.conversationForGuid(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var conversation: Conversation? = null
|
||||||
|
private val pendingMessages: MutableStateFlow<List<Message>> = MutableStateFlow(listOf())
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Remove pending message after message is delivered.
|
||||||
|
// By now, the repository should've committed this to the store.
|
||||||
|
repository.messageDeliveredChannel.collectLatest { event ->
|
||||||
|
pendingMessages.value =
|
||||||
|
pendingMessages.value.filter { it.guid != event.message.guid }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val messages: Flow<List<Message>>
|
||||||
|
get() = repository.messagesChanged(conversation!!)
|
||||||
|
.combine(pendingMessages) { a, b -> a.union(b) }
|
||||||
|
.map { messages ->
|
||||||
|
messages
|
||||||
|
.sortedBy { it.date }
|
||||||
|
.reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
val title: String get() = conversation!!.formattedDisplayName()
|
||||||
|
|
||||||
|
fun enqueueOutgoingMessage(text: String) {
|
||||||
|
val outgoingMessage = Message(
|
||||||
|
guid = UUID.randomUUID().toString(),
|
||||||
|
text = text,
|
||||||
|
sender = null,
|
||||||
|
date = Date(),
|
||||||
|
conversation = conversation!!,
|
||||||
|
)
|
||||||
|
|
||||||
|
pendingMessages.value = pendingMessages.value + listOf(outgoingMessage)
|
||||||
|
repository.enqueueOutgoingMessage(outgoingMessage, conversation!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPendingMessage(message: Message): Boolean {
|
||||||
|
return pendingMessages.value.contains(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun synchronize() = viewModelScope.launch {
|
||||||
|
repository.synchronizeConversation(conversation!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
backend/chat-cache-test
Normal file
BIN
backend/chat-cache-test
Normal file
Binary file not shown.
BIN
backend/chat-cache-test.lock
Normal file
BIN
backend/chat-cache-test.lock
Normal file
Binary file not shown.
@@ -57,7 +57,7 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
|
|||||||
.first()
|
.first()
|
||||||
.messages
|
.messages
|
||||||
.asFlow()
|
.asFlow()
|
||||||
.map { it.list.map { it.toMessage(conversation) } }
|
.map { it.set.map { it.toMessage(conversation) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateConversations(incomingConversations: List<ModelConversation>) = realm.writeBlocking {
|
fun updateConversations(incomingConversations: List<ModelConversation>) = realm.writeBlocking {
|
||||||
@@ -111,15 +111,24 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
|
|||||||
return items.map { it.toConversation() }
|
return items.map { it.toConversation() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeMessages(messages: List<ModelMessage>, conversation: ModelConversation) {
|
fun writeMessages(messages: List<ModelMessage>, conversation: ModelConversation, outgoing: Boolean = false) {
|
||||||
val dbConversation = getManagedConversationByGuid(conversation.guid)
|
val dbConversation = getManagedConversationByGuid(conversation.guid)
|
||||||
realm.writeBlocking {
|
realm.writeBlocking {
|
||||||
val dbMessages = messages
|
val dbMessages = messages
|
||||||
.map { it.toDatabaseMessage() }
|
.map { it.toDatabaseMessage(outgoing = outgoing) }
|
||||||
.map { copyToRealm(it, updatePolicy = UpdatePolicy.ALL) }
|
.map { copyToRealm(it, updatePolicy = UpdatePolicy.ALL) }
|
||||||
|
|
||||||
val obj = findLatest(dbConversation)
|
findLatest(dbConversation)?.messages?.addAll(dbMessages)
|
||||||
obj!!.messages.addAll(dbMessages)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearOutgoingMessages() {
|
||||||
|
realm.query(Message::class, "isOutgoing == true").find().forEach { message ->
|
||||||
|
realm.writeBlocking {
|
||||||
|
findLatest(message)?.let {
|
||||||
|
delete(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package net.buzzert.kordophone.backend.db.model
|
|||||||
|
|
||||||
import io.realm.kotlin.Realm
|
import io.realm.kotlin.Realm
|
||||||
import io.realm.kotlin.ext.realmListOf
|
import io.realm.kotlin.ext.realmListOf
|
||||||
|
import io.realm.kotlin.ext.realmSetOf
|
||||||
import io.realm.kotlin.ext.toRealmList
|
import io.realm.kotlin.ext.toRealmList
|
||||||
import io.realm.kotlin.types.RealmInstant
|
import io.realm.kotlin.types.RealmInstant
|
||||||
import io.realm.kotlin.types.RealmList
|
import io.realm.kotlin.types.RealmList
|
||||||
import io.realm.kotlin.types.RealmObject
|
import io.realm.kotlin.types.RealmObject
|
||||||
|
import io.realm.kotlin.types.RealmSet
|
||||||
import io.realm.kotlin.types.annotations.PrimaryKey
|
import io.realm.kotlin.types.annotations.PrimaryKey
|
||||||
import net.buzzert.kordophone.backend.model.GUID
|
import net.buzzert.kordophone.backend.model.GUID
|
||||||
import org.mongodb.kbson.ObjectId
|
import org.mongodb.kbson.ObjectId
|
||||||
@@ -23,7 +25,7 @@ open class Conversation(
|
|||||||
var unreadCount: Int,
|
var unreadCount: Int,
|
||||||
|
|
||||||
var lastMessagePreview: String?,
|
var lastMessagePreview: String?,
|
||||||
var messages: RealmList<Message>,
|
var messages: RealmSet<Message>,
|
||||||
): RealmObject
|
): RealmObject
|
||||||
{
|
{
|
||||||
constructor() : this(
|
constructor() : this(
|
||||||
@@ -35,7 +37,7 @@ open class Conversation(
|
|||||||
unreadCount = 0,
|
unreadCount = 0,
|
||||||
lastMessagePreview = null,
|
lastMessagePreview = null,
|
||||||
|
|
||||||
messages = realmListOf<Message>()
|
messages = realmSetOf<Message>()
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toConversation(): ModelConversation {
|
fun toConversation(): ModelConversation {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ open class Message(
|
|||||||
var date: RealmInstant,
|
var date: RealmInstant,
|
||||||
|
|
||||||
var conversationGUID: GUID,
|
var conversationGUID: GUID,
|
||||||
|
var isOutgoing: Boolean,
|
||||||
): RealmObject
|
): RealmObject
|
||||||
{
|
{
|
||||||
constructor() : this(
|
constructor() : this(
|
||||||
@@ -30,6 +31,7 @@ open class Message(
|
|||||||
sender = null,
|
sender = null,
|
||||||
date = RealmInstant.now(),
|
date = RealmInstant.now(),
|
||||||
conversationGUID = ObjectId().toString(),
|
conversationGUID = ObjectId().toString(),
|
||||||
|
isOutgoing = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toMessage(parentConversation: ModelConversation): ModelMessage {
|
fun toMessage(parentConversation: ModelConversation): ModelMessage {
|
||||||
@@ -43,7 +45,7 @@ open class Message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ModelMessage.toDatabaseMessage(): Message {
|
fun ModelMessage.toDatabaseMessage(outgoing: Boolean = false): Message {
|
||||||
val from = this
|
val from = this
|
||||||
return Message().apply {
|
return Message().apply {
|
||||||
text = from.text
|
text = from.text
|
||||||
@@ -51,5 +53,6 @@ fun ModelMessage.toDatabaseMessage(): Message {
|
|||||||
sender = from.sender
|
sender = from.sender
|
||||||
date = from.date.toInstant().toRealmInstant()
|
date = from.date.toInstant().toRealmInstant()
|
||||||
conversationGUID = from.conversation.guid
|
conversationGUID = from.conversation.guid
|
||||||
|
isOutgoing = outgoing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package net.buzzert.kordophone.backend.model
|
|||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
typealias GUID = String
|
typealias GUID = String
|
||||||
|
|
||||||
@@ -27,6 +28,19 @@ data class Conversation(
|
|||||||
@SerializedName("lastMessage")
|
@SerializedName("lastMessage")
|
||||||
var lastMessage: Message?,
|
var lastMessage: Message?,
|
||||||
) {
|
) {
|
||||||
|
companion object {
|
||||||
|
fun generate(): Conversation {
|
||||||
|
return Conversation(
|
||||||
|
guid = UUID.randomUUID().toString(),
|
||||||
|
date = Date(),
|
||||||
|
participants = listOf("foo@foo.com"),
|
||||||
|
displayName = null,
|
||||||
|
unreadCount = 0,
|
||||||
|
lastMessagePreview = null,
|
||||||
|
lastMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun formattedDisplayName(): String {
|
fun formattedDisplayName(): String {
|
||||||
return displayName ?: participants.joinToString(", ")
|
return displayName ?: participants.joinToString(", ")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package net.buzzert.kordophone.backend.model
|
|||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
data class Message(
|
data class Message(
|
||||||
@SerializedName("guid")
|
@SerializedName("guid")
|
||||||
@@ -19,6 +20,18 @@ data class Message(
|
|||||||
@Transient
|
@Transient
|
||||||
var conversation: Conversation,
|
var conversation: Conversation,
|
||||||
) {
|
) {
|
||||||
|
companion object {
|
||||||
|
fun generate(text: String, conversation: Conversation = Conversation.generate(), sender: String? = null): Message {
|
||||||
|
return Message(
|
||||||
|
guid = UUID.randomUUID().toString(),
|
||||||
|
text = text,
|
||||||
|
sender = sender,
|
||||||
|
date = Date(),
|
||||||
|
conversation = conversation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
return "Message(guid=$guid, text=$text, sender=$sender, date=$date, parent=${conversation.guid})"
|
return "Message(guid=$guid, text=$text, sender=$sender, date=$date, parent=${conversation.guid})"
|
||||||
}
|
}
|
||||||
@@ -35,4 +48,13 @@ data class Message(
|
|||||||
conversation.guid == o.conversation.guid
|
conversation.guid == o.conversation.guid
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = guid.hashCode()
|
||||||
|
result = 31 * result + text.hashCode()
|
||||||
|
result = 31 * result + (sender?.hashCode() ?: 0)
|
||||||
|
result = 31 * result + date.hashCode()
|
||||||
|
result = 31 * result + conversation.guid.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,12 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.consumeEach
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -34,7 +38,8 @@ class ChatRepository(
|
|||||||
get() = database.fetchConversations()
|
get() = database.fetchConversations()
|
||||||
|
|
||||||
// Channel that's signaled when an outgoing message is delivered.
|
// Channel that's signaled when an outgoing message is delivered.
|
||||||
val messageDeliveredChannel = Channel<MessageDeliveredEvent>()
|
val messageDeliveredChannel: SharedFlow<MessageDeliveredEvent>
|
||||||
|
get() = _messageDeliveredChannel
|
||||||
|
|
||||||
// Changes Flow
|
// Changes Flow
|
||||||
val conversationChanges: Flow<List<Conversation>>
|
val conversationChanges: Flow<List<Conversation>>
|
||||||
@@ -66,6 +71,7 @@ class ChatRepository(
|
|||||||
private val apiInterface = apiClient.getAPIInterface()
|
private val apiInterface = apiClient.getAPIInterface()
|
||||||
private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessageInfo> = ArrayBlockingQueue(16)
|
private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessageInfo> = ArrayBlockingQueue(16)
|
||||||
private var outgoingMessageThread: Thread? = null
|
private var outgoingMessageThread: Thread? = null
|
||||||
|
private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>()
|
||||||
|
|
||||||
private val updateMonitor = UpdateMonitor(apiClient)
|
private val updateMonitor = UpdateMonitor(apiClient)
|
||||||
private var updateWatchJob: Job? = null
|
private var updateWatchJob: Job? = null
|
||||||
@@ -83,6 +89,9 @@ class ChatRepository(
|
|||||||
launch {
|
launch {
|
||||||
updateMonitor.messageAdded.collect { handleMessageAddedUpdate(it) }
|
updateMonitor.messageAdded.collect { handleMessageAddedUpdate(it) }
|
||||||
}
|
}
|
||||||
|
launch {
|
||||||
|
messageDeliveredChannel.collectLatest { handleMessageDelivered(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMonitor.beginMonitoringUpdates()
|
updateMonitor.beginMonitoringUpdates()
|
||||||
@@ -124,15 +133,27 @@ class ChatRepository(
|
|||||||
val serverConversations = fetchConversations()
|
val serverConversations = fetchConversations()
|
||||||
database.updateConversations(serverConversations)
|
database.updateConversations(serverConversations)
|
||||||
|
|
||||||
|
// Delete outgoing conversations
|
||||||
|
// This is an unfortunate limitation in that we don't know what outgoing GUIDs are going to
|
||||||
|
// be before we send them.
|
||||||
|
// TODO: Keep this in mind when syncing messages after a certain GUID. The outgoing GUIDs are fake.
|
||||||
|
database.clearOutgoingMessages()
|
||||||
|
|
||||||
// Sync top N number of conversations' message content
|
// Sync top N number of conversations' message content
|
||||||
Log.d(REPO_LOG, "Synchronizing messages")
|
Log.d(REPO_LOG, "Synchronizing messages")
|
||||||
val sortedConversations = conversations.sortedBy { it.date }.reversed()
|
val sortedConversations = conversations.sortedBy { it.date }.reversed()
|
||||||
for (conversation in sortedConversations.take(CONVERSATION_MESSAGE_SYNC_COUNT)) {
|
for (conversation in sortedConversations.take(CONVERSATION_MESSAGE_SYNC_COUNT)) {
|
||||||
val messages = fetchMessages(conversation, limit = 15)
|
synchronizeConversation(conversation)
|
||||||
database.writeMessages(messages, conversation)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun synchronizeConversation(conversation: Conversation) {
|
||||||
|
// TODO: Should only fetch messages after the last GUID we know about.
|
||||||
|
// But keep in mind that outgoing message GUIDs are fake...
|
||||||
|
val messages = fetchMessages(conversation, limit = 15)
|
||||||
|
database.writeMessages(messages, conversation)
|
||||||
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
database.close()
|
database.close()
|
||||||
}
|
}
|
||||||
@@ -164,6 +185,14 @@ class ChatRepository(
|
|||||||
database.writeMessages(listOf(message), message.conversation)
|
database.writeMessages(listOf(message), message.conversation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleMessageDelivered(event: MessageDeliveredEvent) {
|
||||||
|
Log.d(REPO_LOG, "Handling successful delivery event")
|
||||||
|
|
||||||
|
// Unfortunate protocol reality: the server doesn't tell us about new messages that are from us,
|
||||||
|
// so we have to explicitly handle this like a messageAddedUpdate.
|
||||||
|
database.writeMessages(listOf(event.message), event.conversation, outgoing = true)
|
||||||
|
}
|
||||||
|
|
||||||
private fun outgoingMessageQueueMain() {
|
private fun outgoingMessageQueueMain() {
|
||||||
Log.d(REPO_LOG, "Outgoing Message Queue Main")
|
Log.d(REPO_LOG, "Outgoing Message Queue Main")
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -185,7 +214,7 @@ class ChatRepository(
|
|||||||
|
|
||||||
if (result.isSuccessful) {
|
if (result.isSuccessful) {
|
||||||
Log.d(REPO_LOG, "Successfully sent message.")
|
Log.d(REPO_LOG, "Successfully sent message.")
|
||||||
messageDeliveredChannel.send(MessageDeliveredEvent(guid, message, conversation))
|
_messageDeliveredChannel.emit(MessageDeliveredEvent(guid, message, conversation))
|
||||||
} else {
|
} else {
|
||||||
Log.e(REPO_LOG, "Error sending message. Enqueuing for retry.")
|
Log.e(REPO_LOG, "Error sending message. Enqueuing for retry.")
|
||||||
outgoingMessageQueue.add(it)
|
outgoingMessageQueue.add(it)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import okio.ByteString
|
|||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
import java.lang.reflect.Type
|
import java.lang.reflect.Type
|
||||||
|
|
||||||
const val UPMON_LOG: String = "ChatRepository"
|
const val UPMON_LOG: String = "UpdateMonitor"
|
||||||
|
|
||||||
class UpdateMonitor(private val client: APIClient) : WebSocketListener() {
|
class UpdateMonitor(private val client: APIClient) : WebSocketListener() {
|
||||||
// Flow for getting conversation changed notifications
|
// Flow for getting conversation changed notifications
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package net.buzzert.kordophone.backend
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -87,7 +88,7 @@ class BackendTests {
|
|||||||
|
|
||||||
val guid = repository.enqueueOutgoingMessage(outgoingMessage, conversation)
|
val guid = repository.enqueueOutgoingMessage(outgoingMessage, conversation)
|
||||||
|
|
||||||
val event = repository.messageDeliveredChannel.receive()
|
val event = repository.messageDeliveredChannel.first()
|
||||||
assertEquals(event.guid, guid)
|
assertEquals(event.guid, guid)
|
||||||
assertEquals(event.message.text, outgoingMessage.text)
|
assertEquals(event.message.text, outgoingMessage.text)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user