feat(chat): DM 채팅방 ViewModel을 추가한다
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
package kr.co.vividnext.sodalive.v2.main.chat.dm
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.orhanobut.logger.Logger
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kr.co.vividnext.sodalive.base.BaseViewModel
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessagesPageResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRoomOpenResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmChatMessageResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageUiItem
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.mergeByMessageId
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.sortByCreatedAtAndMessageId
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItem
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItems
|
||||
|
||||
class DmChatRoomViewModel(
|
||||
private val repository: DmChatRepository
|
||||
) : BaseViewModel() {
|
||||
|
||||
private var currentRoomId: Long = 0L
|
||||
private var opponentNickname: String = ""
|
||||
private var opponentProfileImageUrl: String = ""
|
||||
private var currentMessages: List<DmChatMessageUiItem> = emptyList()
|
||||
private var hasMore: Boolean = false
|
||||
private var nextCursor: Long? = null
|
||||
private var isLoadingOlder: Boolean = false
|
||||
private var isSending: Boolean = false
|
||||
private var localMessageSequence: Long = 0L
|
||||
|
||||
private val _chatRoomStateLiveData = MutableLiveData<DmChatRoomUiState>()
|
||||
val chatRoomStateLiveData: LiveData<DmChatRoomUiState>
|
||||
get() = _chatRoomStateLiveData
|
||||
|
||||
private val _finishEventLiveData = MutableLiveData<Boolean>()
|
||||
val finishEventLiveData: LiveData<Boolean>
|
||||
get() = _finishEventLiveData
|
||||
|
||||
private val _prependedMessageCountLiveData = MutableLiveData(0)
|
||||
val prependedMessageCountLiveData: LiveData<Int>
|
||||
get() = _prependedMessageCountLiveData
|
||||
|
||||
fun enter(roomId: Long, creatorId: Long) {
|
||||
when {
|
||||
roomId > 0L -> openRoom(roomId)
|
||||
creatorId > 0L -> createRoomAndOpen(creatorId)
|
||||
else -> _finishEventLiveData.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun loadOlderMessages() {
|
||||
val roomId = currentRoomId
|
||||
val cursor = nextCursor
|
||||
if (roomId <= 0L || !hasMore || cursor == null || isLoadingOlder) return
|
||||
|
||||
isLoadingOlder = true
|
||||
emitContent()
|
||||
|
||||
compositeDisposable.add(
|
||||
repository.getMessages(
|
||||
token = authToken(),
|
||||
roomId = roomId,
|
||||
cursor = cursor
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ handleOlderMessagesResult(it) },
|
||||
{
|
||||
isLoadingOlder = false
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
emitContent()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun sendText(text: String) {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isBlank() || currentRoomId <= 0L || isSending) return
|
||||
|
||||
val localId = nextLocalId()
|
||||
val localItem = DmChatMessageUiItem(
|
||||
messageId = null,
|
||||
localId = localId,
|
||||
mine = true,
|
||||
textMessage = trimmed,
|
||||
senderNickname = "",
|
||||
senderProfileImageUrl = "",
|
||||
createdAt = System.currentTimeMillis(),
|
||||
status = DmChatMessageStatus.SENDING
|
||||
)
|
||||
currentMessages = currentMessages + localItem
|
||||
isSending = true
|
||||
emitContent()
|
||||
|
||||
sendLocalMessage(localId = localId, text = trimmed)
|
||||
}
|
||||
|
||||
fun retry(localId: String) {
|
||||
val failedItem = currentMessages.firstOrNull {
|
||||
it.localId == localId && it.status == DmChatMessageStatus.FAILED
|
||||
} ?: return
|
||||
if (isSending || currentRoomId <= 0L) return
|
||||
|
||||
currentMessages = currentMessages.map {
|
||||
if (it.localId == localId) it.copy(status = DmChatMessageStatus.SENDING) else it
|
||||
}
|
||||
isSending = true
|
||||
emitContent()
|
||||
|
||||
sendLocalMessage(localId = localId, text = failedItem.textMessage)
|
||||
}
|
||||
|
||||
fun onRealtimeMessage(message: DmChatMessageResponse) {
|
||||
val item = message.toUiItem() ?: return
|
||||
currentMessages = currentMessages.mergeByMessageId(listOf(item))
|
||||
emitContent()
|
||||
}
|
||||
|
||||
fun syncLatestMessagesAfterReconnect() {
|
||||
val roomId = currentRoomId
|
||||
if (roomId <= 0L) return
|
||||
|
||||
compositeDisposable.add(
|
||||
repository.getMessages(
|
||||
token = authToken(),
|
||||
roomId = roomId,
|
||||
cursor = null
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
val data = it.data
|
||||
if (it.success && data != null) {
|
||||
currentMessages = currentMessages.mergeByMessageId(data.messages.toUiItems())
|
||||
emitContent()
|
||||
}
|
||||
},
|
||||
{ it.message?.let { message -> Logger.e(message) } }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createRoomAndOpen(creatorId: Long) {
|
||||
_chatRoomStateLiveData.value = DmChatRoomUiState.Loading
|
||||
compositeDisposable.add(
|
||||
repository.createOrGetRoom(token = authToken(), creatorId = creatorId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.flatMap { response ->
|
||||
val data = response.requireData()
|
||||
repository.openRoom(token = authToken(), roomId = data.roomId)
|
||||
}
|
||||
.subscribe(
|
||||
{ handleOpenRoomResult(it) },
|
||||
{ handleError(it) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun openRoom(roomId: Long) {
|
||||
_chatRoomStateLiveData.value = DmChatRoomUiState.Loading
|
||||
compositeDisposable.add(
|
||||
repository.openRoom(token = authToken(), roomId = roomId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ handleOpenRoomResult(it) },
|
||||
{ handleError(it) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendLocalMessage(localId: String, text: String) {
|
||||
compositeDisposable.add(
|
||||
repository.sendTextMessage(
|
||||
token = authToken(),
|
||||
roomId = currentRoomId,
|
||||
textMessage = text
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ handleSendResult(localId, it) },
|
||||
{
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
markLocalMessageFailed(localId)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleOpenRoomResult(response: ApiResponse<DmChatRoomOpenResponse>) {
|
||||
val data = response.data
|
||||
if (!response.success || data == null) {
|
||||
showError(response.message)
|
||||
return
|
||||
}
|
||||
|
||||
currentRoomId = data.roomId
|
||||
opponentNickname = data.opponentNickname
|
||||
opponentProfileImageUrl = data.opponentProfileImageUrl
|
||||
currentMessages = data.messages.toUiItems().sortByCreatedAtAndMessageId()
|
||||
hasMore = data.hasMore
|
||||
nextCursor = data.nextCursor
|
||||
isLoadingOlder = false
|
||||
emitContent()
|
||||
}
|
||||
|
||||
private fun handleOlderMessagesResult(response: ApiResponse<DmChatMessagesPageResponse>) {
|
||||
isLoadingOlder = false
|
||||
val data = response.data
|
||||
if (!response.success || data == null) {
|
||||
emitContent()
|
||||
return
|
||||
}
|
||||
|
||||
val beforeIds = currentMessages.mapNotNull { it.messageId }.toSet()
|
||||
currentMessages = currentMessages.mergeByMessageId(data.messages.toUiItems())
|
||||
hasMore = data.hasMore
|
||||
nextCursor = data.nextCursor
|
||||
_prependedMessageCountLiveData.value = currentMessages.count {
|
||||
it.messageId != null && it.messageId !in beforeIds
|
||||
}
|
||||
emitContent()
|
||||
}
|
||||
|
||||
private fun handleSendResult(
|
||||
localId: String,
|
||||
response: ApiResponse<SendDmChatMessageResponse>
|
||||
) {
|
||||
val message = response.data?.message
|
||||
val sentItem = if (response.success && message != null) message.toUiItem() else null
|
||||
if (sentItem == null) {
|
||||
markLocalMessageFailed(localId)
|
||||
return
|
||||
}
|
||||
|
||||
isSending = false
|
||||
currentMessages = currentMessages.map {
|
||||
if (it.localId == localId) sentItem else it
|
||||
}.deduplicateSentMessage(sentItem.messageId).sortByCreatedAtAndMessageId()
|
||||
emitContent()
|
||||
}
|
||||
|
||||
private fun List<DmChatMessageUiItem>.deduplicateSentMessage(messageId: Long?): List<DmChatMessageUiItem> {
|
||||
if (messageId == null) return this
|
||||
var found = false
|
||||
return filter { item ->
|
||||
if (item.messageId != messageId) return@filter true
|
||||
if (found) return@filter false
|
||||
found = true
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun markLocalMessageFailed(localId: String) {
|
||||
isSending = false
|
||||
currentMessages = currentMessages.map {
|
||||
if (it.localId == localId) it.copy(status = DmChatMessageStatus.FAILED) else it
|
||||
}
|
||||
emitContent()
|
||||
}
|
||||
|
||||
private fun handleError(throwable: Throwable) {
|
||||
throwable.message?.let { Logger.e(it) }
|
||||
showError(throwable.message)
|
||||
}
|
||||
|
||||
private fun showError(message: String?) {
|
||||
_chatRoomStateLiveData.value = DmChatRoomUiState.Error(message)
|
||||
}
|
||||
|
||||
private fun emitContent() {
|
||||
if (currentRoomId <= 0L) return
|
||||
_chatRoomStateLiveData.value = DmChatRoomUiState.Content(
|
||||
roomId = currentRoomId,
|
||||
opponentNickname = opponentNickname,
|
||||
opponentProfileImageUrl = opponentProfileImageUrl,
|
||||
messages = currentMessages,
|
||||
hasMore = hasMore,
|
||||
nextCursor = nextCursor,
|
||||
isLoadingOlder = isLoadingOlder
|
||||
)
|
||||
}
|
||||
|
||||
private fun nextLocalId(): String {
|
||||
localMessageSequence += 1L
|
||||
return "local-$localMessageSequence"
|
||||
}
|
||||
|
||||
private fun authToken(): String = SharedPreferenceManager.token
|
||||
|
||||
private fun ApiResponse<CreateDmChatRoomResponse>.requireData(): CreateDmChatRoomResponse {
|
||||
if (success && data != null) return data
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,19 @@ data class DmChatMessageUiItem(
|
||||
val createdAt: Long,
|
||||
val status: DmChatMessageStatus
|
||||
)
|
||||
|
||||
sealed class DmChatRoomUiState {
|
||||
data object Loading : DmChatRoomUiState()
|
||||
|
||||
data class Content(
|
||||
val roomId: Long,
|
||||
val opponentNickname: String,
|
||||
val opponentProfileImageUrl: String,
|
||||
val messages: List<DmChatMessageUiItem>,
|
||||
val hasMore: Boolean,
|
||||
val nextCursor: Long?,
|
||||
val isLoadingOlder: Boolean = false
|
||||
) : DmChatRoomUiState()
|
||||
|
||||
data class Error(val message: String?) : DmChatRoomUiState()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user