Timeline: add avatar and displayname (not dynamic...)

This commit is contained in:
ganfra 2022-11-08 16:09:24 +01:00
parent 560d2a3291
commit c8b8c08c99
14 changed files with 206 additions and 93 deletions

View file

@ -1,8 +1,11 @@
package io.element.android.x.features.messages package io.element.android.x.features.messages
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.designsystem.components.avatar.AvatarSize
import io.element.android.x.features.messages.model.MessagesItemGroupPosition import io.element.android.x.features.messages.model.MessagesItemGroupPosition
import io.element.android.x.features.messages.model.MessagesTimelineItemState import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.matrix.core.UserId import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.media.MediaResolver
import io.element.android.x.matrix.room.MatrixRoom import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.matrix.timeline.MatrixTimelineItem import io.element.android.x.matrix.timeline.MatrixTimelineItem
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@ -10,7 +13,7 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.MessageType import org.matrix.rustcomponents.sdk.MessageType
class MessageTimelineItemStateMapper( class MessageTimelineItemStateMapper(
private val myUserId: UserId, private val client: MatrixClient,
private val room: MatrixRoom, private val room: MatrixRoom,
private val dispatcher: CoroutineDispatcher, private val dispatcher: CoroutineDispatcher,
) { ) {
@ -22,36 +25,7 @@ class MessageTimelineItemStateMapper(
val currentTimelineItem = timelineItems[index] val currentTimelineItem = timelineItems[index]
val timelineItemState = when (currentTimelineItem) { val timelineItemState = when (currentTimelineItem) {
is MatrixTimelineItem.Event -> { is MatrixTimelineItem.Event -> {
val prevTimelineItem = buildMessageEvent(currentTimelineItem, index, timelineItems)
timelineItems.getOrNull(index - 1) as? MatrixTimelineItem.Event
val nextTimelineItem =
timelineItems.getOrNull(index + 1) as? MatrixTimelineItem.Event
val currentSender = currentTimelineItem.event.sender()
val previousSender = prevTimelineItem?.event?.sender()
val nextSender = nextTimelineItem?.event?.sender()
val groupPosition = when {
previousSender != currentSender && nextSender == currentSender -> MessagesItemGroupPosition.First
previousSender == currentSender && nextSender == currentSender -> MessagesItemGroupPosition.Middle
previousSender == currentSender && nextSender != currentSender -> MessagesItemGroupPosition.Last
else -> MessagesItemGroupPosition.None
}
val messageType =
currentTimelineItem.event.content().asMessage()?.msgtype()
val contentStr = when (messageType) {
is MessageType.Emote -> messageType.content.body
is MessageType.Image -> messageType.content.body
is MessageType.Notice -> messageType.content.body
is MessageType.Text -> messageType.content.body
null -> null
}
MessagesTimelineItemState.MessageEvent(
id = currentTimelineItem.event.eventId() ?: "",
sender = currentTimelineItem.event.sender(),
content = contentStr,
isMine = currentTimelineItem.event.isOwn(),
groupPosition = groupPosition
)
} }
is MatrixTimelineItem.Virtual -> MessagesTimelineItemState.Virtual( is MatrixTimelineItem.Virtual -> MessagesTimelineItemState.Virtual(
"virtual_item_$index" "virtual_item_$index"
@ -63,5 +37,72 @@ class MessageTimelineItemStateMapper(
messagesTimelineItemState messagesTimelineItemState
} }
private suspend fun buildMessageEvent(
currentTimelineItem: MatrixTimelineItem.Event,
index: Int,
timelineItems: List<MatrixTimelineItem>
): MessagesTimelineItemState.MessageEvent {
val currentSender = currentTimelineItem.event.sender()
val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index)
val senderDisplayName = room.userDisplayName(currentSender).getOrNull()
val senderAvatarUrl = room.userAvatarUrl(currentSender).getOrNull()
val senderAvatarData =
loadAvatarData(senderDisplayName ?: currentSender, senderAvatarUrl)
return MessagesTimelineItemState.MessageEvent(
id = currentTimelineItem.event.eventId() ?: "",
senderId = currentSender,
senderDisplayName = senderDisplayName,
senderAvatar = senderAvatarData,
content = currentTimelineItem.computeContent(),
isMine = currentTimelineItem.event.isOwn(),
groupPosition = groupPosition
)
}
private fun MatrixTimelineItem.Event.computeContent(): String? {
val messageType =
event.content().asMessage()?.msgtype()
return when (messageType) {
is MessageType.Emote -> messageType.content.body
is MessageType.Image -> messageType.content.body
is MessageType.Notice -> messageType.content.body
is MessageType.Text -> messageType.content.body
null -> null
}
}
private fun computeGroupPosition(
currentTimelineItem: MatrixTimelineItem.Event,
timelineItems: List<MatrixTimelineItem>,
index: Int
): MessagesItemGroupPosition {
val prevTimelineItem =
timelineItems.getOrNull(index - 1) as? MatrixTimelineItem.Event
val nextTimelineItem =
timelineItems.getOrNull(index + 1) as? MatrixTimelineItem.Event
val currentSender = currentTimelineItem.event.sender()
val previousSender = prevTimelineItem?.event?.sender()
val nextSender = nextTimelineItem?.event?.sender()
return when {
previousSender != currentSender && nextSender == currentSender -> MessagesItemGroupPosition.First
previousSender == currentSender && nextSender == currentSender -> MessagesItemGroupPosition.Middle
previousSender == currentSender && nextSender != currentSender -> MessagesItemGroupPosition.Last
else -> MessagesItemGroupPosition.None
}
}
private suspend fun loadAvatarData(
name: String,
url: String?,
size: AvatarSize = AvatarSize.SMALL
): AvatarData {
val model = client.mediaResolver()
.resolve(url, kind = MediaResolver.Kind.Thumbnail(size.value))
return AvatarData(name, model, size)
}
} }

View file

@ -22,6 +22,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.layout.LastBaseline
@ -29,6 +30,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.core.data.LogCompositions import io.element.android.x.core.data.LogCompositions
@ -170,47 +172,51 @@ fun MessageEventRow(
.wrapContentHeight(), .wrapContentHeight(),
contentAlignment = contentAlignment contentAlignment = contentAlignment
) { ) {
Row(modifier = modifier Row(
.widthIn(max = 300.dp) modifier = modifier
.clickable( .widthIn(max = 300.dp)
onClick = { }, ) {
indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() }
)) {
if (!messageEvent.isMine) { if (!messageEvent.isMine) {
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
} }
Column { Column {
if (messageEvent.showSenderInformation) { if (messageEvent.showSenderInformation) {
MessageSenderInformation(messageEvent.sender, messageEvent.senderAvatar) MessageSenderInformation(
messageEvent.safeSenderName,
messageEvent.senderAvatar,
Modifier.zIndex(1f)
)
} }
MessageEventBubble(messageEvent) MessageEventBubble(messageEvent, Modifier.zIndex(-1f))
} }
if (messageEvent.isMine) { if (messageEvent.isMine) {
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
} }
} }
} }
if (messageEvent.groupPosition is MessagesItemGroupPosition.First) { if (messageEvent.groupPosition.isNew()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(8.dp))
} else { } else {
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
} }
} }
@Composable @Composable
private fun MessageSenderInformation(sender: String, senderAvatar: AvatarData?) { private fun MessageSenderInformation(
Row { sender: String,
senderAvatar: AvatarData?,
modifier: Modifier = Modifier
) {
Row(modifier = modifier) {
if (senderAvatar != null) { if (senderAvatar != null) {
Avatar(senderAvatar) Avatar(senderAvatar)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(4.dp))
} }
Text( Text(
text = sender, text = sender,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier modifier = Modifier
.alignBy(LastBaseline) .alignBy(LastBaseline)
.paddingFrom(LastBaseline, after = 8.dp)
) )
} }
} }
@ -218,6 +224,7 @@ private fun MessageSenderInformation(sender: String, senderAvatar: AvatarData?)
@Composable @Composable
fun MessageEventBubble( fun MessageEventBubble(
messageEvent: MessagesTimelineItemState.MessageEvent, messageEvent: MessagesTimelineItemState.MessageEvent,
modifier: Modifier = Modifier,
) { ) {
fun MessagesTimelineItemState.MessageEvent.bubbleShape(): Shape { fun MessagesTimelineItemState.MessageEvent.bubbleShape(): Shape {
@ -247,10 +254,28 @@ fun MessageEventBubble(
} else { } else {
Pair(Color.Transparent, BorderStroke(1.dp, MaterialTheme.colorScheme.surfaceVariant)) Pair(Color.Transparent, BorderStroke(1.dp, MaterialTheme.colorScheme.surfaceVariant))
} }
fun Modifier.offsetForItem(messageEvent: MessagesTimelineItemState.MessageEvent): Modifier {
return if (messageEvent.isMine) {
offset(y = -(12.dp))
} else {
offset(x = 20.dp, y = -(12.dp))
}
}
val bubbleShape = remember { messageEvent.bubbleShape() }
Surface( Surface(
modifier = Modifier.widthIn(min = 80.dp), modifier = modifier
.widthIn(min = 80.dp)
.offsetForItem(messageEvent)
.clip(bubbleShape)
.clickable(
onClick = { },
indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() }
),
color = backgroundBubbleColor, color = backgroundBubbleColor,
shape = messageEvent.bubbleShape(), shape = bubbleShape,
border = border border = border
) { ) {
Text( Text(

View file

@ -8,6 +8,7 @@ import io.element.android.x.designsystem.components.avatar.AvatarSize
import io.element.android.x.features.messages.model.MessagesViewState import io.element.android.x.features.messages.model.MessagesViewState
import io.element.android.x.matrix.MatrixClient import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.MatrixInstance import io.element.android.x.matrix.MatrixInstance
import io.element.android.x.matrix.media.MediaResolver
import io.element.android.x.matrix.room.MatrixRoom import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.matrix.timeline.MatrixTimeline import io.element.android.x.matrix.timeline.MatrixTimeline
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -15,7 +16,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
private const val PAGINATION_COUNT = 50 private const val PAGINATION_COUNT = 50
@ -38,7 +38,7 @@ class MessagesViewModel(
val client = matrix.activeClient() val client = matrix.activeClient()
val room = client.getRoom(state.roomId) ?: return null val room = client.getRoom(state.roomId) ?: return null
val messageTimelineItemStateMapper = val messageTimelineItemStateMapper =
MessageTimelineItemStateMapper(client.userId(), room, Dispatchers.Default) MessageTimelineItemStateMapper(client, room, Dispatchers.Default)
return MessagesViewModel( return MessagesViewModel(
client, client,
room, room,
@ -84,20 +84,9 @@ class MessagesViewModel(
url: String?, url: String?,
size: AvatarSize = AvatarSize.MEDIUM size: AvatarSize = AvatarSize.MEDIUM
): AvatarData { ): AvatarData {
val mediaContent = url?.let { val model = client.mediaResolver()
val mediaSource = mediaSourceFromUrl(it) .resolve(url, kind = MediaResolver.Kind.Thumbnail(size.value))
client.loadMediaThumbnailForSource( return AvatarData(name, model, size)
mediaSource,
size.value.toLong(),
size.value.toLong()
)
}
return mediaContent?.fold(
{ it },
{ null }
).let { model ->
AvatarData(name.first().uppercase(), model, size)
}
} }
override fun onCleared() { override fun onCleared() {

View file

@ -6,4 +6,9 @@ sealed interface MessagesItemGroupPosition {
object Last : MessagesItemGroupPosition object Last : MessagesItemGroupPosition
object None : MessagesItemGroupPosition object None : MessagesItemGroupPosition
fun isNew(): Boolean = when (this) {
First, None -> true
else -> false
}
} }

View file

@ -9,18 +9,19 @@ sealed interface MessagesTimelineItemState {
data class MessageEvent( data class MessageEvent(
val id: String = "", val id: String = "",
val sender: String = "", val senderId: String,
val senderAvatar: AvatarData? = null, val senderDisplayName: String?,
val senderAvatar: AvatarData,
val content: String? = null, val content: String? = null,
val sentTime: String = "", val sentTime: String = "",
val isMine: Boolean = false, val isMine: Boolean = false,
val groupPosition: MessagesItemGroupPosition = MessagesItemGroupPosition.None val groupPosition: MessagesItemGroupPosition = MessagesItemGroupPosition.None
) : MessagesTimelineItemState { ) : MessagesTimelineItemState {
val showSenderInformation: Boolean = when (groupPosition) { val showSenderInformation = groupPosition.isNew() && !isMine
MessagesItemGroupPosition.First, MessagesItemGroupPosition.None -> !isMine
else -> false val safeSenderName: String = senderDisplayName ?: senderId
}
} }
} }

View file

@ -9,6 +9,7 @@ import io.element.android.x.features.roomlist.model.RoomListRoomSummary
import io.element.android.x.features.roomlist.model.RoomListViewState import io.element.android.x.features.roomlist.model.RoomListViewState
import io.element.android.x.matrix.MatrixClient import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.MatrixInstance import io.element.android.x.matrix.MatrixInstance
import io.element.android.x.matrix.media.MediaResolver
import io.element.android.x.matrix.room.RoomSummary import io.element.android.x.matrix.room.RoomSummary
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -103,20 +104,9 @@ class RoomListViewModel(initialState: RoomListViewState) :
url: String?, url: String?,
size: AvatarSize = AvatarSize.MEDIUM size: AvatarSize = AvatarSize.MEDIUM
): AvatarData { ): AvatarData {
val mediaContent = url?.let { val model = client.mediaResolver()
val mediaSource = mediaSourceFromUrl(it) .resolve(url, kind = MediaResolver.Kind.Thumbnail(size.value))
client.loadMediaThumbnailForSource( return AvatarData(name, model, size)
mediaSource,
size.value.toLong(),
size.value.toLong()
)
}
return mediaContent?.fold(
{ it },
{ null }
).let { model ->
AvatarData(name.first().uppercase(), model, size)
}
} }
private fun handleLogout() { private fun handleLogout() {

View file

@ -12,7 +12,6 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import io.element.android.x.designsystem.AvatarGradientEnd import io.element.android.x.designsystem.AvatarGradientEnd
@ -27,7 +26,7 @@ fun Avatar(avatarData: AvatarData, modifier: Modifier = Modifier) {
if (avatarData.model == null) { if (avatarData.model == null) {
InitialsAvatar( InitialsAvatar(
modifier = commonModifier, modifier = commonModifier,
initials = avatarData.initials initials = avatarData.name.first().uppercase()
) )
} else { } else {
ImageAvatar( ImageAvatar(

View file

@ -4,7 +4,7 @@ import androidx.compose.runtime.Stable
@Stable @Stable
data class AvatarData( data class AvatarData(
val initials: String = "", val name: String = "",
val model: ByteArray? = null, val model: ByteArray? = null,
val size: AvatarSize = AvatarSize.MEDIUM val size: AvatarSize = AvatarSize.MEDIUM
) { ) {
@ -14,7 +14,7 @@ data class AvatarData(
other as AvatarData other as AvatarData
if (initials != other.initials) return false if (name != other.name) return false
if (model != null) { if (model != null) {
if (other.model == null) return false if (other.model == null) return false
if (!model.contentEquals(other.model)) return false if (!model.contentEquals(other.model)) return false
@ -25,7 +25,7 @@ data class AvatarData(
} }
override fun hashCode(): Int { override fun hashCode(): Int {
var result = initials.hashCode() var result = name.hashCode()
result = 31 * result + (model?.contentHashCode() ?: 0) result = 31 * result + (model?.contentHashCode() ?: 0)
result = 31 * result + size.value result = 31 * result + size.value
return result return result

View file

@ -2,6 +2,8 @@ package io.element.android.x.matrix
import io.element.android.x.core.data.CoroutineDispatchers import io.element.android.x.core.data.CoroutineDispatchers
import io.element.android.x.matrix.core.UserId import io.element.android.x.matrix.core.UserId
import io.element.android.x.matrix.media.MediaResolver
import io.element.android.x.matrix.media.RustMediaResolver
import io.element.android.x.matrix.room.MatrixRoom import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.matrix.room.RoomSummaryDataSource import io.element.android.x.matrix.room.RoomSummaryDataSource
import io.element.android.x.matrix.room.RustRoomSummaryDataSource import io.element.android.x.matrix.room.RustRoomSummaryDataSource
@ -64,6 +66,8 @@ class MatrixClient internal constructor(
) )
private var slidingSyncObserverToken: StoppableSpawn? = null private var slidingSyncObserverToken: StoppableSpawn? = null
private val mediaResolver = RustMediaResolver(this)
init { init {
client.setDelegate(clientDelegate) client.setDelegate(clientDelegate)
} }
@ -93,6 +97,8 @@ class MatrixClient internal constructor(
fun roomSummaryDataSource(): RoomSummaryDataSource = roomSummaryDataSource fun roomSummaryDataSource(): RoomSummaryDataSource = roomSummaryDataSource
fun mediaResolver(): MediaResolver = mediaResolver
override fun close() { override fun close() {
stopSync() stopSync()
roomSummaryDataSource.close() roomSummaryDataSource.close()

View file

@ -1,4 +1,6 @@
package io.element.android.x.matrix.core package io.element.android.x.matrix.core
import java.io.Serializable
@JvmInline @JvmInline
value class EventId(val value: String) value class EventId(val value: String) : Serializable

View file

@ -1,4 +1,6 @@
package io.element.android.x.matrix.core package io.element.android.x.matrix.core
import java.io.Serializable
@JvmInline @JvmInline
value class RoomId(val value: String) value class RoomId(val value: String): Serializable

View file

@ -1,4 +1,6 @@
package io.element.android.x.matrix.core package io.element.android.x.matrix.core
import java.io.Serializable
@JvmInline @JvmInline
value class UserId(val value: String) value class UserId(val value: String): Serializable

View file

@ -0,0 +1,36 @@
package io.element.android.x.matrix.media
import io.element.android.x.matrix.MatrixClient
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
interface MediaResolver {
sealed interface Kind {
data class Thumbnail(val width: Int, val height: Int) : Kind {
constructor(size: Int) : this(size, size)
}
object Content : Kind
}
suspend fun resolve(url: String?, kind: Kind): ByteArray?
}
internal class RustMediaResolver(private val client: MatrixClient) : MediaResolver {
override suspend fun resolve(url: String?, kind: MediaResolver.Kind): ByteArray? {
if (url.isNullOrEmpty()) return null
val mediaSource = mediaSourceFromUrl(url)
return when (kind) {
is MediaResolver.Kind.Content -> client.loadMediaContentForSource(mediaSource)
is MediaResolver.Kind.Thumbnail -> client.loadMediaThumbnailForSource(
mediaSource,
kind.width.toLong(),
kind.height.toLong()
)
}.getOrNull()
}
}

View file

@ -2,11 +2,13 @@ package io.element.android.x.matrix.room
import io.element.android.x.core.data.CoroutineDispatchers import io.element.android.x.core.data.CoroutineDispatchers
import io.element.android.x.matrix.core.RoomId import io.element.android.x.matrix.core.RoomId
import io.element.android.x.matrix.core.UserId
import io.element.android.x.matrix.timeline.MatrixTimeline import io.element.android.x.matrix.timeline.MatrixTimeline
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.SlidingSyncRoom import org.matrix.rustcomponents.sdk.SlidingSyncRoom
import org.matrix.rustcomponents.sdk.UpdateSummary import org.matrix.rustcomponents.sdk.UpdateSummary
@ -53,5 +55,18 @@ class MatrixRoom(
return room.avatarUrl() return room.avatarUrl()
} }
suspend fun userDisplayName(userId: String): Result<String?> =
withContext(coroutineDispatchers.io) {
runCatching {
room.memberDisplayName(userId)
}
}
suspend fun userAvatarUrl(userId: String): Result<String?> =
withContext(coroutineDispatchers.io) {
runCatching {
room.memberAvatarUrl(userId)
}
}
} }