Improves rendering performance for chats.

This commit is contained in:
Vitor Pamplona
2023-05-16 12:20:27 -04:00
parent 7a14a1ab26
commit 3950517743
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf 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.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize 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.NotificationCache
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note 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.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent 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.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage 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 com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp) val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp) val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
@@ -82,26 +88,50 @@ fun ChatroomMessageCompose(
navController: NavController, navController: NavController,
onWantsToReply: (Note) -> Unit onWantsToReply: (Note) -> Unit
) { ) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState() 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 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 popupExpanded by remember { mutableStateOf(false) }
if (noteEvent == null) {
BlankNote(Modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true }
))
note?.let {
NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel)
}
} else {
var showHiddenNote by remember { mutableStateOf(false) } var showHiddenNote by remember { mutableStateOf(false) }
var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) }
val context = LocalContext.current.applicationContext LaunchedEffect(key1 = noteReportsState, key2 = accountState) {
val scope = rememberCoroutineScope() withContext(Dispatchers.IO) {
account.userProfile().let { loggedIn ->
val newCanPreview = note.author === loggedIn ||
(note.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
!(noteForReports.hasAnyReports())
if (note?.event == null) { val newIsAcceptable = account.isAcceptable(noteForReports)
BlankNote(Modifier)
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { if (newIsAcceptable != isAcceptableAndCanPreview.first && newCanPreview != isAcceptableAndCanPreview.second) {
isAcceptableAndCanPreview = Pair(newIsAcceptable, newCanPreview)
}
}
}
}
if (!isAcceptableAndCanPreview.first && !showHiddenNote) {
if (!account.isHidden(noteForReports.author!!)) { if (!account.isHidden(noteForReports.author!!)) {
HiddenNote( HiddenNote(
account.getRelevantReports(noteForReports), account.getRelevantReports(noteForReports),
@@ -112,30 +142,27 @@ fun ChatroomMessageCompose(
onClick = { showHiddenNote = true } onClick = { showHiddenNote = true }
) )
} }
} else { } else {
var backgroundBubbleColor: Color val backgroundBubbleColor: Color
var alignment: Arrangement.Horizontal val alignment: Arrangement.Horizontal
var shape: Shape val shape: Shape
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) if (note.author == loggedIn) {
if (note.author == accountUser) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
.compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background)
alignment = Arrangement.End alignment = Arrangement.End
shape = ChatBubbleShapeMe shape = ChatBubbleShapeMe
} else { } else {
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
.compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background)
alignment = Arrangement.Start alignment = Arrangement.Start
shape = ChatBubbleShapeThem shape = ChatBubbleShapeThem
} }
if (parentBackgroundColor != null) { val scope = rememberCoroutineScope()
backgroundBubbleColor = backgroundBubbleColor.compositeOver(parentBackgroundColor)
} else {
backgroundBubbleColor = backgroundBubbleColor.compositeOver(MaterialTheme.colors.background)
}
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = routeForLastRead) { LaunchedEffect(key1 = routeForLastRead) {
routeForLastRead?.let { routeForLastRead?.let {
@@ -145,14 +172,14 @@ fun ChatroomMessageCompose(
val createdAt = note.createdAt() val createdAt = note.createdAt()
if (createdAt != null) { if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt) NotificationCache.markAsRead(it, createdAt)
isNew = createdAt > lastTime
} }
} }
} }
} }
Column() { Column() {
val modif = if (innerQuote) { val modif = remember {
if (innerQuote) {
Modifier.padding(top = 10.dp, end = 5.dp) Modifier.padding(top = 10.dp, end = 5.dp)
} else { } else {
Modifier Modifier
@@ -164,6 +191,7 @@ fun ChatroomMessageCompose(
bottom = 5.dp bottom = 5.dp
) )
} }
}
Row( Row(
modifier = modif, modifier = modif,
@@ -196,40 +224,12 @@ fun ChatroomMessageCompose(
bubbleSize = it bubbleSize = it
} }
) { ) {
val authorState by note.author!!.live().metadata.observeAsState() if ((innerQuote || note.author != loggedIn) && noteEvent is ChannelMessageEvent) {
val author = authorState?.user!! DrawAuthorInfo(
baseNote,
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) { alignment,
Row( navController
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 val replyTo = note.replyTo
@@ -250,66 +250,20 @@ fun ChatroomMessageCompose(
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
val event = note.event when (noteEvent) {
if (event is ChannelCreateEvent) { is ChannelCreateEvent -> {
val channelInfo = event.channelInfo() RenderCreateChannelNote(note)
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( is ChannelMetadataEvent -> {
text = text, RenderChangeChannelMetadataNote(note)
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( else -> {
text = text, RenderRegularTextNote(
tags = note.author?.info?.latestMetadata?.tags note,
) loggedIn,
} else { isAcceptableAndCanPreview.second,
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, backgroundBubbleColor,
accountViewModel, accountViewModel,
navController navController
@@ -332,15 +286,40 @@ fun ChatroomMessageCompose(
} }
) )
) { ) {
Row() { StatusRow(
Text( baseNote,
timeAgoShort(note.createdAt(), context), accountViewModel,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), onWantsToReply
fontSize = 12.sp
) )
}
}
}
}
RelayBadges(note) 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)) Spacer(modifier = Modifier.width(10.dp))
} }
@@ -353,49 +332,172 @@ fun ChatroomMessageCompose(
onWantsToReply(baseNote) 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)
} }
} }
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) 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 @Composable
private fun RelayBadges(baseNote: Note) { private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState() 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) } var expanded by remember { mutableStateOf(false) }
val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3) val relaysToDisplay by remember {
derivedStateOf {
val uri = LocalUriHandler.current if (expanded) noteRelays else noteRelaysSimple
}
}
FlowRow(Modifier.padding(start = 10.dp)) { FlowRow(Modifier.padding(start = 10.dp)) {
relaysToDisplay.forEach { relaysToDisplay.forEach {
val url = it.removePrefix("wss://").removePrefix("ws://") RenderRelay(it)
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") })
)
}
} }
if (noteRelays.size > 3 && !expanded) { 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) })
)
}
}