feat(dm): 메시지 전송 pending을 requestId로 관리한다

This commit is contained in:
2026-06-18 19:09:40 +09:00
parent e640ee6c46
commit 2c1eb03e5f
5 changed files with 193 additions and 28 deletions

View File

@@ -41,7 +41,6 @@ class DmChatRoomViewModel(
private var hasMore: Boolean = false
private var nextCursor: Long? = null
private var isLoadingOlder: Boolean = false
private var isSending: Boolean = false
private var isRealtimeJoining: Boolean = false
private var isRealtimeConnected: Boolean = false
private var shouldReconnectRealtime: Boolean = false
@@ -50,6 +49,9 @@ class DmChatRoomViewModel(
private var currentRealtimeRoomId: Long = 0L
private var reconnectDisposable: Disposable? = null
private var localMessageSequence: Long = 0L
private var requestSequence: Long = 0L
private val pendingRequestLocalIds = mutableMapOf<String, String>()
private val pendingTimeoutDisposables = mutableMapOf<String, Disposable>()
private val mainHandler = Handler(Looper.getMainLooper())
private val _chatRoomStateLiveData = MutableLiveData<DmChatRoomUiState>()
@@ -105,12 +107,14 @@ class DmChatRoomViewModel(
fun sendText(text: String) {
val trimmed = text.trim()
if (trimmed.isBlank() || currentRoomId <= 0L || isSending) return
if (trimmed.isBlank() || currentRoomId <= 0L) return
val localId = nextLocalId()
val requestId = nextRequestId()
val localItem = DmChatMessageUiItem(
messageId = null,
localId = localId,
requestId = requestId,
mine = true,
textMessage = trimmed,
senderNickname = "",
@@ -119,25 +123,28 @@ class DmChatRoomViewModel(
status = DmChatMessageStatus.SENDING
)
currentMessages = currentMessages + localItem
isSending = true
pendingRequestLocalIds[requestId] = localId
schedulePendingTimeout(requestId)
emitContent()
sendLocalMessage(localId = localId, text = trimmed)
sendLocalMessage(requestId = requestId, text = trimmed)
}
fun retry(localId: String) {
val failedItem = currentMessages.firstOrNull {
it.localId == localId && it.status == DmChatMessageStatus.FAILED
} ?: return
if (isSending || currentRoomId <= 0L) return
if (currentRoomId <= 0L) return
val requestId = nextRequestId()
currentMessages = currentMessages.map {
if (it.localId == localId) it.copy(status = DmChatMessageStatus.SENDING) else it
if (it.localId == localId) it.copy(requestId = requestId, status = DmChatMessageStatus.SENDING) else it
}
isSending = true
pendingRequestLocalIds[requestId] = localId
schedulePendingTimeout(requestId)
emitContent()
sendLocalMessage(localId = localId, text = failedItem.textMessage)
sendLocalMessage(requestId = requestId, text = failedItem.textMessage)
}
fun onRealtimeMessage(message: DmChatMessageResponse) {
@@ -294,13 +301,24 @@ class DmChatRoomViewModel(
)
}
private fun sendLocalMessage(localId: String, text: String) {
private fun sendLocalMessage(requestId: String, text: String) {
val sent = repository.sendSocketText(
roomId = currentRoomId,
requestId = localId,
requestId = requestId,
textMessage = text
)
if (!sent) markLocalMessageFailed(localId)
if (!sent) markPendingMessageFailed(requestId)
}
private fun schedulePendingTimeout(requestId: String) {
pendingTimeoutDisposables[requestId]?.dispose()
val disposable = reconnectScheduler.scheduleDirect(
{ scheduleRealtimeCallback { markPendingMessageFailed(requestId) } },
SEND_ACK_TIMEOUT_MILLIS,
TimeUnit.MILLISECONDS
)
pendingTimeoutDisposables[requestId] = disposable
compositeDisposable.add(disposable)
}
private fun handleOpenRoomResult(response: ApiResponse<DmChatRoomOpenResponse>) {
@@ -348,21 +366,22 @@ class DmChatRoomViewModel(
}
is DmChatSocketEvent.Message -> onRealtimeMessage(event.message)
is DmChatSocketEvent.SendAck -> handleSendAck(event.requestId, event.message)
is DmChatSocketEvent.Error -> event.requestId?.let { markLocalMessageFailed(it) }
is DmChatSocketEvent.Error -> event.requestId?.let { markPendingMessageFailed(it) }
DmChatSocketEvent.Pong -> Unit
}
}
private fun handleSendAck(localId: String, message: DmChatMessageResponse) {
private fun handleSendAck(requestId: String, message: DmChatMessageResponse) {
val localId = pendingRequestLocalIds.remove(requestId) ?: return
pendingTimeoutDisposables.remove(requestId)?.dispose()
val sentItem = message.toUiItem()
if (sentItem == null) {
markLocalMessageFailed(localId)
return
}
isSending = false
currentMessages = currentMessages.map {
if (it.localId == localId) sentItem else it
if (it.localId == localId) sentItem.copy(localId = localId) else it
}.deduplicateSentMessage(sentItem.messageId).sortByCreatedAtAndMessageId()
emitContent()
}
@@ -378,8 +397,13 @@ class DmChatRoomViewModel(
}
}
private fun markPendingMessageFailed(requestId: String) {
val localId = pendingRequestLocalIds.remove(requestId) ?: return
pendingTimeoutDisposables.remove(requestId)?.dispose()
markLocalMessageFailed(localId)
}
private fun markLocalMessageFailed(localId: String) {
isSending = false
currentMessages = currentMessages.map {
if (it.localId == localId) it.copy(status = DmChatMessageStatus.FAILED) else it
}
@@ -413,6 +437,11 @@ class DmChatRoomViewModel(
return "local-$localMessageSequence"
}
private fun nextRequestId(): String {
requestSequence += 1L
return "request-$requestSequence"
}
private fun authToken(): String {
val token = tokenProvider()
if (token.isNotBlank()) currentAuthToken = token
@@ -426,6 +455,7 @@ class DmChatRoomViewModel(
private companion object {
const val RECONNECT_DELAY_MILLIS = 3_000L
const val SEND_ACK_TIMEOUT_MILLIS = 10_000L
}
}

View File

@@ -10,6 +10,7 @@ fun DmChatMessageResponse.toUiItem(): DmChatMessageUiItem? {
return DmChatMessageUiItem(
messageId = messageId,
localId = null,
requestId = null,
mine = mine,
textMessage = textMessage,
senderNickname = senderNickname,

View File

@@ -9,6 +9,7 @@ enum class DmChatMessageStatus {
data class DmChatMessageUiItem(
val messageId: Long?,
val localId: String?,
val requestId: String?,
val mine: Boolean,
val textMessage: String,
val senderNickname: String,