feat(chat): DM 채팅방 ViewModel을 추가한다

This commit is contained in:
2026-06-10 18:48:27 +09:00
parent 406c377d13
commit 56f110c548
4 changed files with 931 additions and 0 deletions

View File

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

View File

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