Display most recent activity in room list (#220)
* Create `RoomLastMessageFormatter` to produce readable room summaries. * Add unit tests using Robolectric, fix bugs * Add changelog * Move `RoomLastMessageFormatter` back to `impl` module, allow it to receive an `EventTimelineItem` instead of `MessageContent`.
This commit is contained in:
parent
2c478f0e49
commit
d9183e4092
24 changed files with 1311 additions and 48 deletions
|
|
@ -0,0 +1,330 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultRoomLastMessageFormatter @Inject constructor(
|
||||
// TODO replace with StringProvider
|
||||
@ApplicationContext private val context: Context,
|
||||
private val matrixClient: MatrixClient,
|
||||
) : RoomLastMessageFormatter {
|
||||
|
||||
override fun processMessageItem(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? {
|
||||
val isOutgoing = event.sender == matrixClient.sessionId
|
||||
val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value
|
||||
return when (val content = event.content) {
|
||||
is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom)
|
||||
RedactedContent -> {
|
||||
val message = context.getString(StringR.string.event_redacted)
|
||||
if (!isDmRoom) {
|
||||
prefix(message, senderDisplayName)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
is StickerContent -> {
|
||||
content.body
|
||||
}
|
||||
is UnableToDecryptContent -> {
|
||||
val message = context.getString(StringR.string.encryption_information_decryption_error)
|
||||
if (!isDmRoom) {
|
||||
prefix(message, senderDisplayName)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
is RoomMembershipContent -> {
|
||||
processRoomMembershipChange(content, senderDisplayName, isOutgoing)
|
||||
}
|
||||
is ProfileChangeContent -> {
|
||||
processProfileChangeContent(content, senderDisplayName, isOutgoing)
|
||||
}
|
||||
is StateContent -> {
|
||||
processRoomStateChange(content, senderDisplayName, isOutgoing)
|
||||
}
|
||||
is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> {
|
||||
prefixIfNeeded(context.getString(StringR.string.room_timeline_item_unsupported), senderDisplayName, isDmRoom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processMessageContents(messageContent: MessageContent, senderDisplayName: String, isDmRoom: Boolean): CharSequence? {
|
||||
val messageType: MessageType = messageContent.type ?: return null
|
||||
|
||||
val internalMessage = when (messageType) {
|
||||
// Doesn't need a prefix
|
||||
is EmoteMessageType -> {
|
||||
return "- $senderDisplayName ${messageType.body}"
|
||||
}
|
||||
is TextMessageType -> {
|
||||
messageType.body
|
||||
}
|
||||
is VideoMessageType -> {
|
||||
context.getString(StringR.string.sent_a_video)
|
||||
}
|
||||
is ImageMessageType -> {
|
||||
context.getString(StringR.string.sent_an_image)
|
||||
}
|
||||
is FileMessageType -> {
|
||||
context.getString(StringR.string.sent_a_file)
|
||||
}
|
||||
is AudioMessageType -> {
|
||||
context.getString(StringR.string.sent_an_audio_file)
|
||||
}
|
||||
UnknownMessageType -> {
|
||||
context.getString(StringR.string.unknown_message_content_type_error)
|
||||
}
|
||||
is NoticeMessageType -> {
|
||||
messageType.body
|
||||
}
|
||||
}
|
||||
return prefixIfNeeded(internalMessage, senderDisplayName, isDmRoom)
|
||||
}
|
||||
|
||||
private fun processRoomMembershipChange(membershipContent: RoomMembershipContent, senderDisplayName: String, senderIsYou: Boolean): CharSequence? {
|
||||
val userId = membershipContent.userId
|
||||
val memberIsYou = userId == matrixClient.sessionId
|
||||
return when (val change = membershipContent.change) {
|
||||
MembershipChange.JOINED -> if (memberIsYou) {
|
||||
context.getString(StringR.string.notice_room_join_by_you)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_join, userId.value)
|
||||
}
|
||||
MembershipChange.LEFT -> if (memberIsYou) {
|
||||
context.getString(StringR.string.notice_room_leave_by_you)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_leave, userId.value)
|
||||
}
|
||||
MembershipChange.BANNED, MembershipChange.KICKED_AND_BANNED -> if (senderIsYou) {
|
||||
context.getString(StringR.string.notice_room_ban_by_you, userId.value)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_ban, senderDisplayName, userId.value)
|
||||
}
|
||||
MembershipChange.UNBANNED -> if (senderIsYou) {
|
||||
context.getString(StringR.string.notice_room_unban_by_you, userId.value)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_unban, senderDisplayName, userId.value)
|
||||
}
|
||||
MembershipChange.KICKED -> if (senderIsYou) {
|
||||
context.getString(StringR.string.notice_room_remove_by_you, userId.value)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_remove, senderDisplayName, userId.value)
|
||||
}
|
||||
MembershipChange.INVITED -> if (senderIsYou) {
|
||||
context.getString(StringR.string.notice_room_invite_by_you, userId.value)
|
||||
} else if (memberIsYou) {
|
||||
context.getString(StringR.string.notice_room_invite_you, senderDisplayName)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_invite, senderDisplayName, userId.value)
|
||||
}
|
||||
MembershipChange.INVITATION_ACCEPTED -> if (memberIsYou) {
|
||||
context.getString(StringR.string.notice_room_invite_accepted_by_you)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_invite_accepted, userId.value)
|
||||
}
|
||||
MembershipChange.INVITATION_REJECTED -> if (memberIsYou) {
|
||||
context.getString(StringR.string.notice_room_reject_by_you)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_reject, userId.value)
|
||||
}
|
||||
MembershipChange.INVITATION_REVOKED -> if (senderIsYou) {
|
||||
context.getString(StringR.string.notice_room_third_party_revoked_invite_by_you, userId.value)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_third_party_revoked_invite, senderDisplayName, userId.value)
|
||||
}
|
||||
MembershipChange.KNOCKED -> if (memberIsYou) {
|
||||
context.getString(StringR.string.notice_room_knock_by_you)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_knock, userId.value)
|
||||
}
|
||||
MembershipChange.KNOCK_ACCEPTED -> if (senderIsYou) {
|
||||
context.getString(StringR.string.notice_room_knock_accepted_by_you, userId.value)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_knock_accepted, senderDisplayName, userId.value)
|
||||
}
|
||||
MembershipChange.KNOCK_RETRACTED -> if (memberIsYou) {
|
||||
context.getString(StringR.string.notice_room_knock_retracted_by_you)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_knock_retracted, userId.value)
|
||||
}
|
||||
MembershipChange.KNOCK_DENIED -> if (senderIsYou) {
|
||||
context.getString(StringR.string.notice_room_knock_denied_by_you, userId.value)
|
||||
} else if (memberIsYou) {
|
||||
context.getString(StringR.string.notice_room_knock_denied_you, senderDisplayName)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_knock_denied, senderDisplayName, userId.value)
|
||||
}
|
||||
else -> {
|
||||
Timber.v("Filtering timeline item for room membership: $membershipContent")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processRoomStateChange(stateContent: StateContent, senderDisplayName: String, senderIsYou: Boolean): CharSequence? {
|
||||
return when (val content = stateContent.content) {
|
||||
is OtherState.RoomAvatar -> {
|
||||
val hasAvatarUrl = content.url != null
|
||||
when {
|
||||
senderIsYou && hasAvatarUrl -> context.getString(StringR.string.notice_room_avatar_changed_by_you)
|
||||
senderIsYou && !hasAvatarUrl -> context.getString(StringR.string.notice_room_avatar_removed_by_you)
|
||||
!senderIsYou && hasAvatarUrl -> context.getString(StringR.string.notice_room_avatar_changed, senderDisplayName)
|
||||
else -> context.getString(StringR.string.notice_room_avatar_removed, senderDisplayName)
|
||||
}
|
||||
}
|
||||
is OtherState.RoomCreate -> {
|
||||
if (senderIsYou) {
|
||||
context.getString(StringR.string.notice_room_created_by_you)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_created, senderDisplayName)
|
||||
}
|
||||
}
|
||||
is OtherState.RoomEncryption -> context.getString(StringR.string.encryption_enabled)
|
||||
is OtherState.RoomName -> {
|
||||
val hasRoomName = content.name != null
|
||||
when {
|
||||
senderIsYou && hasRoomName -> context.getString(StringR.string.notice_room_name_changed_by_you, content.name)
|
||||
senderIsYou && !hasRoomName -> context.getString(StringR.string.notice_room_name_removed_by_you)
|
||||
!senderIsYou && hasRoomName -> context.getString(StringR.string.notice_room_name_changed, senderDisplayName, content.name)
|
||||
else -> context.getString(StringR.string.notice_room_name_removed, senderDisplayName)
|
||||
}
|
||||
}
|
||||
is OtherState.RoomThirdPartyInvite -> {
|
||||
if (content.displayName == null) {
|
||||
Timber.e("RoomThirdPartyInvite undisplayable due to missing name")
|
||||
return null
|
||||
}
|
||||
if (senderIsYou) {
|
||||
context.getString(StringR.string.notice_room_third_party_invite_by_you, content.displayName)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_room_third_party_invite, senderDisplayName, content.displayName)
|
||||
}
|
||||
}
|
||||
is OtherState.RoomTopic -> {
|
||||
val hasRoomTopic = content.topic != null
|
||||
when {
|
||||
senderIsYou && hasRoomTopic -> context.getString(StringR.string.notice_room_topic_changed_by_you, content.topic)
|
||||
senderIsYou && !hasRoomTopic -> context.getString(StringR.string.notice_room_topic_removed_by_you)
|
||||
!senderIsYou && hasRoomTopic -> context.getString(StringR.string.notice_room_topic_changed, senderDisplayName, content.topic)
|
||||
else -> context.getString(StringR.string.notice_room_topic_removed, senderDisplayName)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Timber.v("Filtering timeline item for room state change: $content")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processProfileChangeContent(
|
||||
profileChangeContent: ProfileChangeContent,
|
||||
senderDisplayName: String,
|
||||
senderIsYou: Boolean
|
||||
): String? = profileChangeContent.run {
|
||||
val displayNameChanged = displayName != prevDisplayName
|
||||
val avatarChanged = avatarUrl != prevAvatarUrl
|
||||
return when {
|
||||
avatarChanged && displayNameChanged -> {
|
||||
val message = processProfileChangeContent(profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null), senderDisplayName, senderIsYou)
|
||||
val avatarChangedToo = context.getString(StringR.string.notice_avatar_changed_too)
|
||||
"$message\n$avatarChangedToo"
|
||||
}
|
||||
displayNameChanged -> {
|
||||
if (displayName != null && prevDisplayName != null) {
|
||||
if (senderIsYou) {
|
||||
context.getString(StringR.string.notice_display_name_changed_from_by_you, prevDisplayName, displayName)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_display_name_changed_from, senderDisplayName, prevDisplayName, displayName)
|
||||
}
|
||||
} else if (displayName != null) {
|
||||
if (senderIsYou) {
|
||||
context.getString(StringR.string.notice_display_name_set_by_you, displayName)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_display_name_set, senderDisplayName, displayName)
|
||||
}
|
||||
} else {
|
||||
if (senderIsYou) {
|
||||
context.getString(StringR.string.notice_display_name_removed_by_you, prevDisplayName)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_display_name_removed, senderDisplayName, prevDisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
avatarChanged -> {
|
||||
if (senderIsYou) {
|
||||
context.getString(StringR.string.notice_avatar_url_changed_by_you)
|
||||
} else {
|
||||
context.getString(StringR.string.notice_avatar_url_changed, senderDisplayName)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun prefixIfNeeded(message: String, senderDisplayName: String, isDmRoom: Boolean): CharSequence = if (isDmRoom) {
|
||||
message
|
||||
} else {
|
||||
prefix(message, senderDisplayName)
|
||||
}
|
||||
|
||||
private fun prefix(message: String, senderDisplayName: String): AnnotatedString {
|
||||
return buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(senderDisplayName)
|
||||
}
|
||||
append(": ")
|
||||
append(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
|
||||
interface RoomLastMessageFormatter {
|
||||
fun processMessageItem(event: EventTimelineItem, isDmRoom: Boolean): CharSequence?
|
||||
}
|
||||
|
|
@ -30,15 +30,16 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
|||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.parallelMap
|
||||
import io.element.android.libraries.dateformatter.api.LastMessageFormatter
|
||||
import io.element.android.libraries.core.extensions.orEmpty
|
||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomSummary
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -52,7 +53,8 @@ private const val extendedRangeSize = 40
|
|||
|
||||
class RoomListPresenter @Inject constructor(
|
||||
private val client: MatrixClient,
|
||||
private val lastMessageFormatter: LastMessageFormatter,
|
||||
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
|
||||
private val roomLastMessageFormatter: RoomLastMessageFormatter,
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
) : Presenter<RoomListState> {
|
||||
|
||||
|
|
@ -169,8 +171,10 @@ class RoomListPresenter @Inject constructor(
|
|||
id = roomSummary.identifier(),
|
||||
name = roomSummary.details.name,
|
||||
hasUnread = roomSummary.details.unreadNotificationCount > 0,
|
||||
timestamp = lastMessageFormatter.format(roomSummary.details.lastMessageTimestamp),
|
||||
lastMessage = roomSummary.details.lastMessage,
|
||||
timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp),
|
||||
lastMessage = roomSummary.details.lastMessage?.let { message ->
|
||||
roomLastMessageFormatter.processMessageItem(message.event, roomSummary.details.isDirect)
|
||||
}.orEmpty(),
|
||||
avatarData = avatarData,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,8 +67,6 @@ import io.element.android.libraries.designsystem.theme.components.Surface
|
|||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import io.element.android.libraries.designsystem.R as DrawableR
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -55,6 +56,7 @@ import androidx.compose.ui.unit.sp
|
|||
import com.google.accompanist.placeholder.material.placeholder
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvider
|
||||
import io.element.android.libraries.core.extensions.orEmpty
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
|
|
@ -133,13 +135,15 @@ internal fun DefaultRoomSummaryRow(
|
|||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
// Last Message
|
||||
val attributedLastMessage = (room.lastMessage as? AnnotatedString)
|
||||
?: AnnotatedString(room.lastMessage.orEmpty().toString())
|
||||
Text(
|
||||
modifier = Modifier.placeholder(
|
||||
visible = room.isPlaceholder,
|
||||
shape = TextPlaceholderShape,
|
||||
color = ElementTheme.colors.roomListPlaceHolder(),
|
||||
),
|
||||
text = room.lastMessage?.toString().orEmpty(),
|
||||
text = attributedLastMessage,
|
||||
color = MaterialTheme.roomListRoomMessage(),
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue