diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index ffb7dbea5..a20a887be 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -45,6 +46,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntSize @@ -55,9 +57,12 @@ import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.model.EventInterface import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.ResizeImage @@ -67,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp) val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp) @@ -82,320 +88,416 @@ fun ChatroomMessageCompose( navController: NavController, onWantsToReply: (Note) -> Unit ) { - val noteState by baseNote.live().metadata.observeAsState() - val note = noteState?.note - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return + val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return + + val noteState by baseNote.live().metadata.observeAsState() + val note = remember(noteState) { noteState?.note } val noteReportsState by baseNote.live().reports.observeAsState() - val noteForReports = noteReportsState?.note ?: return + val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return - val accountUser = account.userProfile() + val noteEvent = note?.event var popupExpanded by remember { mutableStateOf(false) } - var showHiddenNote by remember { mutableStateOf(false) } - val context = LocalContext.current.applicationContext - val scope = rememberCoroutineScope() + if (noteEvent == null) { + BlankNote(Modifier.combinedClickable( + onClick = { }, + onLongClick = { popupExpanded = true } + )) - if (note?.event == null) { - BlankNote(Modifier) - } else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { - if (!account.isHidden(noteForReports.author!!)) { - HiddenNote( - account.getRelevantReports(noteForReports), - account.userProfile(), - Modifier, - innerQuote, - navController, - onClick = { showHiddenNote = true } - ) + note?.let { + NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel) } } else { - var backgroundBubbleColor: Color - var alignment: Arrangement.Horizontal - var shape: Shape + var showHiddenNote by remember { mutableStateOf(false) } + var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) } - val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + LaunchedEffect(key1 = noteReportsState, key2 = accountState) { + withContext(Dispatchers.IO) { + account.userProfile().let { loggedIn -> + val newCanPreview = note.author === loggedIn || + (note.author?.let { loggedIn.isFollowingCached(it) } ?: true) || + !(noteForReports.hasAnyReports()) - if (note.author == accountUser) { - backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) - alignment = Arrangement.End - shape = ChatBubbleShapeMe - } else { - backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) - alignment = Arrangement.Start - shape = ChatBubbleShapeThem - } + val newIsAcceptable = account.isAcceptable(noteForReports) - if (parentBackgroundColor != null) { - backgroundBubbleColor = backgroundBubbleColor.compositeOver(parentBackgroundColor) - } else { - backgroundBubbleColor = backgroundBubbleColor.compositeOver(MaterialTheme.colors.background) - } - - var isNew by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = routeForLastRead) { - routeForLastRead?.let { - scope.launch(Dispatchers.IO) { - val lastTime = NotificationCache.load(it) - - val createdAt = note.createdAt() - if (createdAt != null) { - NotificationCache.markAsRead(it, createdAt) - isNew = createdAt > lastTime + if (newIsAcceptable != isAcceptableAndCanPreview.first && newCanPreview != isAcceptableAndCanPreview.second) { + isAcceptableAndCanPreview = Pair(newIsAcceptable, newCanPreview) } } } } - Column() { - val modif = if (innerQuote) { - Modifier.padding(top = 10.dp, end = 5.dp) - } else { - Modifier - .fillMaxWidth(1f) - .padding( - start = 12.dp, - end = 12.dp, - top = 5.dp, - bottom = 5.dp - ) + if (!isAcceptableAndCanPreview.first && !showHiddenNote) { + if (!account.isHidden(noteForReports.author!!)) { + HiddenNote( + account.getRelevantReports(noteForReports), + account.userProfile(), + Modifier, + innerQuote, + navController, + onClick = { showHiddenNote = true } + ) } - Row( - modifier = modif, - horizontalArrangement = alignment - ) { - var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) } - val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f) + } else { + val backgroundBubbleColor: Color + val alignment: Arrangement.Horizontal + val shape: Shape + + if (note.author == loggedIn) { + backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) + .compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background) + + alignment = Arrangement.End + shape = ChatBubbleShapeMe + } else { + backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + .compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background) + + alignment = Arrangement.Start + shape = ChatBubbleShapeThem + } + + val scope = rememberCoroutineScope() + + LaunchedEffect(key1 = routeForLastRead) { + routeForLastRead?.let { + scope.launch(Dispatchers.IO) { + val lastTime = NotificationCache.load(it) + + val createdAt = note.createdAt() + if (createdAt != null) { + NotificationCache.markAsRead(it, createdAt) + } + } + } + } + + Column() { + val modif = remember { + if (innerQuote) { + Modifier.padding(top = 10.dp, end = 5.dp) + } else { + Modifier + .fillMaxWidth(1f) + .padding( + start = 12.dp, + end = 12.dp, + top = 5.dp, + bottom = 5.dp + ) + } + } Row( - horizontalArrangement = alignment, - modifier = modif2.onSizeChanged { - availableBubbleSize = it - } + modifier = modif, + horizontalArrangement = alignment ) { - Surface( - color = backgroundBubbleColor, - shape = shape, - modifier = Modifier - .combinedClickable( - onClick = { }, - onLongClick = { popupExpanded = true } - ) + var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) } + val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f) + + Row( + horizontalArrangement = alignment, + modifier = modif2.onSizeChanged { + availableBubbleSize = it + } ) { - var bubbleSize by remember { mutableStateOf(IntSize.Zero) } - - Column( + Surface( + color = backgroundBubbleColor, + shape = shape, modifier = Modifier - .padding(start = 10.dp, end = 5.dp, bottom = 5.dp) - .onSizeChanged { - bubbleSize = it - } + .combinedClickable( + onClick = { }, + onLongClick = { popupExpanded = true } + ) ) { - val authorState by note.author!!.live().metadata.observeAsState() - val author = authorState?.user!! + var bubbleSize by remember { mutableStateOf(IntSize.Zero) } - if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = alignment, - modifier = Modifier.padding(top = 5.dp) - ) { - RobohashAsyncImageProxy( - robot = author.pubkeyHex, - model = ResizeImage(author.profilePicture(), 25.dp), - contentDescription = stringResource(id = R.string.profile_image), - modifier = Modifier - .width(25.dp) - .height(25.dp) - .clip(shape = CircleShape) - .clickable(onClick = { - author.let { - navController.navigate("User/${it.pubkeyHex}") - } - }) - ) - - CreateClickableTextWithEmoji( - clickablePart = " ${author.toBestDisplayName()}", - suffix = "", - tags = author.info?.latestMetadata?.tags, - fontWeight = FontWeight.Bold, - overrideColor = MaterialTheme.colors.onBackground, - route = "User/${author.pubkeyHex}", - navController = navController - ) - } - } - - val replyTo = note.replyTo - if (!innerQuote && !replyTo.isNullOrEmpty()) { - Row(verticalAlignment = Alignment.CenterVertically) { - replyTo.toSet().mapIndexed { _, note -> - ChatroomMessageCompose( - note, - null, - innerQuote = true, - parentBackgroundColor = backgroundBubbleColor, - accountViewModel = accountViewModel, - navController = navController, - onWantsToReply = onWantsToReply - ) - } - } - } - - Row(verticalAlignment = Alignment.CenterVertically) { - val event = note.event - if (event is ChannelCreateEvent) { - val channelInfo = event.channelInfo() - val text = note.author?.toBestDisplayName() - .toString() + " ${stringResource(R.string.created)} " + ( - channelInfo.name - ?: "" - ) + " ${stringResource(R.string.with_description_of)} '" + ( - channelInfo.about - ?: "" - ) + "', ${stringResource(R.string.and_picture)} '" + ( - channelInfo.picture - ?: "" - ) + "'" - - CreateTextWithEmoji( - text = text, - tags = note.author?.info?.latestMetadata?.tags - ) - } else if (event is ChannelMetadataEvent) { - val channelInfo = event.channelInfo() - val text = note.author?.toBestDisplayName() - .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + ( - channelInfo.name - ?: "" - ) + "', ${stringResource(R.string.description_to)} '" + ( - channelInfo.about - ?: "" - ) + "', ${stringResource(R.string.and_picture_to)} '" + ( - channelInfo.picture - ?: "" - ) + "'" - - CreateTextWithEmoji( - text = text, - tags = note.author?.info?.latestMetadata?.tags - ) - } else { - val eventContent = accountViewModel.decrypt(note) - - val canPreview = note.author == accountUser || - (note.author?.let { accountUser.isFollowingCached(it) } ?: true) || - !noteForReports.hasAnyReports() - - if (eventContent != null) { - TranslatableRichTextViewer( - eventContent, - canPreview, - Modifier.padding(top = 5.dp), - note.event?.tags(), - backgroundBubbleColor, - accountViewModel, - navController - ) - } else { - TranslatableRichTextViewer( - stringResource(R.string.could_not_decrypt_the_message), - true, - Modifier.padding(top = 5.dp), - note.event?.tags(), - backgroundBubbleColor, - accountViewModel, - navController - ) - } - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Column( modifier = Modifier - .padding(top = 5.dp) - .then( - with(LocalDensity.current) { - Modifier.widthIn( - bubbleSize.width.toDp(), - availableBubbleSize.width.toDp() + .padding(start = 10.dp, end = 5.dp, bottom = 5.dp) + .onSizeChanged { + bubbleSize = it + } + ) { + if ((innerQuote || note.author != loggedIn) && noteEvent is ChannelMessageEvent) { + DrawAuthorInfo( + baseNote, + alignment, + navController + ) + } + + val replyTo = note.replyTo + if (!innerQuote && !replyTo.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + replyTo.toSet().mapIndexed { _, note -> + ChatroomMessageCompose( + note, + null, + innerQuote = true, + parentBackgroundColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + navController = navController, + onWantsToReply = onWantsToReply ) } - ) - ) { - Row() { - Text( - timeAgoShort(note.createdAt(), context), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - fontSize = 12.sp - ) - - RelayBadges(note) - - Spacer(modifier = Modifier.width(10.dp)) + } } - Row() { - LikeReaction(baseNote, grayTint, accountViewModel) - Spacer(modifier = Modifier.width(5.dp)) - ZapReaction(baseNote, grayTint, accountViewModel) - Spacer(modifier = Modifier.width(5.dp)) - ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) { - onWantsToReply(baseNote) + Row(verticalAlignment = Alignment.CenterVertically) { + when (noteEvent) { + is ChannelCreateEvent -> { + RenderCreateChannelNote(note) + } + + is ChannelMetadataEvent -> { + RenderChangeChannelMetadataNote(note) + } + + else -> { + RenderRegularTextNote( + note, + loggedIn, + isAcceptableAndCanPreview.second, + backgroundBubbleColor, + accountViewModel, + navController + ) + } } } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(top = 5.dp) + .then( + with(LocalDensity.current) { + Modifier.widthIn( + bubbleSize.width.toDp(), + availableBubbleSize.width.toDp() + ) + } + ) + ) { + StatusRow( + baseNote, + accountViewModel, + onWantsToReply + ) + } } } } - } - NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) + NoteQuickActionMenu( + note, + popupExpanded, + { popupExpanded = false }, + accountViewModel + ) + } } } } } +@Composable +private fun StatusRow( + baseNote: Note, + accountViewModel: AccountViewModel, + onWantsToReply: (Note) -> Unit +) { + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + val time = remember { baseNote.createdAt() ?: 0 } + + Row() { + ChatTimeAgo(time) + RelayBadges(baseNote) + Spacer(modifier = Modifier.width(10.dp)) + } + + Row() { + LikeReaction(baseNote, grayTint, accountViewModel) + Spacer(modifier = Modifier.width(5.dp)) + ZapReaction(baseNote, grayTint, accountViewModel) + Spacer(modifier = Modifier.width(5.dp)) + ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) { + onWantsToReply(baseNote) + } + } +} + +@Composable +fun ChatTimeAgo(time: Long) { + val context = LocalContext.current + + var timeStr by remember { mutableStateOf("") } + + LaunchedEffect(key1 = time) { + withContext(Dispatchers.IO) { + timeStr = timeAgoShort(time, context = context) + } + } + + Text( + timeStr, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + fontSize = 12.sp + ) +} + +@Composable +private fun RenderRegularTextNote( + note: Note, + loggedIn: User, + canPreview: Boolean, + backgroundBubbleColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val tags = remember { note.event?.tags() } + val eventContent = remember { accountViewModel.decrypt(note) } + val modifier = remember { Modifier.padding(top = 5.dp) } + + if (eventContent != null) { + TranslatableRichTextViewer( + eventContent, + canPreview, + modifier, + tags, + backgroundBubbleColor, + accountViewModel, + navController + ) + } else { + TranslatableRichTextViewer( + stringResource(R.string.could_not_decrypt_the_message), + true, + modifier, + tags, + backgroundBubbleColor, + accountViewModel, + navController + ) + } +} + +@Composable +private fun RenderChangeChannelMetadataNote( + note: Note +) { + val noteEvent = note.event as? ChannelMetadataEvent ?: return + + val channelInfo = noteEvent.channelInfo() + val text = note.author?.toBestDisplayName() + .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + ( + channelInfo.name + ?: "" + ) + "', ${stringResource(R.string.description_to)} '" + ( + channelInfo.about + ?: "" + ) + "', ${stringResource(R.string.and_picture_to)} '" + ( + channelInfo.picture + ?: "" + ) + "'" + + CreateTextWithEmoji( + text = text, + tags = note.author?.info?.latestMetadata?.tags + ) +} + +@Composable +private fun RenderCreateChannelNote(note: Note) { + val noteEvent = note.event as? ChannelCreateEvent ?: return + val channelInfo = remember { noteEvent.channelInfo() } + + val text = note.author?.toBestDisplayName() + .toString() + " ${stringResource(R.string.created)} " + ( + channelInfo.name + ?: "" + ) + " ${stringResource(R.string.with_description_of)} '" + ( + channelInfo.about + ?: "" + ) + "', ${stringResource(R.string.and_picture)} '" + ( + channelInfo.picture + ?: "" + ) + "'" + + CreateTextWithEmoji( + text = text, + tags = note.author?.info?.latestMetadata?.tags + ) +} + +@Composable +private fun DrawAuthorInfo( + baseNote: Note, + alignment: Arrangement.Horizontal, + navController: NavController +) { + val userState by baseNote.author!!.live().metadata.observeAsState() + + val pubkeyHex = remember { baseNote.author?.pubkeyHex } ?: return + val route = remember { "User/$pubkeyHex" } + val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } + val userProfilePicture = remember(userState) { ResizeImage(userState?.user?.profilePicture(), 25.dp) } + val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = alignment, + modifier = Modifier.padding(top = 5.dp) + ) { + RobohashAsyncImageProxy( + robot = pubkeyHex, + model = userProfilePicture, + contentDescription = stringResource(id = R.string.profile_image), + modifier = Modifier + .width(25.dp) + .height(25.dp) + .clip(shape = CircleShape) + .clickable(onClick = { + navController.navigate(route) + }) + ) + + CreateClickableTextWithEmoji( + clickablePart = " $userDisplayName", + suffix = "", + tags = userTags, + fontWeight = FontWeight.Bold, + overrideColor = MaterialTheme.colors.onBackground, + route = route, + navController = navController + ) + } +} + @Composable private fun RelayBadges(baseNote: Note) { val noteRelaysState by baseNote.live().relays.observeAsState() - val noteRelays = noteRelaysState?.note?.relays ?: emptySet() + val noteRelays = remember(noteRelaysState) { noteRelaysState?.note?.relays ?: emptySet() } + val noteRelaysSimple = remember(noteRelaysState) { noteRelaysState?.note?.relays?.take(3) ?: emptySet() } var expanded by remember { mutableStateOf(false) } - val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3) - - val uri = LocalUriHandler.current + val relaysToDisplay by remember { + derivedStateOf { + if (expanded) noteRelays else noteRelaysSimple + } + } FlowRow(Modifier.padding(start = 10.dp)) { relaysToDisplay.forEach { - val url = it.removePrefix("wss://").removePrefix("ws://") - Box( - Modifier - .size(15.dp) - .padding(1.dp) - ) { - RobohashFallbackAsyncImage( - robot = "https://$url/favicon.ico", - robotSize = 15.dp, - model = "https://$url/favicon.ico", - contentDescription = stringResource(id = R.string.relay_icon), - colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), - modifier = Modifier - .fillMaxSize(1f) - .clip(shape = CircleShape) - .background(MaterialTheme.colors.background) - .clickable(onClick = { uri.openUri("https://$url") }) - ) - } + RenderRelay(it) } if (noteRelays.size > 3 && !expanded) { @@ -413,3 +515,37 @@ private fun RelayBadges(baseNote: Note) { } } } + +@Composable +private fun RenderRelay(dirtyUrl: String) { + val uri = LocalUriHandler.current + val website = remember { + val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://") + "https://$cleanUrl" + } + val iconUrl = remember { + val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://") + "https://$cleanUrl/favicon.ico" + } + + Box( + remember { + Modifier + .size(15.dp) + .padding(1.dp) + } + ) { + RobohashFallbackAsyncImage( + robot = iconUrl, + robotSize = 15.dp, + model = iconUrl, + contentDescription = stringResource(id = R.string.relay_icon), + colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), + modifier = Modifier + .fillMaxSize(1f) + .clip(shape = CircleShape) + .background(MaterialTheme.colors.background) + .clickable(onClick = { uri.openUri(website) }) + ) + } +}