package com.suno.android.ui.bottom_sheets.comments import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.viewModelScope import arrow.core.getOrElse import arrow.retrofit.adapter.either.networkhandling.HttpError import com.google.gson.Gson import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.global_errors.DialogSpec import com.suno.android.common_core_utils.global_errors.DialogSpec.Button import com.suno.android.common_core_utils.global_errors.TopLevelErrorManager import com.suno.android.common_core_utils.model.UiString.Raw import com.suno.android.common_core_utils.model.UiString.Resource import com.suno.android.common_core_utils.model.UserHandle import com.suno.android.common_data.comments.Comment import com.suno.android.common_data.comments.CommentReply import com.suno.android.common_data.comments.MediaEntity import com.suno.android.common_data.mentions.UserMention import com.suno.android.common_data.repos.comments.CommentsRepository import com.suno.android.common_data.user.UserSessionRepository import com.suno.android.common_mvi.MviProcessorFactory import com.suno.android.common_mvi.MviViewModel import com.suno.android.common_networking.extensions.toThrowable import com.suno.android.common_networking.remote.entities.SimpleProfileInfoSchema import com.suno.android.common_networking.remote.entities.UserSearchRequest import com.suno.android.common_networking.remote.search.SearchService import com.suno.android.common_res.R import com.suno.android.common_ui.components.list_items.comments.MentionState import com.suno.android.common_ui.extensions.xGetCurrentlyEditingWord import com.suno.android.gating.Feature import com.suno.android.gating.FeatureManager import com.suno.android.media.MediaManager import com.suno.android.media.MediaPlayerState import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlin.time.DurationUnit private const val ORDER = "newest" @HiltViewModel(assistedFactory = CommentsBottomSheetVM.Factory::class) class CommentsBottomSheetVM @AssistedInject constructor( processorFactory: MviProcessorFactory, @Assisted private val mediaEntity: MediaEntity, userSessionRepository: UserSessionRepository, featureManager: FeatureManager, private val commentsRepository: CommentsRepository, private val searchService: SearchService, private val mediaManager: MediaManager, private val topLevelErrorManager: TopLevelErrorManager, ) : MviViewModel( processorFactory = processorFactory, initialState = CommentsBottomSheetState( isMentionsGateEnabled = featureManager.hasFeature(Feature.CommentsMentions), ), ) { private var cursor: String? = null private var unableToLoadMore = false init { if (mediaEntity is MediaEntity.ClipEntity) { mediaManager.mediaPlayerFlow() .onEach { mediaPlayerState: MediaPlayerState -> // if user is typing, don't update the tracked timestamp // TODO: make this also work on focus with no inputted text if (state.value.textFieldValue.text.isEmpty()) { updateState { oldState -> oldState.copy(timestamp = mediaPlayerState.playtimeDuration) } } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) } userSessionRepository.sessionConfigurationStateFlow() .onEach { sessionConfiguration -> updateState { it.copy(user = sessionConfiguration.user) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) } @Suppress("LongMethod") override suspend fun reduceEvent( currentState: CommentsBottomSheetState, event: CommentsBottomSheetEvent, emitEffect: suspend (CommentsBottomSheetEffect) -> Unit, ): CommentsBottomSheetState { return when (event) { is CommentsBottomSheetEvent.OnPostComment -> { if (currentState.commentingAllowed == false) { topLevelErrorManager.broadcastTopLevelDialogError( errorMessage = DialogSpec( title = Resource(R.string.unable_to_comment), body = Raw(state.value.banReason.orEmpty()), buttons = persistentListOf( Button( label = Resource(R.string.dismiss), type = Button.DialogButtonType.DEFAULT, ), ), ), ) return currentState } val userMentions = currentState.userMentions.map { UserMention( start = it.start, end = it.end, handle = it.handle, displayName = it.displayName, ) } viewModelScope.launch { commentsRepository.postComment( mediaEntity = event.mediaEntity, content = currentState.textFieldValue.text, parentId = currentState.parentId, mentions = userMentions, trackTimestamp = currentState.timestamp?.toDouble(DurationUnit.SECONDS), ).getOrElse { error -> val detailMessage = runCatching { val errorBody = (error as? HttpError)?.body val errorJson = Gson().fromJson(errorBody, Map::class.java) errorJson["detail"] as? String }.getOrNull() val updatedErrorMessage = if (detailMessage != null) { Raw(detailMessage) } else { Resource(R.string.comment_stuck_try_again) } topLevelErrorManager.broadcastTopLevelDialogError( errorMessage = DialogSpec( body = updatedErrorMessage, buttons = persistentListOf( Button( label = Resource(R.string.dismiss), type = Button.DialogButtonType.DEFAULT, ), ), ), ) logger.e(error.toThrowable()) updateState { it.copy(loading = false) } return@launch } fetchPage(mediaEntity = event.mediaEntity, isFirstLoad = true) } updateCommentContent(inputValue = TextFieldValue(""), authorHandle = null) currentState } is CommentsBottomSheetEvent.OnReactToComment -> { viewModelScope.launch { val response = commentsRepository.reactToComment( commentId = event.commentId, commentType = event.commentType, reaction = event.reaction, ).getOrElse { error -> updateState { oldState -> oldState.copy(commentReactLoadingMap = emptyMap()) } logger.e(error.toThrowable()) return@launch } updateState { oldState -> val newContent = oldState.comments?.map { comment -> if (event.commentId.value == comment.id) { comment.copy( numLikes = response.numLikes, reactionType = if (response.reactionType == "LIKE") "like" else null, ) } else { val updatedReplies = comment.replies?.map { reply -> if (event.commentId.value == reply.id) { reply.copy( numLikes = response.numLikes, reactionType = if (response.reactionType == "LIKE") "like" else null, ) } else { reply } } comment.copy( replies = updatedReplies, ) } } val newRepliesMap = oldState.commentRepliesMap.toMutableMap() event.parentCommentId?.value?.let { parentId -> newRepliesMap.forEach { (commentId, replies) -> if (commentId == parentId) { val updatedReplies = replies.map { reply -> if (reply.id == event.commentId.value) { reply.copy( numLikes = response.numLikes, reactionType = if (response.reactionType == "LIKE") "like" else null, ) } else { reply } } newRepliesMap[commentId] = updatedReplies } else { newRepliesMap[commentId] = replies } } } val newLoadingMap = oldState.commentReactLoadingMap.toMutableMap() newLoadingMap[event.commentId.value] = false oldState.copy( comments = newContent, commentReactLoadingMap = newLoadingMap, commentRepliesMap = newRepliesMap, ) } } val newMap = currentState.commentReactLoadingMap.toMutableMap() newMap[event.commentId.value] = true currentState.copy(commentReactLoadingMap = newMap) } is CommentsBottomSheetEvent.OnDeleteComment -> { viewModelScope.launch { commentsRepository.deleteComment( commentId = event.commentId, commentType = event.commentType, ).getOrElse { error -> logger.e(error.toThrowable()) return@launch } } currentState.copy( comments = currentState.comments?.filter { comment -> comment.id != event.commentId.value }, ) } is CommentsBottomSheetEvent.OnGetCommentReplies -> { viewModelScope.launch { val response = commentsRepository.getCommentReplies( commentId = event.commentId, commentType = event.commentType, cursor = null, ).getOrElse { error -> logger.e(error.toThrowable()) updateState { oldState -> val newLoadingMap = oldState.commentRepliesLoadingMap.toMutableMap() newLoadingMap[event.commentId.value] = false oldState.copy(commentRepliesLoadingMap = newLoadingMap) } return@launch } updateState { oldState -> val commentId = event.commentId.value val newRepliesMap = oldState.commentRepliesMap.toMutableMap() val replies = response.replies newRepliesMap[commentId] = replies.map(CommentReply::toCommentReplyState) val newLoadingMap = oldState.commentRepliesLoadingMap.toMutableMap() newLoadingMap[commentId] = false oldState.copy( commentRepliesMap = newRepliesMap, commentRepliesLoadingMap = newLoadingMap, ) } } val newMap = currentState.commentRepliesLoadingMap.toMutableMap() newMap[event.commentId.value] = true currentState.copy(commentRepliesLoadingMap = newMap) } is CommentsBottomSheetEvent.OnTimecodeClicked -> { val timestampPosition = event.timecode.toLong(DurationUnit.MILLISECONDS) mediaManager.scrubToPosition(timeStampPosition = timestampPosition) currentState } is CommentsBottomSheetEvent.OnReportComment -> { currentState.selectedCommentToReport?.let { (commentId, commentType) -> commentsRepository.reportComment( commentId = commentId, commentType = commentType, reason = event.reason, ).getOrElse { error -> logger.e(error.toThrowable()) } } currentState.copy(selectedCommentToReport = null) } is CommentsBottomSheetEvent.Internal -> { when (event) { is CommentsBottomSheetEvent.Internal.OnSetCommentToReport -> { currentState.copy(selectedCommentToReport = Pair(event.commentId, event.commentType)) } is CommentsBottomSheetEvent.Internal.OnClearCommentToReport -> { currentState.copy(selectedCommentToReport = null) } } } } } fun setReplyTarget( commentId: Id?, ) { updateState { it.copy(parentId = commentId) } } fun fetchPage( mediaEntity: MediaEntity, isFirstLoad: Boolean = false, ) { if (state.value.loading || state.value.loadingMore) { return } if (isFirstLoad) clearComments() if (unableToLoadMore) return if (isFirstLoad) { updateState { it.copy(loading = true) } } else { updateState { it.copy(loadingMore = true) } } viewModelScope.launch { val response = commentsRepository.getComments( mediaEntity = mediaEntity, cursor = cursor, order = ORDER, ).getOrElse { error -> logger.e(error.toThrowable()) updateState { it.copy( loading = false, loadingMore = false, ) } return@launch } updateState { oldState -> val hasMoreCommentsToLoad = (oldState.comments?.size ?: 0) < response.totalCount val allCommentsLoaded = !isFirstLoad && !hasMoreCommentsToLoad if (allCommentsLoaded) { unableToLoadMore = true oldState } else { val commentingAllowed = response.allowComment val banReason = response.disableReason val prevComments = oldState.comments ?: emptyList() val newComments = response.results.map(Comment::toCommentListItemState) val allComments = prevComments + newComments if (allComments.size >= response.totalCount) { unableToLoadMore = true } else { cursor = response.nextCursor } oldState.copy( loading = false, loadingMore = false, comments = allComments, commentingAllowed = commentingAllowed, banReason = banReason, totalCount = response.totalCount, userMentionHandles = allComments.map { it.userHandle }.distinct(), ) } } } } fun clearComments() { unableToLoadMore = false cursor = null updateState { it.copy( comments = null, totalCount = null, commentRepliesMap = emptyMap(), commentRepliesLoadingMap = emptyMap(), commentReactLoadingMap = emptyMap(), ) } } fun updateCommentContent( inputValue: TextFieldValue, authorHandle: UserHandle?, ) { if (!state.value.isMentionsGateEnabled) { updateState { it.copy(textFieldValue = inputValue) } return } val currentEditingWord = inputValue.xGetCurrentlyEditingWord() val isSearchingForMentions = currentEditingWord.contains("@") if (isSearchingForMentions) { val searchQuery = currentEditingWord.substringAfter("@") findMentionsByHandle(searchQuery, authorHandle) } else { clearMentionTargets() } updateState { oldState -> // check if mentions need to be repositioned/removed based on changes to content val mentions = oldState.userMentions val updatedMentions = mentions.mapNotNull { mention -> // find the nth instance of each specific mention for each handle and update val mentionsOfHandle = mentions.filter { it.handle == mention.handle } val mentionIndex = mentionsOfHandle.indexOf(mention) val matches = Regex(mention.displayName).findAll(inputValue.text) try { matches.elementAt(mentionIndex).let { match -> mention.copy(start = match.range.first, end = match.range.last + 1) } } catch (e: Exception) { null } } oldState.copy( textFieldValue = inputValue, userMentions = updatedMentions, ) } } private fun findMentionsByHandle( query: String, authorHandle: UserHandle?, ) { // prioritize handles - users who have already been mentioned, as well as the author of the song val mentionHandles = state.value.userMentionHandles + listOfNotNull(authorHandle) val searchQuery = UserSearchRequest( term = query, boostedUserHandles = mentionHandles.map { it.handle }, ) searchService.postUserSearch(userSearchRequest = searchQuery) .onEach { response -> val body = response.body() val users = body ?: emptyList() updateState { it.copy(mentionTargets = users) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) } private fun clearMentionTargets() { updateState { it.copy(mentionTargets = emptyList()) } } fun addMentionToComment( mention: SimpleProfileInfoSchema, ) { if (mention.handle == null) return // handle multiple mentions of a single user val displayNameWithFallback = mention.displayName?.ifEmpty { mention.handle } ?: mention.handle as String val currentlyEditingWord = state.value.textFieldValue.xGetCurrentlyEditingWord() val updatedContent = state.value.textFieldValue.text.replace(currentlyEditingWord, displayNameWithFallback) val mentionRegex = Regex(displayNameWithFallback) val handleMatches = mentionRegex.findAll(updatedContent) val prevMentions = state.value.userMentions.filter { it.handle == mention.handle }.size val match = handleMatches.elementAt(prevMentions) val userMention = MentionState( start = match.range.first, end = match.range.last + 1, handle = mention.handle ?: "", displayName = mention.displayName ?: "", ) val updatedTextFieldValue = state.value.textFieldValue.copy( text = updatedContent, selection = TextRange(match.range.last + 1), ) updateState { oldState -> val newMentions = oldState.userMentions.plus(userMention) oldState.copy( userMentions = newMentions, textFieldValue = updatedTextFieldValue, mentionTargets = emptyList(), ) } } @AssistedFactory interface Factory { fun create( mediaEntity: MediaEntity, ): CommentsBottomSheetVM } }