package com.suno.android.ui.bottom_sheets.comments import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.extensions.xAsMinutesSecondsTimecode import com.suno.android.common_core_utils.extensions.xAsTimecodeStringToDuration import com.suno.android.common_core_utils.model.Url import com.suno.android.common_core_utils.model.UserHandle import com.suno.android.common_data.comments.Comment import com.suno.android.common_data.comments.MediaEntity import com.suno.android.common_data.user.UserProfile import com.suno.android.common_networking.remote.entities.SimpleProfileInfoSchema import com.suno.android.common_ui.components.images.ProfileAvatarImage import com.suno.android.common_ui.components.list_items.comments.CommentListItem import com.suno.android.common_ui.components.list_items.comments.CommentListItemType import com.suno.android.common_ui.components.list_items.comments.MentionState import com.suno.android.common_ui.components.loading.SunoSpinner import com.suno.android.common_ui.components.omni.CircleIconButton import com.suno.android.common_ui.theme.ExtendedTheme import kotlinx.coroutines.launch import com.suno.android.common_res.R as CommonResR import com.suno.android.common_ui.R as CommonUIR @Composable fun CommentsBottomSheetContent( viewState: CommentsBottomSheetState, vm: CommentsBottomSheetVM, mediaEntity: MediaEntity, authorId: Id?, authorHandle: UserHandle?, onDismiss: () -> Unit, onProfileClicked: (UserHandle) -> Unit, onBeginWriting: () -> Unit, focusRequester: FocusRequester, focusManager: FocusManager, lazyListState: LazyListState, sheetState: SheetState, isTextFieldFocused: Boolean, hasBegunTyping: Boolean, onFocusChanged: (Boolean) -> Unit, onTypingStateChanged: (Boolean) -> Unit, onShowReportSheet: () -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() Column( modifier = modifier, verticalArrangement = Arrangement.SpaceBetween, ) { CommentsHeader( loading = viewState.loading, totalCount = viewState.totalCount, onDismiss = onDismiss, modifier = Modifier .fillMaxWidth() .height(96.dp), ) if (viewState.loading) { Box( modifier = Modifier .fillMaxWidth() .weight(1f) .padding(top = 32.dp), contentAlignment = Alignment.TopCenter, ) { SunoSpinner() } } else { LazyColumn( modifier = Modifier .padding(horizontal = 16.dp) .weight(1f), state = lazyListState, ) { if (viewState.comments?.isEmpty() == true) { item { EmptyCommentsState( modifier = Modifier .fillMaxWidth() .padding(top = 48.dp), ) } } items(viewState.comments ?: emptyList()) { comment -> val repliesLoading = viewState.commentRepliesLoadingMap[comment.id] ?: false CommentListItem( comment = comment, onProfileClicked = { userHandle -> onProfileClicked(userHandle) }, onTimecodeClicked = { timecode -> vm.sendEvent( CommentsBottomSheetEvent.OnTimecodeClicked( timecode = timecode.xAsTimecodeStringToDuration(), ), ) }, authorId = authorId?.value, onReactClicked = { commentId, reaction, parentCommentId -> vm.sendEvent( CommentsBottomSheetEvent.OnReactToComment( commentId = Id(commentId), // All replies will have entityType (clip or hook) of parent comment commentType = comment.entityType.toCommentType(), reaction = reaction, parentCommentId = parentCommentId?.let(::Id), ), ) }, fetchedReplies = viewState.commentRepliesMap[comment.id], fetchReplies = { vm.sendEvent( CommentsBottomSheetEvent.OnGetCommentReplies( commentId = Id(comment.id), commentType = comment.entityType.toCommentType(), ), ) }, repliesLoading = repliesLoading, reactLoadingMap = viewState.commentReactLoadingMap, onReplyClicked = { vm.setReplyTarget(commentId = Id(comment.id)) focusRequester.requestFocus() }, onReportComment = { commentId -> vm.sendEvent( CommentsBottomSheetEvent.Internal.OnSetCommentToReport( commentId = Id(commentId), commentType = comment.entityType.toCommentType(), ), ) onShowReportSheet() }, onDeleteComment = { commentId -> scope.launch { vm.sendEvent( CommentsBottomSheetEvent.OnDeleteComment( commentId = Id(commentId), commentType = comment.entityType.toCommentType(), ), ) } }, isOwnContent = authorId?.value != null && authorId.value == viewState.user?.id, isOwnComment = comment.userId == viewState.user?.id, showMentions = viewState.isMentionsGateEnabled, entityLabelText = if ( mediaEntity is MediaEntity.HookEntity && comment.entityType == CommentListItemType.Clip ) { stringResource(CommonResR.string.on_song) } else { null }, showTimestamp = mediaEntity is MediaEntity.ClipEntity, ) } if (viewState.loadingMore) { item { Box( modifier = Modifier .fillMaxWidth() .padding(top = 16.dp), contentAlignment = Alignment.TopCenter, ) { SunoSpinner() } } } } } Column( modifier = Modifier .offset { IntOffset( x = 0, y = -sheetState.requireOffset().toInt() + 80, ) } .fillMaxWidth() .background(ExtendedTheme.colors.backgroundSecondary) .padding(bottom = 12.dp, start = 8.dp, end = 8.dp), ) { val replyComment = viewState.comments?.find { viewState.parentId != null && it.id == viewState.parentId.value } if (replyComment != null) { ReplyIndicator( userDisplayName = replyComment.userDisplayName, commentContent = replyComment.content, onClearReply = { vm.setReplyTarget(null) focusManager.clearFocus() }, modifier = Modifier .fillMaxWidth() .background(ExtendedTheme.colors.backgroundSecondary) .padding(top = 8.dp, bottom = 16.dp), ) } MentionTargetsList( mentionTargets = viewState.mentionTargets, onMentionClicked = { vm.addMentionToComment(it) }, modifier = Modifier .fillMaxWidth() .heightIn(0.dp, 248.dp), ) CommentInputSection( textFieldValue = viewState.textFieldValue, userMentions = viewState.userMentions, userAvatarUrl = viewState.user?.avatarImageUrl, timestamp = viewState.timestamp, parentId = viewState.parentId, authorHandle = authorHandle, focusRequester = focusRequester, focusManager = focusManager, isTextFieldFocused = isTextFieldFocused, hasBegunTyping = hasBegunTyping, onBeginWriting = onBeginWriting, onFocusChanged = onFocusChanged, onTypingStateChanged = onTypingStateChanged, onUpdateCommentContent = { textField, handle -> vm.updateCommentContent(textField, handle) }, onPostComment = { vm.sendEvent( CommentsBottomSheetEvent.OnPostComment( mediaEntity = mediaEntity, replyTargetId = viewState.parentId, ), ) }, onClearReply = { vm.setReplyTarget(null) }, modifier = Modifier .fillMaxWidth() .height(80.dp) .background(ExtendedTheme.colors.backgroundSecondary), ) } } } @Composable private fun CommentsHeader( loading: Boolean, totalCount: Int?, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { Box( modifier = modifier, ) { val isLoading = loading || totalCount == null val commentsString = pluralStringResource( CommonResR.plurals.commentsVARIABLE, totalCount ?: 0, totalCount ?: 0, ) Text( modifier = Modifier.align(Alignment.Center), text = if (isLoading) stringResource(CommonResR.string.comments) else commentsString, style = ExtendedTheme.typography.introCopy, ) CircleIconButton( modifier = Modifier .align(Alignment.CenterEnd) .padding(24.dp), vectorDrawableResId = CommonUIR.drawable.close, contentDescription = stringResource(CommonResR.string.close), onClick = onDismiss, ) } } @Composable private fun EmptyCommentsState( modifier: Modifier = Modifier, ) { Box( modifier = modifier, contentAlignment = Alignment.TopCenter, ) { Column( modifier = Modifier.width(240.dp), ) { Text( modifier = Modifier .padding(bottom = 8.dp) .align(Alignment.CenterHorizontally), text = stringResource(CommonResR.string.no_comments_yet), style = ExtendedTheme.typography.smallTrackName, ) Text( text = stringResource(CommonResR.string.add_first_comment), textAlign = TextAlign.Center, style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.textSecondary, ) } } } @Composable private fun ReplyIndicator( userDisplayName: String, commentContent: String, onClearReply: () -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Text( maxLines = 1, modifier = Modifier.padding(end = 4.dp), text = stringResource( CommonResR.string.reply_to, userDisplayName, ), style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.textPrimary, ) Text( modifier = Modifier .weight(1f) .padding(end = 4.dp), maxLines = 1, overflow = TextOverflow.Ellipsis, text = commentContent, style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.textSecondary, ) Icon( modifier = Modifier .size(24.dp) .clickable { onClearReply() }, imageVector = ImageVector.vectorResource(CommonUIR.drawable.close), contentDescription = stringResource(CommonResR.string.close), ) } } @Composable private fun MentionTargetsList( mentionTargets: List, onMentionClicked: (SimpleProfileInfoSchema) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( modifier = modifier, state = rememberLazyListState(), ) { item { Spacer(modifier = Modifier.height(8.dp)) } items(mentionTargets) { mention -> mention.handle.takeUnless { it.isNullOrBlank() }?.let { handle -> Row( modifier = Modifier .fillMaxSize() .padding(vertical = 8.dp) .clickable { onMentionClicked(mention) }, ) { ProfileAvatarImage( avatarUrl = mention.avatarImageUrl?.let(::Url), modifier = Modifier .padding(end = 8.dp) .size(44.dp), ) Column( modifier = Modifier.height(44.dp), verticalArrangement = Arrangement.Center, ) { Text( text = mention.displayName ?: "", style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.textPrimary, ) Text( text = stringResource(CommonResR.string.arobas_user_handle, handle), style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.textTertiary, ) } } } } } } @Composable private fun CommentInputSection( textFieldValue: TextFieldValue, userMentions: List, userAvatarUrl: String?, timestamp: kotlin.time.Duration?, parentId: Id?, authorHandle: UserHandle?, focusRequester: FocusRequester, focusManager: FocusManager, isTextFieldFocused: Boolean, hasBegunTyping: Boolean, onBeginWriting: () -> Unit, onFocusChanged: (Boolean) -> Unit, onTypingStateChanged: (Boolean) -> Unit, onUpdateCommentContent: (TextFieldValue, UserHandle?) -> Unit, onPostComment: () -> Unit, onClearReply: () -> Unit, modifier: Modifier = Modifier, ) { val inputVal = textFieldValue Row( modifier = modifier, ) { ProfileAvatarImage( modifier = Modifier .padding(end = 8.dp) .size(44.dp), avatarUrl = userAvatarUrl?.let { Url(it) }, ) val mentionColor = ExtendedTheme.colors.textPrimary BasicTextField( modifier = Modifier .fillMaxWidth() .padding(horizontal = 4.dp) .height(44.dp) .background( color = ExtendedTheme.colors.backgroundTertiary, shape = RoundedCornerShape(8.dp), ) .focusRequester(focusRequester = focusRequester) .onFocusChanged { focusState -> onFocusChanged(focusState.isFocused) onTypingStateChanged(false) }, value = inputVal, onValueChange = { onUpdateCommentContent(it, authorHandle) // Track when the user begins typing if (isTextFieldFocused && !hasBegunTyping && it.text != textFieldValue.text) { onBeginWriting() onTypingStateChanged(true) } }, textStyle = ExtendedTheme.typography.body.copy(color = ExtendedTheme.colors.textSecondary), maxLines = 1, cursorBrush = SolidColor(ExtendedTheme.colors.sunoPink), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions( onDone = { if (inputVal.text.isEmpty()) return@KeyboardActions onPostComment() onClearReply() focusManager.clearFocus() onUpdateCommentContent(TextFieldValue(), authorHandle) }, ), visualTransformation = { inputText -> val annotatedString = buildAnnotatedString { append(inputText.text) userMentions.forEach { mention -> addStyle( style = SpanStyle( fontWeight = FontWeight.Bold, color = mentionColor, ), start = mention.start, end = mention.end, ) } } TransformedText( text = annotatedString, offsetMapping = OffsetMapping.Identity, ) }, decorationBox = @Composable { innerTextField -> Row( modifier = Modifier .fillMaxSize() .padding(horizontal = 8.dp) .background( color = ExtendedTheme.colors.backgroundTertiary, shape = RoundedCornerShape(8.dp), ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Box( modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart, ) { if (inputVal.text.isEmpty()) { Text( text = stringResource( if (parentId == null) { CommonResR.string.add_a_comment } else { CommonResR.string.reply_to_comment }, ), style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.textSecondary, ) } innerTextField() } timestamp?.let { Text( text = timestamp.xAsMinutesSecondsTimecode(), style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.textSecondary, ) } } }, ) } }