Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9161be7
fix: 캐릭터 말풍선 클릭을 롱클릭으로 변경
seunghee17 Sep 22, 2026
e3c4c43
feat: 메시지 전송 실패 시 재전송 UI 지원
seunghee17 Sep 22, 2026
eb1a5b6
fix: 채팅 메시지 진입 애니메이션 상태 재계산 방지
seunghee17 Sep 25, 2026
43a13ff
fix: 채팅 메시지 애니메이션 및 스크롤 동작 개선
seunghee17 Sep 27, 2026
e1a54db
fix: 채팅 메시지 전환 애니메이션과 스크롤 동작 안정화
seunghee17 Sep 28, 2026
e44d0c4
fix: 채팅 새 메시지 등장 성능 개선
seunghee17 Sep 28, 2026
2bee647
fix: 메시지 전송 실패 시 입력 상태를 복구
seunghee17 Sep 28, 2026
0c87f27
fix: detekt ReturnCount 위반 수정
seunghee17 Sep 28, 2026
9747e2a
fix: 채팅방 상태 스레드 안전성 및 위험 감지 차단 테스트 보강
seunghee17 Sep 28, 2026
d4b6001
fix: 화면 밖 새 메시지의 지연 등장 애니메이션 방지
seunghee17 Sep 29, 2026
d23e177
fix: 채팅방 입력 및 스크롤 상태 복구 오류 수정
seunghee17 Sep 29, 2026
4c2897d
fix: 최신 답장 기준으로 새 메시지 토스트 표시
seunghee17 Sep 29, 2026
7dfefc1
Merge remote-tracking branch 'origin/fix/chatting-room' into fix/chat…
seunghee17 Sep 29, 2026
ff8d3db
fix: 채팅방 메시지 전송 시 위험 감지 검사 후 말풍선을 그리도록 순서 변경
seunghee17 Sep 29, 2026
f7dbeeb
fix: 전송 실패 시 최신 입력 보존
seunghee17 Sep 30, 2026
528a164
Merge branch 'develop' of https://github.com/Nexters/GAMSS-Android in…
seunghee17 Sep 30, 2026
b688f54
test: 위험 용어 저장소 주입 방식에 맞게 채팅방 테스트 수정
seunghee17 Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,20 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -81,6 +89,13 @@ fun GamssInputBar(
replyClearContentDescription: String? = null,
) {
val canSubmit = sendEnabled && value.isNotBlank()

// 문자열 API 의 BasicTextField 는 밖에서 글자를 통째로 바꾸면 이전 커서 위치를 그대로 둔다 — 비웠다가
// 복구하면 커서가 맨 앞에 남는다. 커서까지 직접 들고, 입력창이 가진 글자와 다른 값이 들어오면(비우기·
// 복구·글자 수 제한 등 밖에서 바꾼 경우) 커서를 끝에 둔다. 직접 타이핑하면 글자가 같아 커서·한글 조합
// 상태가 그대로 유지된다.
var fieldValue by rememberInputFieldValue(value)
val shownValue = if (fieldValue.text == value) fieldValue else value.toCursorAtEnd()
val verticalPadding = if (replyQuote != null) InputBarReplyVerticalPadding else 0.dp

Column(
Expand Down Expand Up @@ -108,8 +123,12 @@ fun GamssInputBar(
verticalAlignment = Alignment.CenterVertically,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
value = shownValue,
onValueChange = { newValue ->
fieldValue = newValue
// 커서만 옮긴 경우는 올려보내지 않는다.
if (newValue.text != value) onValueChange(newValue.text)
},
modifier = Modifier
.weight(1f)
.let { if (replyQuote == null) it.padding(vertical = InputBarVerticalPadding) else it },
Expand Down Expand Up @@ -157,6 +176,28 @@ fun GamssInputBar(
}
}

/**
* 입력창이 들고 있는 [TextFieldValue]. 밖에서 [value]가 바뀌면 이 값에도 저장해 둔다 — 그러지 않으면 비울 때
* 전송 직전 값이 남아, 전송 실패로 같은 글자가 복구되면 글자가 같다고 보고 예전 커서·한글 조합 범위를 되살린다.
*
* [value]가 바뀐 때만 맞춘다. 타이핑 직후엔 [value]가 한 박자 늦게 따라오므로, 글자가 다르다는 이유만으로
* 덮으면 방금 친 글자와 조합 상태를 옛 값으로 되돌린다.
*/
@Composable
private fun rememberInputFieldValue(value: String): MutableState<TextFieldValue> {
val fieldValue = remember { mutableStateOf(value.toCursorAtEnd()) }
var lastSyncedValue by remember { mutableStateOf(value) }
SideEffect {
if (value != lastSyncedValue) {
lastSyncedValue = value
if (fieldValue.value.text != value) fieldValue.value = value.toCursorAtEnd()
}
}
return fieldValue
}

private fun String.toCursorAtEnd(): TextFieldValue = TextFieldValue(this, TextRange(length))

@Composable
private fun ReplyPreviewRow(
replyQuote: ChatReplyQuote,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import com.gamss.android.core.designsystem.component.chat.ChatSender
import com.gamss.android.core.designsystem.component.chat.GamssChatBubbleDefaults
import com.gamss.android.core.designsystem.component.chat.GamssReceivedChatBubble
import com.gamss.android.core.designsystem.component.chat.GamssSentChatBubble
import com.gamss.android.core.designsystem.modifier.noRippleClickableIfNotNull
import com.gamss.android.core.designsystem.modifier.noRippleCombinedClickable
import com.gamss.android.domain.conversation.Message
import com.gamss.android.domain.conversation.MessageSender

Expand All @@ -22,7 +22,7 @@ import com.gamss.android.domain.conversation.MessageSender
* @param replyQuote 호출부(리스트)가 [rememberReplyQuoteLookup] 으로 미리 찾아 넘긴다. 이 버블은
* 메시지 목록 전체를 몰라도 되고, 목록이 커져도 이 값이 그대로면 재구성을 건너뛴다.
* @param oppositeWallGap 폭이 좁은 카드 안에서는 [GamssChatBubbleDefaults.CardOppositeWallGap] 을 넘긴다.
* @param onCharacterMessageClick 넘기지 않으면 말풍선이 눌리지 않는다.
* @param onCharacterMessageClick 넘기지 않으면 말풍선이 눌리지 않는다. 길게 눌렀을 때 호출된다.
*/
@Composable
fun ChatMessageBubble(
Expand Down Expand Up @@ -55,8 +55,9 @@ fun ChatMessageBubble(
oppositeWallGap = oppositeWallGap,
modifier = Modifier
.align(Alignment.CenterStart)
.noRippleClickableIfNotNull(
onCharacterMessageClick?.let { onClick -> { onClick(message) } },
.noRippleCombinedClickable(
onClick = null,
onLongClick = onCharacterMessageClick?.let { onClick -> { onClick(message) } },
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,16 @@ private fun ChatRoomContent(
) {
val listState = rememberLazyListState()
val focusManager = LocalFocusManager.current
val messageAnimationState = rememberChatMessageAnimationState(
val animationState = rememberChatMessageAnimationState(
conversationId = state.conversationId,
isLoading = state.isLoading,
messageIds = state.messages.map(Message::id),
initialMessageIds = { state.messages.map(state::listKeyOf) },
)
val chatScrollState = rememberChatScrollState(
state = state,
listState = listState,
animationState = animationState,
)
val chatScrollState = rememberChatScrollState(state = state, listState = listState)

// WindowInsets.ime 게터 자체가 @Composable이라 LaunchedEffect(코루틴) 안에서 직접 부를 수
// 없다. 여기서 객체 참조만 한 번 얻어두면, 이후 getBottom() 호출은 일반 함수 호출이라 코루틴
Expand Down Expand Up @@ -278,8 +282,8 @@ private fun ChatRoomContent(
state = state,
actions = actions,
listState = listState,
animationState = messageAnimationState,
scrollState = chatScrollState,
animationState = animationState,
showScrollToBottomButton = chatScrollState.showScrollToBottomButton && !isImeInTransition,
modifier = Modifier
.weight(1f)
Expand Down Expand Up @@ -390,13 +394,15 @@ private fun ChatMessageList(
state: ChatRoomState,
actions: ChatRoomActions,
listState: LazyListState,
animationState: ChatMessageAnimation,
scrollState: ChatScrollState,
animationState: ChatMessageAnimation,
showScrollToBottomButton: Boolean,
modifier: Modifier = Modifier,
) {
val replyQuotes = rememberReplyQuoteLookup(state.messages)

val loadingPlaceholder = state.loadingPlaceholder

Box(modifier = modifier) {
LazyColumn(
state = listState,
Expand All @@ -422,25 +428,23 @@ private fun ChatMessageList(
}
}
}
items(state.messages, key = { it.id }) { message ->
val replyQuote = replyQuotes.quoteFor(message)
items(state.displayMessages, key = state::listKeyOf) { message ->
AnimatedChatMessage(
messageId = message.id,
shouldAnimate = animationState.shouldAnimate(message.id),
listState = listState,
messageId = state.listKeyOf(message),
animationState = animationState,
) {
ChatMessageBubble(
message = message,
replyQuote = replyQuote,
onCharacterMessageClick = actions.onCharacterMessageClick,
)
if (message.id == loadingPlaceholder?.id) {
val character = (message.sender as? MessageSender.Character)?.character
LoadingMessageBubble(character = character)
} else {
ChatMessageBubble(
message = message,
replyQuote = replyQuotes.quoteFor(message),
onCharacterMessageClick = actions.onCharacterMessageClick,
)
}
}
}
if (state.isAwaitingComments) {
val nextCharacter = (state.pendingComments.firstOrNull()?.sender as? MessageSender.Character)
?.character
item { LoadingMessageBubble(character = nextCharacter) }
}
}

scrollState.newMessageToast?.let { toastMessage ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ data class ChatRoomState(
val conversationId: Long? = null,
val conversationCreatedAt: LocalDateTime? = null,
val messages: List<Message> = emptyList(),
/** 서버 id로 교체된 내 메시지의 서버 id → 임시 id. [listKeyOf]가 교체 전후 같은 key를 쓰게 한다. */
val localKeyByMessageId: Map<Long, Long> = emptyMap(),
val pendingComments: List<Message> = emptyList(),
val input: String = "",
val replyTarget: ReplyTarget? = null,
Expand Down Expand Up @@ -44,6 +46,20 @@ data class ChatRoomState(
messages.any { it.sender == MessageSender.User }

val canEnd: Boolean get() = endFlow.acceptsEndRequest && endPreconditionsMet

/**
* 다음 답장의 로딩 표시. 그 답장과 같은 id라, 공개되는 순간 같은 아이템에서 내용만 로딩 → 실제
* 글자로 바뀐다.
*/
val loadingPlaceholder: Message? get() = pendingComments.firstOrNull()

/** 화면에 그리는 목록. [messages] 끝에 [loadingPlaceholder]를 붙인다. Compose(메인 스레드)에서만 읽는다. */
val displayMessages: List<Message> by lazy(LazyThreadSafetyMode.NONE) {
loadingPlaceholder?.let { messages + it } ?: messages
}

/** 목록 key. 임시 id → 서버 id로 바뀌어도 같은 아이템으로 남아 진입 애니메이션이 다시 돌지 않는다. */
fun listKeyOf(message: Message): Long = localKeyByMessageId[message.id] ?: message.id
}

data class ReplyTarget(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.gamss.android.feature.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.gamss.android.core.common.AppResult
import com.gamss.android.core.common.util.formatKoreanTime
import com.gamss.android.domain.card.CardNotRetryableException
import com.gamss.android.domain.conversation.CommentGenerationStatus
import com.gamss.android.domain.conversation.ConversationSession
Expand All @@ -23,6 +24,8 @@ import org.orbitmvi.orbit.ContainerHost
import org.orbitmvi.orbit.blockingIntent
import org.orbitmvi.orbit.syntax.Syntax
import org.orbitmvi.orbit.viewmodel.container
import java.time.LocalDateTime
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject

private typealias ChatRoomSyntax = Syntax<ChatRoomState, ChatRoomSideEffect>
Expand All @@ -47,6 +50,9 @@ class ChatRoomViewModel @Inject constructor(
@Volatile
private var tokenAlertJob: Job? = null

/** 서버 응답 전에 먼저 그리는 내 메시지의 임시 id. 서버 id(양수)와 겹치지 않게 음수를 쓴다. */
private val localMessageIdCounter = AtomicLong(-1)

fun start(conversationId: Long) {
if (started) return
started = true
Expand Down Expand Up @@ -210,34 +216,56 @@ class ChatRoomViewModel @Inject constructor(
} else {
null
}
// 서버 왕복이 끝날 때까지 기다리지 않고 버튼을 누른 즉시 입력칸을 비운다. 아래
// 위험 신호 차단·전송 실패 분기에서 되돌리지 않는 한 이 상태로 남는다.
if (pending == null) state else state.copy(isSending = true, input = "")
// 중복 전송만 먼저 막아 둔다. 말풍선은 아래 위험 신호 검사가 끝난 뒤에 그린다.
if (pending == null) state else state.copy(isSending = true)
}
val sending = pending ?: return@intent
val localId = localMessageIdCounter.getAndDecrement()

// session.send() 전에 고정된 메시지 내용으로 위험 신호 검사
// 말풍선을 그리기 전에 고정된 메시지 내용으로 위험 신호부터 검사한다. 검사가 끝나기 전엔
// 화면에 아무것도 그리지 않아, CRITICAL 문구가 잠깐이라도 보이는 일이 없다.
val detection = detectRiskInText(sending.content)
if (detection.level != RiskLevel.NONE) {
reduce {
state.copy(
riskDetection = detection,
// CRITICAL이면 전송을 중단하므로 다시 전송 가능한 상태로 복구
isSending = if (detection.shouldBlock) {
false
} else {
state.isSending
},
// 방금 비운 입력칸을 되돌린다. 그 사이 사용자가 새로 타이핑했다면 덮어쓰지 않는다.
input = if (detection.shouldBlock && state.input.isEmpty()) sending.content else state.input,
)
}
if (detection.shouldBlock) {
reduce { state.copy(isSending = false, riskDetection = detection) }
return@intent
}

if (detection.shouldBlock) {
return@intent
}
reduce {
state.copy(
// 검사하는 동안 사용자가 새로 타이핑했다면 입력칸은 덮어쓰지 않는다.
input = if (state.input == sending.content) "" else state.input,
messages = state.messages + state.buildOptimisticMessage(sending, localId),
riskDetection = if (detection.level != RiskLevel.NONE) detection else state.riskDetection,
)
}

awaitSendResult(sending, localId)
}

private fun ChatRoomState.buildOptimisticMessage(sending: PendingSend, localId: Long): Message = Message(
id = localId,
conversationId = conversationId ?: UNASSIGNED_CONVERSATION_ID,
sender = MessageSender.User,
content = sending.content,
repliesToMessageId = sending.replyToMessageId,
createdTime = formatKoreanTime(LocalDateTime.now()),
)

/**
* 전송 직전 상태로 되돌린다. 먼저 그려 둔 말풍선을 지우고 보낸 내용을 입력칸에 복구한다. 그 사이
* 사용자가 새로 타이핑했다면 입력칸은 덮어쓰지 않는다. 답장 대상은 성공할 때만 지우므로 그대로 남는다.
*/
private fun ChatRoomState.revertOptimisticSend(sending: PendingSend, localId: Long): ChatRoomState = copy(
isSending = false,
messages = messages.filterNot { it.id == localId },
input = input.ifEmpty { sending.content },
)

/**
* 먼저 그려 둔 말풍선([localId])에 전송 결과를 반영한다. 성공하면 서버 메시지로 바꾸고,
* 실패하면 전송 직전 상태로 되돌린다.
*/
private suspend fun ChatRoomSyntax.awaitSendResult(sending: PendingSend, localId: Long) {
val result = session.send(
conversationId = state.conversationId,
content = sending.content,
Expand All @@ -252,7 +280,8 @@ class ChatRoomViewModel @Inject constructor(
state.copy(
isSending = false,
conversationId = sent.message.conversationId,
messages = state.messages + sent.message,
messages = state.messages.map { if (it.id == localId) sent.message else it },
localKeyByMessageId = state.localKeyByMessageId + (sent.message.id to localId),
pendingComments = sent.comments,
replyTarget = state.replyTarget.takeIf { it?.messageId != sending.replyToMessageId },
)
Expand All @@ -263,13 +292,7 @@ class ChatRoomViewModel @Inject constructor(
refreshTokenUsageInBackground()
}
is AppResult.Failure -> {
reduce {
state.copy(
isSending = false,
// 방금 비운 입력칸을 되돌린다. 그 사이 사용자가 새로 타이핑했다면 덮어쓰지 않는다.
input = if (state.input.isEmpty()) sending.content else state.input,
)
}
reduce { state.revertOptimisticSend(sending, localId) }
postSideEffect(ChatRoomSideEffect.ShowToast(SEND_FAILED))
}
}
Expand Down Expand Up @@ -407,6 +430,8 @@ class ChatRoomViewModel @Inject constructor(
)

private companion object {
// 첫 메시지 전송이 성공하기 전까지 임시 말풍선에 넣어 둘 conversationId.
const val UNASSIGNED_CONVERSATION_ID = -1L
const val LOAD_FAILED = "대화를 불러오지 못했어요"
const val SEND_FAILED = "메시지를 보내지 못했어요"
const val END_FAILED = "대화를 끝내지 못했어요"
Expand Down
Loading
Loading