diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt index 7ee7b625..b6d934aa 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt @@ -17,6 +17,7 @@ import androidx.annotation.LayoutRes import androidx.annotation.VisibleForTesting import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.DiffUtil import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.databinding.ItemChatAiMessageBinding import kr.co.vividnext.sodalive.databinding.ItemChatTypingIndicatorBinding @@ -81,6 +82,27 @@ class ChatMessageAdapter : RecyclerView.Adapter() { private val items: MutableList = mutableListOf() + private fun getStableIdFor(item: ChatListItem): Long { + return when (item) { + is ChatListItem.UserMessage -> { + val data = item.data + if (data.messageId != 0L) data.messageId + else (data.localId?.hashCode()?.toLong() ?: ("user:" + data.createdAt + data.message.hashCode()).hashCode().toLong()) + } + is ChatListItem.AiMessage -> { + val data = item.data + if (data.messageId != 0L) data.messageId + else (data.localId?.hashCode()?.toLong() ?: ("ai:" + data.createdAt + data.message.hashCode()).hashCode().toLong()) + } + is ChatListItem.Notice -> ("notice:" + item.text).hashCode().toLong() + is ChatListItem.TypingIndicator -> Long.MIN_VALUE // 고정 ID + } + } + + override fun getItemId(position: Int): Long { + return getStableIdFor(items[position]) + } + // Notice 접기 상태 (간단 전역 토글) private var isNoticeCollapsed: Boolean = false @@ -117,9 +139,33 @@ class ChatMessageAdapter : RecyclerView.Adapter() { @SuppressLint("NotifyDataSetChanged") fun setItems(newItems: List) { + // 하위 호환용: DiffUtil을 사용해 변경분만 반영 + submitList(newItems) + } + + /** + * DiffUtil을 사용해 최소 변경만 반영하는 submitList + */ + fun submitList(newItems: List) { + val old = items.toList() + val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int = old.size + override fun getNewListSize(): Int = newItems.size + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val o = old[oldItemPosition] + val n = newItems[newItemPosition] + // 안정 ID 기준 비교와 동일한 로직 적용 + return getStableIdFor(o) == getStableIdFor(n) + } + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val o = old[oldItemPosition] + val n = newItems[newItemPosition] + return o == n + } + }) items.clear() items.addAll(newItems) - notifyDataSetChanged() + diff.dispatchUpdatesTo(this) } /** @@ -322,7 +368,12 @@ class ChatMessageAdapter : RecyclerView.Adapter() { // 프로필 이미지 로딩 (공용 유틸 + 둥근 모서리 적용) if (binding.ivProfile.isVisible) { - loadProfileImage(binding.ivProfile, data.profileImageUrl) + val oldUrl = binding.ivProfile.tag as? String + val newUrl = data.profileImageUrl + if (oldUrl != newUrl) { + loadProfileImage(binding.ivProfile, newUrl) + binding.ivProfile.tag = newUrl + } } // 그룹 내부 간격 최소화 (상단 패딩 축소) @@ -420,6 +471,12 @@ class ChatMessageAdapter : RecyclerView.Adapter() { } // endregion + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + // RecyclerView와 연결된 시점에 안정 ID 활성화 (JVM 테스트에서 NPE 회피) + setHasStableIds(true) + } + override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { super.onViewAttachedToWindow(holder) if (holder is TypingIndicatorViewHolder) { @@ -433,6 +490,26 @@ class ChatMessageAdapter : RecyclerView.Adapter() { } super.onViewDetachedFromWindow(holder) } + + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + when (holder) { + is TypingIndicatorViewHolder -> holder.stopTypingAnimation() + is UserMessageViewHolder -> { + // 클릭 리스너 제거로 누수 및 중복 클릭 방지 + holder.itemView.findViewById(R.id.iv_retry)?.setOnClickListener(null) + } + is AiMessageViewHolder -> { + // 이미지 태그/리소스 정리로 잘못된 재활용 방지 + holder.itemView.findViewById(R.id.iv_profile)?.let { v -> + (v as? android.widget.ImageView)?.apply { + setImageDrawable(null) + tag = null + } + } + } + } + super.onViewRecycled(holder) + } } // endregion