Skip to content

Commit

Permalink
Render and send messages for a conversation (#44)
Browse files Browse the repository at this point in the history
* save state with passing conversations

* make client instance a singleton to share across the app

* fix import order

* rename to clientmanager since it manages the global client

* Add create conversation bottom sheet (#38)

* make conversation parcelable

* set client in convo detail view model

* add bottom sheet to create a conversation

* remove parcelable in favor of querying by topic

* remove parcelize plugin

* no need for api anymore

* use new bottom sheet on button press

* fetch a conversation by topic and add test

* check peer address is the same as well

* add message adapter to render messages

* send messages

* render messages in message list

* fix duplicate conversations

* we need to actually mutate the object so that it returns a wallet signature

* fix message width

---------

Co-authored-by: Naomi Plasterer <[email protected]>
Co-authored-by: Naomi Plasterer <[email protected]>
  • Loading branch information
3 people authored Mar 6, 2023
1 parent 8053d6d commit 6e68384
Show file tree
Hide file tree
Showing 16 changed files with 350 additions and 18 deletions.
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
package org.xmtp.android.example.conversation

import android.R.id.home
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.launch
import org.xmtp.android.example.R
import org.xmtp.android.example.databinding.ActivityConversationDetailBinding
import org.xmtp.android.example.extension.truncatedAddress
import org.xmtp.android.example.message.MessageAdapter

class ConversationDetailActivity : AppCompatActivity() {

private lateinit var binding: ActivityConversationDetailBinding
private lateinit var adapter: MessageAdapter

private val viewModel: ConversationDetailViewModel by viewModels()

private val peerAddress
get() = intent.extras?.getString(EXTRA_PEER_ADDRESS)

companion object {
const val EXTRA_CONVERSATION_TOPIC = "EXTRA_CONVERSATION_TOPIC"
private const val EXTRA_PEER_ADDRESS = "EXTRA_PEER_ADDRESS"
Expand All @@ -35,18 +54,107 @@ class ConversationDetailActivity : AppCompatActivity() {
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.subtitle = intent.extras?.getString(EXTRA_PEER_ADDRESS)
supportActionBar?.subtitle = peerAddress?.truncatedAddress()

adapter = MessageAdapter()
binding.list.layoutManager =
LinearLayoutManager(this, RecyclerView.VERTICAL, true)
binding.list.adapter = adapter

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect(::ensureUiState)
}
}

binding.messageEditText.requestFocus()
binding.messageEditText.addTextChangedListener {
val sendEnabled = !binding.messageEditText.text.isNullOrBlank()
binding.sendButton.isEnabled = sendEnabled
}

binding.sendButton.setOnClickListener {
val flow = viewModel.sendMessage(binding.messageEditText.text.toString())
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
flow.collect(::ensureSendState)
}
}
}

binding.refresh.setOnRefreshListener {
viewModel.fetchMessages()
}

viewModel.fetchMessages()
}

override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main, menu)
return true
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
return when (item.itemId) {
home -> {
finish()
return true
true
}
R.id.copy_address -> {
copyWalletAddress()
true
}
else -> super.onOptionsItemSelected(item)
}
return super.onOptionsItemSelected(item)
}

private fun copyWalletAddress() {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("peer_address", peerAddress)
clipboard.setPrimaryClip(clip)
}

private fun ensureUiState(uiState: ConversationDetailViewModel.UiState) {
binding.progress.visibility = View.GONE
when (uiState) {
is ConversationDetailViewModel.UiState.Loading -> {
if (uiState.listItems.isNullOrEmpty()) {
binding.progress.visibility = View.VISIBLE
} else {
adapter.setData(uiState.listItems)
}
}
is ConversationDetailViewModel.UiState.Success -> {
binding.refresh.isRefreshing = false
adapter.setData(uiState.listItems)
}
is ConversationDetailViewModel.UiState.Error -> {
binding.refresh.isRefreshing = false
showError(uiState.message)
}
}
}

private fun ensureSendState(sendState: ConversationDetailViewModel.SendMessageState) {
when (sendState) {
is ConversationDetailViewModel.SendMessageState.Error -> {
showError(sendState.message)
}
ConversationDetailViewModel.SendMessageState.Loading -> {
binding.sendButton.isEnabled = false
binding.messageEditText.isEnabled = false
}
ConversationDetailViewModel.SendMessageState.Success -> {
binding.messageEditText.text.clear()
binding.messageEditText.isEnabled = true
binding.sendButton.isEnabled = true
viewModel.fetchMessages()
}
}
}

