From 04dcb3f09f51fc4d925fff7f4fc1877c88783a6d Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 9 Jul 2026 07:43:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20FanTalk=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelFanTalkDetailViewModel.kt | 244 +++++++++++++ ...reatorChannelFanTalkDetailViewModelTest.kt | 320 ++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailViewModel.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailViewModelTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailViewModel.kt new file mode 100644 index 00000000..38c52c26 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailViewModel.kt @@ -0,0 +1,244 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.orhanobut.logger.Logger +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import kr.co.vividnext.sodalive.R +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.common.ToastMessage +import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter +import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.toFanTalkReplyUiModel + +class CreatorChannelFanTalkDetailViewModel( + private val repository: CreatorChannelRepository, + private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter +) : BaseViewModel() { + + private val _detailStateLiveData = MutableLiveData() + val detailStateLiveData: LiveData + get() = _detailStateLiveData + + private val _replacementModalEventLiveData = + MutableLiveData>() + val replacementModalEventLiveData: LiveData> + get() = _replacementModalEventLiveData + + private val _replyChangedEventLiveData = MutableLiveData(false) + val replyChangedEventLiveData: LiveData + get() = _replyChangedEventLiveData + + private val _toastLiveData = MutableLiveData>() + val toastLiveData: LiveData> + get() = _toastLiveData + + private var creatorId: Long = 0L + private var isSubmitting: Boolean = false + private var pendingReplacementContent: String? = null + + fun load(creatorId: Long, payload: CreatorChannelFanTalkDetailPayload) { + this.creatorId = creatorId + val parentFanTalk = payload.toUiModel() + _detailStateLiveData.value = CreatorChannelFanTalkDetailUiState.Content( + parentFanTalk = parentFanTalk, + reply = parentFanTalk.reply + ) + } + + fun updateReplyInput(input: String) { + val content = currentContent() ?: return + _detailStateLiveData.value = content.copy(replyInput = input) + } + + fun submitReply() { + val content = currentContent() ?: return + val trimmedContent = content.replyInput.trim() + if (trimmedContent.isEmpty() || isSubmitting) return + + val editingTarget = content.editingTarget + if (editingTarget is CreatorChannelFanTalkDetailEditTarget.Reply) { + modifyReply(content, editingTarget.replyFanTalkId, trimmedContent) + return + } + + val reply = content.reply + if (reply == null) { + writeReply(content, trimmedContent) + } else { + if (reply.fanTalkId <= 0L) { + showFailureToast() + return + } + pendingReplacementContent = trimmedContent + _replacementModalEventLiveData.value = CreatorChannelEvent(CreatorChannelFanTalkReplacementModalUiModel()) + } + } + + fun confirmReplacement() { + val content = currentContent() ?: return + val reply = content.reply ?: return + val replacementContent = pendingReplacementContent ?: content.replyInput.trim() + if (replacementContent.isBlank() || isSubmitting) return + if (reply.fanTalkId <= 0L) { + pendingReplacementContent = null + showFailureToast() + return + } + + modifyReply(content, reply.fanTalkId, replacementContent) + pendingReplacementContent = null + } + + fun cancelReplacement() { + pendingReplacementContent = null + } + + fun consumeReplyChangedEvent() { + _replyChangedEventLiveData.value = false + } + + fun cancelReplyEdit() { + val content = currentContent() ?: return + _detailStateLiveData.value = content.copy(replyInput = "", editingTarget = null) + } + + fun startReplyEdit() { + val content = currentContent() ?: return + val reply = content.reply ?: return + _detailStateLiveData.value = content.copy( + replyInput = reply.content, + editingTarget = CreatorChannelFanTalkDetailEditTarget.Reply(reply.fanTalkId) + ) + } + + fun deleteReply() { + val content = currentContent() ?: return + val reply = content.reply ?: return + if (isSubmitting) return + + submitMutation(content, repository.deleteFanTalkReply(reply.fanTalkId, authToken())) { current -> + current.copy(reply = null, replyInput = "", editingTarget = null) + } + } + + private fun writeReply(content: CreatorChannelFanTalkDetailUiState.Content, replyContent: String) { + if (creatorId <= 0L || content.parentFanTalk.fanTalkId <= 0L) return + + isSubmitting = true + _detailStateLiveData.value = content.copy(isSubmitting = true) + compositeDisposable.add( + repository.writeFanTalkReply( + fanTalkId = content.parentFanTalk.fanTalkId, + creatorId = creatorId, + content = replyContent, + token = authToken() + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> handleWriteReplyResponse(response) }, + { error -> handleMutationError(error) } + ) + ) + } + + private fun modifyReply( + content: CreatorChannelFanTalkDetailUiState.Content, + replyFanTalkId: Long, + replyContent: String + ) { + if (replyFanTalkId <= 0L) return + + submitMutation( + content = content, + request = repository.modifyFanTalkReply(replyFanTalkId, replyContent, authToken()) + ) { current -> + current.copy( + reply = current.reply?.copy(content = replyContent), + replyInput = "", + editingTarget = null + ) + } + } + + private fun submitMutation( + content: CreatorChannelFanTalkDetailUiState.Content, + request: Single>, + onSuccess: (CreatorChannelFanTalkDetailUiState.Content) -> CreatorChannelFanTalkDetailUiState.Content + ) { + isSubmitting = true + _detailStateLiveData.value = content.copy(isSubmitting = true) + compositeDisposable.add( + request + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> handleMutationResponse(response, onSuccess) }, + { error -> handleMutationError(error) } + ) + ) + } + + private fun handleWriteReplyResponse(response: ApiResponse) { + isSubmitting = false + val current = currentContent() ?: return + if (response.success) { + _detailStateLiveData.value = current.copy( + reply = response.data?.toFanTalkReplyUiModel(relativeTimeTextFormatter) ?: current.reply, + replyInput = "", + isSubmitting = false, + editingTarget = null + ) + _replyChangedEventLiveData.value = true + } else { + showFailureToast(response.message) + _detailStateLiveData.value = current.copy(isSubmitting = false) + } + } + + private fun handleMutationError(error: Throwable) { + isSubmitting = false + error.message?.let { Logger.e(it) } + val current = currentContent() ?: return + showFailureToast() + _detailStateLiveData.value = current.copy(isSubmitting = false) + } + + private fun handleMutationResponse( + response: ApiResponse, + onSuccess: (CreatorChannelFanTalkDetailUiState.Content) -> CreatorChannelFanTalkDetailUiState.Content + ) { + isSubmitting = false + val current = currentContent() ?: return + if (response.success) { + _detailStateLiveData.value = onSuccess(current).copy(isSubmitting = false) + _replyChangedEventLiveData.value = true + } else { + showFailureToast(response.message) + _detailStateLiveData.value = current.copy(isSubmitting = false) + } + } + + private fun showFailureToast(message: String? = null) { + _toastLiveData.value = CreatorChannelEvent( + if (message.isNullOrBlank()) { + ToastMessage(resId = R.string.common_error_unknown) + } else { + ToastMessage(message = message) + } + ) + } + + private fun currentContent(): CreatorChannelFanTalkDetailUiState.Content? { + return _detailStateLiveData.value as? CreatorChannelFanTalkDetailUiState.Content + } + + private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}" +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailViewModelTest.kt new file mode 100644 index 00000000..6c035f24 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailViewModelTest.kt @@ -0,0 +1,320 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail + +import android.app.Application +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.test.core.app.ApplicationProvider +import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.Schedulers +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder +import kr.co.vividnext.sodalive.common.ToastMessage +import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkReplyUiModel +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkRightAction +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class CreatorChannelFanTalkDetailViewModelTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var repository: CreatorChannelRepository + private lateinit var viewModel: CreatorChannelFanTalkDetailViewModel + + @Before + fun setUp() { + setImmediateRxSchedulers() + SharedPreferenceManager.resetForTest() + SodaLiveApplicationHolder.init(context as Application) + SharedPreferenceManager.init(context) + SharedPreferenceManager.token = "test-token" + repository = org.mockito.kotlin.mock() + viewModel = CreatorChannelFanTalkDetailViewModel( + repository, + UtcRelativeTimeTextFormatter { value -> "relative:$value" } + ) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + SharedPreferenceManager.resetForTest() + } + + @Test + fun `payload로 받은 부모 팬톡과 첫 답글을 content state로 노출한다`() { + val parent = fanTalk(reply = reply(201L, "기존 답글")) + + viewModel.load(creatorId = 100L, payload = parent.toFanTalkDetailPayload()) + + val state = viewModel.detailStateLiveData.requireContent() + assertEquals(parent, state.parentFanTalk) + assertEquals(parent.reply, state.reply) + assertEquals("", state.replyInput) + assertFalse(state.isSendEnabled) + } + + @Test + fun `공백 입력은 보내기 비활성 상태이고 submit해도 API를 호출하지 않는다`() { + viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload()) + + viewModel.updateReplyInput(" \n \t") + viewModel.submitReply() + + val state = viewModel.detailStateLiveData.requireContent() + assertEquals(" \n \t", state.replyInput) + assertFalse(state.isSendEnabled) + verify(repository, never()).writeFanTalkReply(any(), any(), any(), any()) + verify(repository, never()).modifyFanTalkReply(any(), any(), any()) + } + + @Test + fun `답글이 없으면 submit 시 반환된 답글 데이터를 현재 state에 바인딩한다`() { + whenever(repository.writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token")) + .thenReturn(Single.just(ApiResponse(true, serverReply(301L, "새 답글"), null))) + viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload()) + viewModel.updateReplyInput(" 새 답글 ") + + viewModel.submitReply() + + verify(repository).writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token") + val state = viewModel.detailStateLiveData.requireContent() + assertEquals("", state.replyInput) + assertEquals(301L, state.reply?.fanTalkId) + assertEquals(900L, state.reply?.writerId) + assertEquals("server-creator", state.reply?.writerNickname) + assertEquals("server-creator.png", state.reply?.writerProfileImageUrl) + assertEquals("새 답글", state.reply?.content) + assertEquals("relative:2026-07-09T01:02:03Z", state.reply?.createdAtText) + assertEquals(null, viewModel.toastLiveData.requireValue()?.consume()) + assertTrue(viewModel.replyChangedEventLiveData.requireValue() == true) + } + + @Test + fun `작성 성공 응답의 data가 null이어도 성공으로 처리하고 실패 toast를 노출하지 않는다`() { + whenever(repository.writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token")) + .thenReturn(Single.just(ApiResponse(true, null, null))) + viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload()) + viewModel.updateReplyInput("새 답글") + + viewModel.submitReply() + + verify(repository).writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token") + val state = viewModel.detailStateLiveData.requireContent() + assertEquals("", state.replyInput) + assertEquals(null, viewModel.toastLiveData.requireValue()?.consume()) + assertTrue(viewModel.replyChangedEventLiveData.requireValue() == true) + } + + @Test + fun `작성 직후 서버 답글 id로 대체 submit과 수정 API를 진행한다`() { + whenever(repository.writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token")) + .thenReturn(Single.just(ApiResponse(true, serverReply(301L, "새 답글"), null))) + whenever(repository.modifyFanTalkReply(301L, "다시 쓰기", "Bearer test-token")) + .thenReturn(Single.just(ApiResponse(true, null, null))) + viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload()) + viewModel.updateReplyInput("새 답글") + viewModel.submitReply() + + viewModel.updateReplyInput("다시 쓰기") + viewModel.submitReply() + viewModel.confirmReplacement() + + verify(repository).modifyFanTalkReply(301L, "다시 쓰기", "Bearer test-token") + assertEquals("다시 쓰기", viewModel.detailStateLiveData.requireContent().reply?.content) + } + + @Test + fun `기존 답글이 있으면 submit 시 대체 모달 event만 emit하고 확인 전에는 API를 호출하지 않는다`() { + viewModel.load(creatorId = 100L, payload = fanTalk(reply = reply(201L, "기존 답글")).toFanTalkDetailPayload()) + viewModel.updateReplyInput(" 대체 답글 ") + + viewModel.submitReply() + + val event = viewModel.replacementModalEventLiveData.requireValue()?.consume() + assertEquals(CreatorChannelFanTalkReplacementModalUiModel(), event) + verify(repository, never()).writeFanTalkReply(any(), any(), any(), any()) + verify(repository, never()).modifyFanTalkReply(any(), any(), any()) + } + + @Test + fun `대체 모달 확인 시 기존 답글 id에 trim content로 수정 wrapper를 호출한다`() { + whenever(repository.modifyFanTalkReply(201L, "대체 답글", "Bearer test-token")) + .thenReturn(Single.just(ApiResponse(true, null, null))) + viewModel.load(creatorId = 100L, payload = fanTalk(reply = reply(201L, "기존 답글")).toFanTalkDetailPayload()) + viewModel.updateReplyInput(" 대체 답글 ") + viewModel.submitReply() + + viewModel.confirmReplacement() + + verify(repository).modifyFanTalkReply(201L, "대체 답글", "Bearer test-token") + val state = viewModel.detailStateLiveData.requireContent() + assertEquals("", state.replyInput) + assertEquals("대체 답글", state.reply?.content) + assertTrue(viewModel.replyChangedEventLiveData.requireValue() == true) + } + + @Test + fun `대체 모달 취소 시 입력값을 유지하고 API를 호출하지 않는다`() { + viewModel.load(creatorId = 100L, payload = fanTalk(reply = reply(201L, "기존 답글")).toFanTalkDetailPayload()) + viewModel.updateReplyInput(" 대체 답글 ") + viewModel.submitReply() + + viewModel.cancelReplacement() + + assertEquals(" 대체 답글 ", viewModel.detailStateLiveData.requireContent().replyInput) + verify(repository, never()).writeFanTalkReply(any(), any(), any(), any()) + verify(repository, never()).modifyFanTalkReply(any(), any(), any()) + } + + @Test + fun `등록 중에는 중복 submit을 막는다`() { + whenever(repository.writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token")) + .thenReturn(Single.never()) + viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload()) + viewModel.updateReplyInput("새 답글") + + viewModel.submitReply() + viewModel.submitReply() + + verify(repository, times(1)).writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token") + assertTrue(viewModel.detailStateLiveData.requireContent().isSubmitting) + } + + @Test + fun `실패 시 입력값을 유지하고 서버 message를 우선 toast로 emit한다`() { + whenever(repository.writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token")) + .thenReturn(Single.just(ApiResponse(false, null, "서버 실패"))) + viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload()) + viewModel.updateReplyInput(" 새 답글 ") + + viewModel.submitReply() + + assertEquals(" 새 답글 ", viewModel.detailStateLiveData.requireContent().replyInput) + assertEquals(ToastMessage(message = "서버 실패"), viewModel.toastLiveData.requireValue()?.consume()) + } + + @Test + fun `실패 message가 없으면 공통 오류 toast를 emit한다`() { + whenever(repository.writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token")) + .thenReturn(Single.just(ApiResponse(false, null, null))) + viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload()) + viewModel.updateReplyInput("새 답글") + + viewModel.submitReply() + + assertEquals(ToastMessage(resId = R.string.common_error_unknown), viewModel.toastLiveData.requireValue()?.consume()) + } + + @Test + fun `답글 수정 모드는 입력값을 채우고 submit 시 수정 wrapper를 호출한다`() { + whenever(repository.modifyFanTalkReply(201L, "수정 답글", "Bearer test-token")) + .thenReturn(Single.just(ApiResponse(true, null, null))) + viewModel.load(creatorId = 100L, payload = fanTalk(reply = reply(201L, "기존 답글")).toFanTalkDetailPayload()) + + viewModel.startReplyEdit() + viewModel.updateReplyInput(" 수정 답글 ") + viewModel.submitReply() + + verify(repository).modifyFanTalkReply(201L, "수정 답글", "Bearer test-token") + val state = viewModel.detailStateLiveData.requireContent() + assertEquals("", state.replyInput) + assertEquals(null, state.editingTarget) + assertEquals("수정 답글", state.reply?.content) + } + + @Test + fun `답글 삭제는 delete wrapper를 호출하고 성공 시 local reply를 제거한다`() { + whenever(repository.deleteFanTalkReply(201L, "Bearer test-token")) + .thenReturn(Single.just(ApiResponse(true, null, null))) + viewModel.load(creatorId = 100L, payload = fanTalk(reply = reply(201L, "기존 답글")).toFanTalkDetailPayload()) + + viewModel.deleteReply() + + verify(repository).deleteFanTalkReply(201L, "Bearer test-token") + assertEquals(null, viewModel.detailStateLiveData.requireContent().reply) + assertTrue(viewModel.replyChangedEventLiveData.requireValue() == true) + } + + @Test + fun `대체 모달 UI 모델은 문구를 문자열 리소스 id로 제공한다`() { + val modal = CreatorChannelFanTalkReplacementModalUiModel() + + assertEquals(R.string.screen_user_profile_reply_edit, modal.titleResId) + assertEquals(R.string.creator_channel_fantalk_reply_replace_description, modal.descriptionResId) + assertEquals(R.string.cancel, modal.cancelResId) + assertEquals(R.string.confirm, modal.confirmResId) + } + + private fun setImmediateRxSchedulers() { + val trampoline = { _: Scheduler -> Schedulers.trampoline() } + RxJavaPlugins.setIoSchedulerHandler(trampoline) + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } + } + + private fun serverReply(id: Long, content: String) = CreatorChannelFanTalkReplyResponse( + fanTalkId = id, + writerId = 900L, + writerNickname = "server-creator", + writerProfileImageUrl = "server-creator.png", + content = content, + createdAtUtc = "2026-07-09T01:02:03Z" + ) + + private fun fanTalk(reply: CreatorChannelFanTalkReplyUiModel?) = CreatorChannelFanTalkUiModel( + fanTalkId = 10L, + writerId = 20L, + writerNickname = "fan", + writerProfileImageUrl = "fan.png", + content = "응원 원문", + createdAtText = "방금 전", + reply = reply, + rightAction = CreatorChannelFanTalkRightAction.OwnerMore(showEdit = false, showDelete = true) + ) + + private fun reply(id: Long, content: String) = CreatorChannelFanTalkReplyUiModel( + fanTalkId = id, + writerId = 30L, + writerNickname = "creator", + writerProfileImageUrl = "creator.png", + content = content, + createdAtText = "방금 전" + ) + + private fun LiveData.requireContent(): CreatorChannelFanTalkDetailUiState.Content { + return requireValue() as CreatorChannelFanTalkDetailUiState.Content + } + + private fun LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { value = it } + observeForever(observer) + removeObserver(observer) + return value + } +}