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.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<Boolean>(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) })
)
}
}