private fun showError(message: String) {
val error = message.ifBlank { resources.getString(R.string.error) }
Toast.makeText(this, error, Toast.LENGTH_SHORT).show()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.xmtp.android.example.ClientManager
import org.xmtp.android.library.Conversation
import org.xmtp.android.library.DecodedMessage

class ConversationDetailViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {

Expand All @@ -26,6 +28,8 @@ class ConversationDetailViewModel(private val savedStateHandle: SavedStateHandle
private val _uiState = MutableStateFlow<UiState>(UiState.Loading(null))
val uiState: StateFlow<UiState> = _uiState

private var conversation: Conversation? = null

@UiThread
fun fetchMessages() {
when (val uiState = uiState.value) {
Expand All @@ -35,11 +39,15 @@ class ConversationDetailViewModel(private val savedStateHandle: SavedStateHandle
viewModelScope.launch(Dispatchers.IO) {
val listItems = mutableListOf<MessageListItem>()
try {
val conversation = ClientManager.client.fetchConversation(conversationTopic)
if (conversation == null) {
conversation = ClientManager.client.fetchConversation(conversationTopic)
}
conversation?.let {
it.messages().map { message ->
MessageListItem.Message(message.id, message.body)
}
listItems.addAll(
it.messages().map { message ->
MessageListItem.Message(message.id, message)
}
)
}
_uiState.value = UiState.Success(listItems)
} catch (e: Exception) {
Expand All @@ -48,18 +56,38 @@ class ConversationDetailViewModel(private val savedStateHandle: SavedStateHandle
}
}

@UiThread
fun sendMessage(body: String): StateFlow<SendMessageState> {
val flow = MutableStateFlow<SendMessageState>(SendMessageState.Loading)
viewModelScope.launch(Dispatchers.IO) {
try {
conversation?.send(body)
flow.value = SendMessageState.Success
} catch (e: Exception) {
flow.value = SendMessageState.Error(e.localizedMessage.orEmpty())
}
}
return flow
}

sealed class UiState {
data class Loading(val listItems: List<MessageListItem>?) : UiState()
data class Success(val listItems: List<MessageListItem>) : UiState()
data class Error(val message: String) : UiState()
}

sealed class SendMessageState {
object Loading : SendMessageState()
object Success : SendMessageState()
data class Error(val message: String) : SendMessageState()
}

sealed class MessageListItem(open val id: String, val itemType: Int) {
companion object {
const val ITEM_TYPE_MESSAGE = 1
}

data class Message(override val id: String, val body: String) :
data class Message(override val id: String, val message: DecodedMessage) :
MessageListItem(id, ITEM_TYPE_MESSAGE)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.xmtp.android.example.extension

fun String.truncatedAddress(): String {
if (length > 6) {
val start = 6
val end = lastIndex - 3
return replaceRange(start, end, "...")
}
return this
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package org.xmtp.android.example.extension

import android.view.View
import android.view.ViewGroup.MarginLayoutParams

fun View.margins(
left: Int = 0,
top: Int = 0,
right: Int = 0,
bottom: Int = 0
) {
val layoutParams = layoutParams as MarginLayoutParams
val marginLeft = left.let { if (it > 0) it else layoutParams.leftMargin }
val marginTop = top.let { if (it > 0) it else layoutParams.topMargin }
val marginRight = right.let { if (it > 0) it else layoutParams.rightMargin }
val marginBottom = bottom.let { if (it > 0) it else layoutParams.bottomMargin }
layoutParams.setMargins(marginLeft, marginTop, marginRight, marginBottom)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package org.xmtp.android.example.message

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.xmtp.android.example.conversation.ConversationDetailViewModel
import org.xmtp.android.example.databinding.ListItemMessageBinding

class MessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

private val listItems = mutableListOf<ConversationDetailViewModel.MessageListItem>()

fun setData(newItems: List<ConversationDetailViewModel.MessageListItem>) {
listItems.clear()
listItems.addAll(newItems)
notifyItemRangeChanged(0, newItems.size)
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
ConversationDetailViewModel.MessageListItem.ITEM_TYPE_MESSAGE -> {
val binding = ListItemMessageBinding.inflate(inflater, parent, false)
MessageViewHolder(binding)
}
else -> throw IllegalArgumentException("Unsupported view type $viewType")
}
}

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = listItems[position]
when (holder) {
is MessageViewHolder -> {
holder.bind(item as ConversationDetailViewModel.MessageListItem.Message)
}
else -> throw IllegalArgumentException("Unsupported view holder")
}
}

override fun getItemViewType(position: Int) = listItems[position].itemType

override fun getItemCount() = listItems.count()

override fun getItemId(position: Int) = listItems[position].id.hashCode().toLong()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.xmtp.android.example.message

import android.graphics.Color
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.UNSET
import androidx.recyclerview.widget.RecyclerView
import org.xmtp.android.example.ClientManager
import org.xmtp.android.example.R
import org.xmtp.android.example.conversation.ConversationDetailViewModel
import org.xmtp.android.example.databinding.ListItemMessageBinding
import org.xmtp.android.example.extension.margins

class MessageViewHolder(
private val binding: ListItemMessageBinding
) : RecyclerView.ViewHolder(binding.root) {

private val marginLarge = binding.root.resources.getDimensionPixelSize(R.dimen.message_margin)
private val marginSmall = binding.root.resources.getDimensionPixelSize(R.dimen.padding)
private val backgroundMe = Color.LTGRAY
private val backgroundPeer =
binding.root.resources.getColor(R.color.teal_700, binding.root.context.theme)

fun bind(item: ConversationDetailViewModel.MessageListItem.Message) {
val isFromMe = ClientManager.client.address == item.message.senderAddress
val params = binding.messageContainer.layoutParams as ConstraintLayout.LayoutParams
if (isFromMe) {
params.rightToRight = PARENT_ID
params.leftToLeft = UNSET
binding.messageRow.margins(left = marginLarge, right = marginSmall)
binding.messageContainer.setCardBackgroundColor(backgroundMe)
binding.messageBody.setTextColor(Color.BLACK)
} else {
params.leftToLeft = PARENT_ID
params.rightToRight = UNSET
binding.messageRow.margins(right = marginLarge, left = marginSmall)
binding.messageContainer.setCardBackgroundColor(backgroundPeer)
binding.messageBody.setTextColor(Color.WHITE)
}
binding.messageBody.text = item.message.body
}
}
5 changes: 5 additions & 0 deletions example/src/main/res/drawable/ic_send_24.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:tint="#000000" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M2.01,21L23,12 2.01,3 2,10l15,2 -15,2z"/>
</vector>
Loading

0 comments on commit 6e68384

Please sign in to comment.