Private
Public Access
1
0

Implements message loading and display

This commit is contained in:
2023-08-24 00:45:18 -07:00
parent 4add2b4674
commit 6375900710
14 changed files with 371 additions and 132 deletions

View File

@@ -36,10 +36,10 @@ fun KordophoneApp(
}
composable(Destination.MessageList.route) {
val conversationID = it.arguments?.getString("id")
MessageListScreen(messages = listOf()) {
val conversationID = it.arguments?.getString("id")!!
MessageListScreen(conversationGUID = conversationID, backAction = {
navController.popBackStack()
}
})
}
}
}

View File

@@ -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")
}
}
}
}
}

View File

@@ -1,20 +1,17 @@
package net.buzzert.kordophonedroid.ui.messagelist
import android.util.Log
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
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.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
@@ -34,56 +30,92 @@ import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.alpha
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.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
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
data class Message(
val content: String,
private val IncomingChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
private val OutgoingChatBubbleShape = RoundedCornerShape(20.dp, 4.dp, 20.dp, 20.dp)
data class MessageListViewItem(
val text: String,
val fromMe: Boolean,
val delivered: Boolean = true,
)
@Composable
fun MessageListScreen(
messages: List<Message>,
backAction: () -> Unit
conversationGUID: GUID,
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()
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue())
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Messages") },
navigationIcon = {
IconButton(onClick = backAction) {
Icon(Icons.Filled.ArrowBack, null)
}
},
actions = {
})
}
) { paddingValues ->
Column(
Modifier
.fillMaxSize()
@@ -94,14 +126,19 @@ fun MessageListScreen(
MessageEntry(
onTextChanged = { textState = it },
textFieldValue = textState,
onSend = {
onSendMessage(textState.text)
// Clear text state
textState = TextFieldValue()
},
)
}
}
}
@Composable
fun Messages(
messages: List<Message>,
messages: List<MessageListViewItem>,
modifier: Modifier = Modifier,
scrollState: LazyListState
) {
@@ -117,97 +154,59 @@ fun Messages(
for (index in messages.indices) {
val content = messages[index]
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
fun MessageBubble(message: Message) {
val backgroundBubbleColor = MaterialTheme.colors.primary
fun MessageBubble(
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() {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = if (mine) Arrangement.End else Arrangement.Start,
) {
Surface(
color = backgroundBubbleColor,
shape = ChatBubbleShape
shape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape,
) {
Text(
text = message.content,
text = text,
style = MaterialTheme.typography.body1,
modifier = Modifier
.padding(16.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
@Composable
private fun MessageListScreenPreview() {
val messages = listOf<Message>(
Message(content = "Hello"),
Message(content = "Hi there"),
)
val messages = listOf<MessageListViewItem>(
MessageListViewItem(text = "Hello", fromMe = false),
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 = {})
}
}

View File

@@ -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

Binary file not shown.

Binary file not shown.

View File

@@ -57,7 +57,7 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
.first()
.messages
.asFlow()
.map { it.list.map { it.toMessage(conversation) } }
.map { it.set.map { it.toMessage(conversation) } }
}
fun updateConversations(incomingConversations: List<ModelConversation>) = realm.writeBlocking {
@@ -111,15 +111,24 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
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)
realm.writeBlocking {
val dbMessages = messages
.map { it.toDatabaseMessage() }
.map { it.toDatabaseMessage(outgoing = outgoing) }
.map { copyToRealm(it, updatePolicy = UpdatePolicy.ALL) }
val obj = findLatest(dbConversation)
obj!!.messages.addAll(dbMessages)
findLatest(dbConversation)?.messages?.addAll(dbMessages)
}
}
fun clearOutgoingMessages() {
realm.query(Message::class, "isOutgoing == true").find().forEach { message ->
realm.writeBlocking {
findLatest(message)?.let {
delete(it)
}
}
}
}

View File

@@ -2,10 +2,12 @@ package net.buzzert.kordophone.backend.db.model
import io.realm.kotlin.Realm
import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.ext.realmSetOf
import io.realm.kotlin.ext.toRealmList
import io.realm.kotlin.types.RealmInstant
import io.realm.kotlin.types.RealmList
import io.realm.kotlin.types.RealmObject
import io.realm.kotlin.types.RealmSet
import io.realm.kotlin.types.annotations.PrimaryKey
import net.buzzert.kordophone.backend.model.GUID
import org.mongodb.kbson.ObjectId
@@ -23,7 +25,7 @@ open class Conversation(
var unreadCount: Int,
var lastMessagePreview: String?,
var messages: RealmList<Message>,
var messages: RealmSet<Message>,
): RealmObject
{
constructor() : this(
@@ -35,7 +37,7 @@ open class Conversation(
unreadCount = 0,
lastMessagePreview = null,
messages = realmListOf<Message>()
messages = realmSetOf<Message>()
)
fun toConversation(): ModelConversation {

View File

@@ -22,6 +22,7 @@ open class Message(
var date: RealmInstant,
var conversationGUID: GUID,
var isOutgoing: Boolean,
): RealmObject
{
constructor() : this(
@@ -30,6 +31,7 @@ open class Message(
sender = null,
date = RealmInstant.now(),
conversationGUID = ObjectId().toString(),
isOutgoing = false,
)
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
return Message().apply {
text = from.text
@@ -51,5 +53,6 @@ fun ModelMessage.toDatabaseMessage(): Message {
sender = from.sender
date = from.date.toInstant().toRealmInstant()
conversationGUID = from.conversation.guid
isOutgoing = outgoing
}
}

View File

@@ -2,6 +2,7 @@ package net.buzzert.kordophone.backend.model
import com.google.gson.annotations.SerializedName
import java.util.Date
import java.util.UUID
typealias GUID = String
@@ -27,6 +28,19 @@ data class Conversation(
@SerializedName("lastMessage")
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 {
return displayName ?: participants.joinToString(", ")

View File

@@ -2,6 +2,7 @@ package net.buzzert.kordophone.backend.model
import com.google.gson.annotations.SerializedName
import java.util.Date
import java.util.UUID
data class Message(
@SerializedName("guid")
@@ -19,6 +20,18 @@ data class Message(
@Transient
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 {
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
)
}
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
}
}

View File

@@ -5,8 +5,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@@ -34,7 +38,8 @@ class ChatRepository(
get() = database.fetchConversations()
// Channel that's signaled when an outgoing message is delivered.
val messageDeliveredChannel = Channel<MessageDeliveredEvent>()
val messageDeliveredChannel: SharedFlow<MessageDeliveredEvent>
get() = _messageDeliveredChannel
// Changes Flow
val conversationChanges: Flow<List<Conversation>>
@@ -66,6 +71,7 @@ class ChatRepository(
private val apiInterface = apiClient.getAPIInterface()
private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessageInfo> = ArrayBlockingQueue(16)
private var outgoingMessageThread: Thread? = null
private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>()
private val updateMonitor = UpdateMonitor(apiClient)
private var updateWatchJob: Job? = null
@@ -83,6 +89,9 @@ class ChatRepository(
launch {
updateMonitor.messageAdded.collect { handleMessageAddedUpdate(it) }
}
launch {
messageDeliveredChannel.collectLatest { handleMessageDelivered(it) }
}
}
updateMonitor.beginMonitoringUpdates()
@@ -124,14 +133,26 @@ class ChatRepository(
val serverConversations = fetchConversations()
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
Log.d(REPO_LOG, "Synchronizing messages")
val sortedConversations = conversations.sortedBy { it.date }.reversed()
for (conversation in sortedConversations.take(CONVERSATION_MESSAGE_SYNC_COUNT)) {
synchronizeConversation(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() {
database.close()
@@ -164,6 +185,14 @@ class ChatRepository(
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() {
Log.d(REPO_LOG, "Outgoing Message Queue Main")
while (true) {
@@ -185,7 +214,7 @@ class ChatRepository(
if (result.isSuccessful) {
Log.d(REPO_LOG, "Successfully sent message.")
messageDeliveredChannel.send(MessageDeliveredEvent(guid, message, conversation))
_messageDeliveredChannel.emit(MessageDeliveredEvent(guid, message, conversation))
} else {
Log.e(REPO_LOG, "Error sending message. Enqueuing for retry.")
outgoingMessageQueue.add(it)

View File

@@ -16,7 +16,7 @@ import okio.ByteString
import retrofit2.converter.gson.GsonConverterFactory
import java.lang.reflect.Type
const val UPMON_LOG: String = "ChatRepository"
const val UPMON_LOG: String = "UpdateMonitor"
class UpdateMonitor(private val client: APIClient) : WebSocketListener() {
// Flow for getting conversation changed notifications

View File

@@ -3,6 +3,7 @@ package net.buzzert.kordophone.backend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@@ -87,7 +88,7 @@ class BackendTests {
val guid = repository.enqueueOutgoingMessage(outgoingMessage, conversation)
val event = repository.messageDeliveredChannel.receive()
val event = repository.messageDeliveredChannel.first()
assertEquals(event.guid, guid)
assertEquals(event.message.text, outgoingMessage.text)