Merge pull request #678 from vector-im/feature/bma/designRoomList

Iterate on design
This commit is contained in:
Benoit Marty 2023-06-27 22:51:49 +02:00 committed by GitHub
commit b4a525422a
349 changed files with 2137 additions and 1049 deletions

View file

@ -222,6 +222,10 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onRoomSettingsClicked(roomId: RoomId) { override fun onRoomSettingsClicked(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId, initialElement = RoomFlowNode.NavTarget.RoomDetails)) backstack.push(NavTarget.Room(roomId, initialElement = RoomFlowNode.NavTarget.RoomDetails))
} }
override fun onReportBugClicked() {
plugins<Callback>().forEach { it.onOpenBugReport() }
}
} }
roomListEntryPoint roomListEntryPoint
.nodeBuilder(this, buildContext) .nodeBuilder(this, buildContext)

View file

@ -45,6 +45,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.deeplink)
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.usersearch.impl) implementation(projects.libraries.usersearch.impl)

View file

@ -20,9 +20,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
@ -40,7 +40,7 @@ fun SearchMultipleUsersResultItem(
CheckableUnresolvedUserRow( CheckableUnresolvedUserRow(
checked = isUserSelected, checked = isUserSelected,
modifier = modifier, modifier = modifier,
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.Custom(36.dp)), avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem),
id = searchResult.matrixUser.userId.value, id = searchResult.matrixUser.userId.value,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
) )
@ -49,7 +49,7 @@ fun SearchMultipleUsersResultItem(
checked = isUserSelected, checked = isUserSelected,
modifier = modifier, modifier = modifier,
matrixUser = searchResult.matrixUser, matrixUser = searchResult.matrixUser,
avatarSize = AvatarSize.Custom(36.dp), avatarSize = AvatarSize.UserListItem,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
) )
} }
@ -63,8 +63,11 @@ internal fun SearchMultipleUsersResultItemPreview() = ElementThemedPreview { Con
private fun ContentToPreview() { private fun ContentToPreview() {
Column { Column {
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = false) SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = false)
Divider()
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = true) SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = true)
Divider()
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = false) SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = false)
Divider()
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = true) SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = true)
} }
} }

View file

@ -21,9 +21,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.UnresolvedUserRow import io.element.android.libraries.matrix.ui.components.UnresolvedUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
@ -39,26 +39,27 @@ fun SearchSingleUserResultItem(
if (searchResult.isUnresolved) { if (searchResult.isUnresolved) {
UnresolvedUserRow( UnresolvedUserRow(
modifier = modifier.clickable(onClick = onClick), modifier = modifier.clickable(onClick = onClick),
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.Custom(36.dp)), avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem),
id = searchResult.matrixUser.userId.value, id = searchResult.matrixUser.userId.value,
) )
} else { } else {
MatrixUserRow( MatrixUserRow(
modifier = modifier.clickable(onClick = onClick), modifier = modifier.clickable(onClick = onClick),
matrixUser = searchResult.matrixUser, matrixUser = searchResult.matrixUser,
avatarSize = AvatarSize.Custom(36.dp), avatarSize = AvatarSize.UserListItem,
) )
} }
} }
@Preview @Preview
@Composable @Composable
internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview{ ContentToPreview() } internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview { ContentToPreview() }
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
Column { Column {
SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false)) SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false))
Divider()
SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true)) SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true))
} }
} }

View file

@ -16,7 +16,7 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import android.content.Context import android.app.Activity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -29,24 +29,18 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import timber.log.Timber
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class CreateRoomRootNode @AssistedInject constructor( class CreateRoomRootNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: CreateRoomRootPresenter, private val presenter: CreateRoomRootPresenter,
private val matrixClient: MatrixClient,
private val buildMeta: BuildMeta,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val inviteFriendsUseCase: InviteFriendsUseCase,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
@ -73,31 +67,18 @@ class CreateRoomRootNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
val context = LocalContext.current val activity = LocalContext.current as Activity
CreateRoomRootView( CreateRoomRootView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onClosePressed = this::navigateUp, onClosePressed = this::navigateUp,
onNewRoomClicked = callback::onCreateNewRoom, onNewRoomClicked = callback::onCreateNewRoom,
onOpenDM = callback::onStartChatSuccess, onOpenDM = callback::onStartChatSuccess,
onInviteFriendsClicked = { invitePeople(context) }, onInviteFriendsClicked = { invitePeople(activity) }
) )
} }
private fun invitePeople(context: Context) { private fun invitePeople(activity: Activity) {
val permalinkResult = PermalinkBuilder.permalinkForUser(matrixClient.sessionId) inviteFriendsUseCase.execute(activity)
permalinkResult.onSuccess { permalink ->
val appName = buildMeta.applicationName
startSharePlainTextIntent(
context = context,
activityResultLauncher = null,
chooserTitle = context.getString(CommonStrings.action_invite_friends),
text = context.getString(CommonStrings.invite_friends_text, appName, permalink),
extraTitle = context.getString(CommonStrings.invite_friends_rich_title, appName),
noActivityFoundMessage = context.getString(io.element.android.libraries.androidutils.R.string.error_no_compatible_app_found)
)
}.onFailure {
Timber.e(it)
}
} }
} }

View file

@ -33,6 +33,7 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomSummary import io.element.android.libraries.matrix.api.room.RoomSummary
@ -158,12 +159,14 @@ class InviteListPresenter @Inject constructor(
id = i.userId.value, id = i.userId.value,
name = i.displayName, name = i.displayName,
url = i.avatarUrl, url = i.avatarUrl,
size = AvatarSize.RoomInviteItem,
) )
else else
AvatarData( AvatarData(
id = roomId.value, id = roomId.value,
name = name, name = name,
url = avatarURLString url = avatarURLString,
size = AvatarSize.RoomInviteItem,
) )
val alias = if (isDirect) val alias = if (isDirect)
@ -186,6 +189,7 @@ class InviteListPresenter @Inject constructor(
id = userId.value, id = userId.value,
name = displayName, name = displayName,
url = avatarUrl, url = avatarUrl,
size = AvatarSize.InviteSender,
), ),
) )
}, },

View file

@ -16,7 +16,6 @@
package io.element.android.features.invitelist.impl.components package io.element.android.features.invitelist.impl.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -28,14 +27,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -52,6 +48,7 @@ import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
import io.element.android.features.invitelist.impl.model.InviteListInviteSummaryProvider import io.element.android.features.invitelist.impl.model.InviteListInviteSummaryProvider
import io.element.android.features.invitelist.impl.model.InviteSender import io.element.android.features.invitelist.impl.model.InviteSender
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -74,8 +71,8 @@ internal fun InviteSummaryRow(
) { ) {
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = minHeight) .heightIn(min = minHeight)
) { ) {
DefaultInviteSummaryRow( DefaultInviteSummaryRow(
invite = invite, invite = invite,
@ -93,20 +90,20 @@ internal fun DefaultInviteSummaryRow(
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(16.dp)
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
Avatar( Avatar(
invite.roomAvatarData.copy(size = AvatarSize.Custom(52.dp)), invite.roomAvatarData,
) )
Column( Column(
modifier = Modifier modifier = Modifier
.padding(start = 16.dp, end = 4.dp) .padding(start = 16.dp, end = 4.dp)
.alignByBaseline() .alignByBaseline()
.weight(1f) .weight(1f)
) { ) {
val bonusPadding = if (invite.isNew) 12.dp else 0.dp val bonusPadding = if (invite.isNew) 12.dp else 0.dp
@ -145,7 +142,9 @@ internal fun DefaultInviteSummaryRow(
OutlinedButton( OutlinedButton(
content = { Text(stringResource(CommonStrings.action_decline), style = ElementTextStyles.Button) }, content = { Text(stringResource(CommonStrings.action_decline), style = ElementTextStyles.Button) },
onClick = onDeclineClicked, onClick = onDeclineClicked,
modifier = Modifier.weight(1f).heightIn(max = 36.dp), modifier = Modifier
.weight(1f)
.heightIn(max = 36.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp),
) )
@ -154,20 +153,16 @@ internal fun DefaultInviteSummaryRow(
Button( Button(
content = { Text(stringResource(CommonStrings.action_accept), style = ElementTextStyles.Button) }, content = { Text(stringResource(CommonStrings.action_accept), style = ElementTextStyles.Button) },
onClick = onAcceptClicked, onClick = onAcceptClicked,
modifier = Modifier.weight(1f).heightIn(max = 36.dp), modifier = Modifier
.weight(1f)
.heightIn(max = 36.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp),
) )
} }
} }
val unreadIndicatorColor = if (invite.isNew) MaterialTheme.roomListUnreadIndicator() else Color.Transparent val unreadIndicatorColor = if (invite.isNew) MaterialTheme.roomListUnreadIndicator() else Color.Transparent
UnreadIndicatorAtom(color = unreadIndicatorColor)
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(unreadIndicatorColor)
)
} }
} }
@ -178,7 +173,7 @@ private fun SenderRow(sender: InviteSender) {
modifier = Modifier.padding(top = 6.dp), modifier = Modifier.padding(top = 6.dp),
) { ) {
Avatar( Avatar(
avatarData = sender.avatarData.copy(size = AvatarSize.Custom(16.dp)), avatarData = sender.avatarData,
) )
Text( Text(
text = stringResource(R.string.screen_invites_invited_you, sender.displayName, sender.userId.value).let { text -> text = stringResource(R.string.screen_invites_invited_you, sender.displayName, sender.userId.value).let { text ->

View file

@ -18,6 +18,7 @@ package io.element.android.features.invitelist.impl.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -26,14 +27,14 @@ data class InviteListInviteSummary(
val roomId: RoomId, val roomId: RoomId,
val roomName: String = "", val roomName: String = "",
val roomAlias: String? = null, val roomAlias: String? = null,
val roomAvatarData: AvatarData = AvatarData(roomId.value, roomName), val roomAvatarData: AvatarData = AvatarData(roomId.value, roomName, size = AvatarSize.RoomInviteItem),
val sender: InviteSender? = null, val sender: InviteSender? = null,
val isDirect: Boolean = false, val isDirect: Boolean = false,
val isNew: Boolean = false, val isNew: Boolean = false,
) )
data class InviteSender( data class InviteSender constructor(
val userId: UserId, val userId: UserId,
val displayName: String, val displayName: String,
val avatarData: AvatarData = AvatarData(userId.value, displayName), val avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender),
) )

View file

@ -24,6 +24,7 @@ import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.invitelist.test.FakeSeenInvitesStore import io.element.android.features.invitelist.test.FakeSeenInvitesStore
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
@ -36,8 +37,8 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -91,6 +92,7 @@ class InviteListPresenterTests {
id = A_USER_ID.value, id = A_USER_ID.value,
name = A_USER_NAME, name = A_USER_NAME,
url = AN_AVATAR_URL, url = AN_AVATAR_URL,
size = AvatarSize.RoomInviteItem,
) )
) )
Truth.assertThat(withInviteState.inviteList[0].sender).isNull() Truth.assertThat(withInviteState.inviteList[0].sender).isNull()
@ -119,6 +121,7 @@ class InviteListPresenterTests {
id = A_USER_ID.value, id = A_USER_ID.value,
name = A_USER_NAME, name = A_USER_NAME,
url = AN_AVATAR_URL, url = AN_AVATAR_URL,
size = AvatarSize.InviteSender,
) )
) )
} }

View file

@ -118,7 +118,7 @@ class MessagesPresenter @AssistedInject constructor(
id = room.roomId.value, id = room.roomId.value,
name = room.name, name = room.name,
url = room.avatarUrl, url = room.avatarUrl,
size = AvatarSize.SMALL size = AvatarSize.TimelineRoom
) )
roomName.value = room.name roomName.value = room.name
} }

View file

@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.components.retrysendme
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
@ -42,7 +43,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
fun aMessagesState() = MessagesState( fun aMessagesState() = MessagesState(
roomId = RoomId("!id:domain"), roomId = RoomId("!id:domain"),
roomName = "Room name", roomName = "Room name",
roomAvatar = AvatarData("!id:domain", "Room name"), roomAvatar = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
userHasPermissionToSendMessage = true, userHasPermissionToSendMessage = true,
composerState = aMessageComposerState().copy( composerState = aMessageComposerState().copy(
text = StableCharSequence("Hello"), text = StableCharSequence("Hello"),

View file

@ -19,6 +19,10 @@ package io.element.android.features.messages.impl.actionlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> { open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
@ -29,15 +33,27 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
anActionListState().copy( anActionListState().copy(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent(), event = aTimelineItemEvent(),
actions = persistentListOf( actions = aTimelineItemActionList(),
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
TimelineItemAction.Edit,
TimelineItemAction.Redact,
)
) )
) ),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemImageContent()),
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemVideoContent()),
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemFileContent()),
actions = aTimelineItemActionList(),
)
),
) )
} }
@ -45,3 +61,15 @@ fun anActionListState() = ActionListState(
target = ActionListState.Target.None, target = ActionListState.Target.None,
eventSink = {} eventSink = {}
) )
fun aTimelineItemActionList(): ImmutableList<TimelineItemAction> {
return persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
TimelineItemAction.Edit,
TimelineItemAction.Redact,
TimelineItemAction.ReportContent,
TimelineItemAction.Developer,
)
}

View file

@ -42,27 +42,22 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
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.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -74,6 +69,7 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Divider import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
@ -169,9 +165,11 @@ private fun SheetContent(
) { ) {
item { item {
Column { Column {
MessageSummary(event = target.event, modifier = Modifier MessageSummary(
event = target.event, modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp)) .padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(14.dp)) Spacer(modifier = Modifier.height(14.dp))
Divider() Divider()
} }
@ -214,10 +212,10 @@ private fun SheetContent(
@Composable @Composable
private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) { private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) {
val content: @Composable () -> Unit val content: @Composable () -> Unit
var icon: @Composable () -> Unit = { Avatar(avatarData = event.senderAvatar.copy(size = AvatarSize.SMALL)) } var icon: @Composable () -> Unit = { Avatar(avatarData = event.senderAvatar.copy(size = AvatarSize.MessageActionSender)) }
val contentStyle = ElementTextStyles.Regular.bodyMD.copy(color = MaterialTheme.colorScheme.secondary) val contentStyle = ElementTextStyles.Regular.bodyMD.copy(color = MaterialTheme.colorScheme.secondary)
val imageModifier = Modifier val imageModifier = Modifier
.size(36.dp) .size(AvatarSize.MessageActionSender.dp)
.clip(RoundedCornerShape(9.dp)) .clip(RoundedCornerShape(9.dp))
@Composable @Composable
@ -232,7 +230,6 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
when (event.content) { when (event.content) {
is TimelineItemTextBasedContent, is TimelineItemTextBasedContent,
is TimelineItemStateContent, is TimelineItemStateContent,
is TimelineItemProfileChangeContent,
is TimelineItemEncryptedContent, is TimelineItemEncryptedContent,
is TimelineItemRedactedContent, is TimelineItemRedactedContent,
is TimelineItemUnknownContent -> content = { ContentForBody(textContent) } is TimelineItemUnknownContent -> content = { ContentForBody(textContent) }
@ -282,7 +279,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
Row(modifier = modifier) { Row(modifier = modifier) {
icon() icon()
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Column { Column(modifier = Modifier.weight(1f)) {
Row { Row {
if (event.senderDisplayName != null) { if (event.senderDisplayName != null) {
Text( Text(
@ -291,16 +288,16 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} }
Text(
event.sentTime,
style = ElementTextStyles.Regular.caption2,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.End,
modifier = Modifier.weight(1f)
)
} }
content() content()
} }
Spacer(modifier = Modifier.width(16.dp))
Text(
event.sentTime,
style = ElementTextStyles.Regular.caption2,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.End,
)
} }
} }
@ -349,7 +346,7 @@ private fun EmojiButton(
) { ) {
Text( Text(
emoji, emoji,
fontSize = 28.dpToSp(), fontSize = 28.dp.toSp(),
modifier = modifier.clickable( modifier = modifier.clickable(
enabled = true, enabled = true,
onClick = { onClicked(emoji) }, onClick = { onClicked(emoji) },
@ -359,11 +356,6 @@ private fun EmojiButton(
) )
} }
@Composable
private fun Int.dpToSp(): TextUnit = with(LocalDensity.current) {
return dp.toSp()
}
@Preview @Preview
@Composable @Composable
fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) = fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =

View file

@ -21,13 +21,13 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
@ -48,6 +48,7 @@ import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults
@ -227,18 +228,22 @@ internal fun RoomSummaryView(
modifier = modifier modifier = modifier
.clickable { onSelection(summary) } .clickable { onSelection(summary) }
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(start = 16.dp, end = 4.dp)
.height(IntrinsicSize.Min), .heightIn(56.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
val roomAlias = summary.canonicalAlias ?: summary.roomId.value val roomAlias = summary.canonicalAlias ?: summary.roomId.value
Avatar( Avatar(
avatarData = AvatarData(id = roomAlias, name = summary.name, url = summary.avatarURLString), avatarData = AvatarData(
id = roomAlias,
name = summary.name,
url = summary.avatarURLString,
size = AvatarSize.ForwardRoomListItem,
),
) )
Column( Column(
modifier = Modifier modifier = Modifier
.padding(start = 12.dp, end = 4.dp, top = 8.dp, bottom = 8.dp) .padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
.alignByBaseline()
.weight(1f) .weight(1f)
) { ) {
// Name // Name

View file

@ -35,9 +35,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.text.toDp
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import me.saket.telephoto.zoomable.zoomable import me.saket.telephoto.zoomable.zoomable
@ -51,7 +51,7 @@ fun PdfViewer(
modifier = modifier.zoomable(pdfViewerState.zoomableState), modifier = modifier.zoomable(pdfViewerState.zoomableState),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val maxWidthInPx = maxWidth.dpToPx() val maxWidthInPx = maxWidth.roundToPx()
DisposableEffect(pdfViewerState) { DisposableEffect(pdfViewerState) {
pdfViewerState.openForWidth(maxWidthInPx) pdfViewerState.openForWidth(maxWidthInPx)
onDispose { onDispose {
@ -107,15 +107,9 @@ private fun PdfPageView(
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(state.height.pxToDp()) .height(state.height.toDp())
.background(color = Color.White) .background(color = Color.White)
) )
} }
} }
} }
@Composable
private fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() }
@Composable
private fun Dp.dpToPx() = with(LocalDensity.current) { this@dpToPx.roundToPx() }

View file

@ -23,7 +23,10 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
@ -32,6 +35,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventSendStat
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlin.random.Random import kotlin.random.Random
fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState( fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState(
@ -83,15 +88,23 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
content = content, content = content,
groupPosition = TimelineItemGroupPosition.First groupPosition = TimelineItemGroupPosition.First
), ),
// A state event on top of it // A grouped event on top of it
aTimelineItemEvent( aGroupedEvents(),
isMine = true, // A day separator
content = aTimelineItemStateEventContent(), aTimelineItemDaySeparator(),
groupPosition = TimelineItemGroupPosition.None // Loading
), aTimelineItemLoading(),
) )
} }
fun aTimelineItemLoading(): TimelineItem.Virtual {
return TimelineItem.Virtual("virtual_loading", TimelineItemLoadingModel)
}
fun aTimelineItemDaySeparator(): TimelineItem.Virtual {
return TimelineItem.Virtual("virtual_day", aTimelineItemDaySeparatorModel("Today"))
}
internal fun aTimelineItemEvent( internal fun aTimelineItemEvent(
eventId: EventId = EventId("\$" + Random.nextInt().toString()), eventId: EventId = EventId("\$" + Random.nextInt().toString()),
transactionId: String? = null, transactionId: String? = null,
@ -101,22 +114,19 @@ internal fun aTimelineItemEvent(
sendState: EventSendState = EventSendState.Sent(eventId), sendState: EventSendState = EventSendState.Sent(eventId),
inReplyTo: InReplyTo? = null, inReplyTo: InReplyTo? = null,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
): TimelineItem.Event { ): TimelineItem.Event {
return TimelineItem.Event( return TimelineItem.Event(
id = eventId.value, id = eventId.value,
eventId = eventId, eventId = eventId,
transactionId = transactionId, transactionId = transactionId,
senderId = UserId("@senderId:domain"), senderId = UserId("@senderId:domain"),
senderAvatar = AvatarData("@senderId:domain", "sender"), senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender),
content = content, content = content,
reactionsState = TimelineItemReactions( reactionsState = timelineItemReactions,
persistentListOf(
AggregatedReaction("👍", "1")
)
),
sentTime = "12:34", sentTime = "12:34",
isMine = isMine, isMine = isMine,
senderDisplayName = "sender", senderDisplayName = "Sender",
groupPosition = groupPosition, groupPosition = groupPosition,
sendState = sendState, sendState = sendState,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
@ -124,6 +134,19 @@ internal fun aTimelineItemEvent(
) )
} }
fun aTimelineItemReactions(
count: Int = 1,
isHighlighted: Boolean = false,
): TimelineItemReactions {
return TimelineItemReactions(
reactions = buildList {
repeat(count) {
add(AggregatedReaction(key = "👍", count = 1 + it, isHighlighted = isHighlighted))
}
}.toPersistentList()
)
}
internal fun aTimelineItemDebugInfo( internal fun aTimelineItemDebugInfo(
model: String = "Rust(Model())", model: String = "Rust(Model())",
originalJson: String? = null, originalJson: String? = null,
@ -131,3 +154,17 @@ internal fun aTimelineItemDebugInfo(
) = TimelineItemDebugInfo( ) = TimelineItemDebugInfo(
model, originalJson, latestEditedJson model, originalJson, latestEditedJson
) )
fun aGroupedEvents(): TimelineItem.GroupedEvents {
val event = aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None
)
return TimelineItem.GroupedEvents(
events = listOf(
event,
event,
).toImmutableList()
)
}

View file

@ -89,7 +89,7 @@ fun TimelineView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = lazyListState, state = lazyListState,
reverseLayout = true, reverseLayout = true,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
) { ) {
itemsIndexed( itemsIndexed(
items = state.timelineItems, items = state.timelineItems,

View file

@ -20,6 +20,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -48,6 +49,9 @@ import io.element.android.libraries.designsystem.theme.components.Text
private val BUBBLE_RADIUS = 12.dp private val BUBBLE_RADIUS = 12.dp
private val BUBBLE_INCOMING_OFFSET = 16.dp private val BUBBLE_INCOMING_OFFSET = 16.dp
// Design says: The maximum width of a bubble is still 3/4 of the screen width
private const val BUBBLE_WIDTH_RATIO = 0.75f
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun MessageEventBubble( fun MessageEventBubble(
@ -103,21 +107,30 @@ fun MessageEventBubble(
} }
} }
val bubbleShape = bubbleShape() val bubbleShape = bubbleShape()
Surface( Box(
modifier = modifier modifier = modifier
.widthIn(min = 80.dp) .fillMaxWidth(BUBBLE_WIDTH_RATIO)
.offsetForItem() .padding(horizontal = 16.dp)
.clip(bubbleShape) .offsetForItem(),
.combinedClickable( // Need to set the contentAlignment again (it's already set in TimelineItemEventRow), for the case
onClick = onClick, // when content width is low.
onLongClick = onLongClick, contentAlignment = if (state.isMine) Alignment.CenterEnd else Alignment.CenterStart
indication = rememberRipple(), ) {
interactionSource = interactionSource Surface(
), modifier = Modifier
color = backgroundBubbleColor, .widthIn(min = 80.dp)
shape = bubbleShape, .clip(bubbleShape)
content = content .combinedClickable(
) onClick = onClick,
onLongClick = onLongClick,
indication = rememberRipple(),
interactionSource = interactionSource
),
color = backgroundBubbleColor,
shape = bubbleShape,
content = content
)
}
} }
@Preview @Preview

View file

@ -17,6 +17,8 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -27,6 +29,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -37,23 +40,40 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
@Composable @Composable
fun MessagesReactionButton(reaction: AggregatedReaction, modifier: Modifier = Modifier) { fun MessagesReactionButton(reaction: AggregatedReaction, modifier: Modifier = Modifier) {
// First Surface is to render a border with the same background color as the background
Surface( Surface(
modifier = modifier, modifier = modifier,
color = MaterialTheme.colorScheme.surfaceVariant, // TODO Should use compound.bgSubtlePrimary
color = ElementTheme.colors.gray300,
border = BorderStroke(2.dp, MaterialTheme.colorScheme.background), border = BorderStroke(2.dp, MaterialTheme.colorScheme.background),
shape = RoundedCornerShape(corner = CornerSize(12.dp)), shape = RoundedCornerShape(corner = CornerSize(14.dp)),
) { ) {
Row( Box(modifier = Modifier.padding(2.dp)) {
modifier = Modifier.padding(vertical = 5.dp, horizontal = 8.dp), val reactionModifier = if (reaction.isHighlighted) {
verticalAlignment = Alignment.CenterVertically Modifier
) { // TODO Check the color, should use compound.borderInteractivePrimary
// TODO `reaction.isHighlighted` is not used. .border(BorderStroke(1.dp, Color(0xFF808994)), RoundedCornerShape(corner = CornerSize(12.dp)))
Text(text = reaction.key, fontSize = 12.sp) } else {
Spacer(modifier = Modifier.width(4.dp)) Modifier
Text(text = reaction.count, color = MaterialTheme.colorScheme.secondary, fontSize = 12.sp) }
Row(
modifier = reactionModifier.padding(vertical = 4.dp, horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = reaction.key, fontSize = 15.sp)
if (reaction.count > 1) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = reaction.count.toString(),
color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
fontSize = 14.sp
)
}
}
} }
} }
} }

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@ -27,11 +28,9 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -40,22 +39,33 @@ 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.draw.clip
import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.theme.LocalColors import io.element.android.libraries.theme.LocalColors
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -66,6 +76,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import org.jsoup.Jsoup
@Composable @Composable
fun TimelineItemEventRow( fun TimelineItemEventRow(
@ -89,66 +100,72 @@ fun TimelineItemEventRow(
inReplyToClick(inReplyToEventId) inReplyToClick(inReplyToEventId)
} }
val (parentAlignment, contentAlignment) = if (event.isMine) { // To avoid using negative offset, we display in this Box a column with:
Pair(Alignment.CenterEnd, Alignment.End) // - Spacer to give room to the Sender information if they must be displayed;
} else { // - The message bubble;
Pair(Alignment.CenterStart, Alignment.Start) // - Spacer for the reactions if there are some.
} // Then the Sender information and the reactions are displayed on top of it.
// This fixes some clickable issue and some unexpected margin on top and bottom of each message row
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight(), .wrapContentHeight(),
contentAlignment = parentAlignment contentAlignment = if (event.isMine) Alignment.CenterEnd else Alignment.CenterStart
) { ) {
Row { Column {
Column(horizontalAlignment = contentAlignment) { if (event.showSenderInformation) {
if (event.showSenderInformation) { Spacer(modifier = Modifier.height(event.senderAvatar.size.dp - 8.dp))
MessageSenderInformation( }
event.safeSenderName, val bubbleState = BubbleState(
event.senderAvatar, groupPosition = event.groupPosition,
Modifier isMine = event.isMine,
.zIndex(1f) isHighlighted = isHighlighted,
.offset(y = 12.dp) )
.clickable(onClick = ::onUserDataClicked) MessageEventBubble(
) state = bubbleState,
} interactionSource = interactionSource,
val bubbleState = BubbleState( onClick = onClick,
groupPosition = event.groupPosition, onLongClick = onLongClick,
isMine = event.isMine, ) {
isHighlighted = isHighlighted, MessageEventBubbleContent(
) event = event,
MessageEventBubble(
state = bubbleState,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onMessageClick = onClick,
onLongClick = onLongClick, onMessageLongClick = onLongClick,
modifier = Modifier inReplyToClick = ::inReplyToClicked,
.zIndex(-1f) onTimestampClicked = {
.widthIn(max = 320.dp) onTimestampClicked(event)
) { }
MessageEventBubbleContent(
event = event,
interactionSource = interactionSource,
onMessageClick = onClick,
onMessageLongClick = onLongClick,
inReplyToClick = ::inReplyToClicked,
onTimestampClicked = {
onTimestampClicked(event)
}
)
}
TimelineItemReactionsView(
reactionsState = event.reactionsState,
modifier = Modifier
.zIndex(1f)
.offset(x = if (event.isMine) 0.dp else 20.dp, y = -(4.dp))
) )
} }
if (event.reactionsState.reactions.isNotEmpty()) {
Spacer(modifier = Modifier.height(28.dp))
}
}
// Align to the top of the box
if (event.showSenderInformation) {
MessageSenderInformation(
event.safeSenderName,
event.senderAvatar,
Modifier
.padding(horizontal = 16.dp)
.align(Alignment.TopStart)
.clickable(onClick = ::onUserDataClicked)
)
}
// Align to the bottom of the box
if (event.reactionsState.reactions.isNotEmpty()) {
TimelineItemReactionsView(
reactionsState = event.reactionsState,
modifier = Modifier
.align(if (event.isMine) Alignment.BottomEnd else Alignment.BottomStart)
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
)
} }
} }
// This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage.
if (event.groupPosition.isNew()) { if (event.groupPosition.isNew()) {
Spacer(modifier = modifier.height(8.dp)) Spacer(modifier = modifier.height(16.dp))
} else { } else {
Spacer(modifier = modifier.height(2.dp)) Spacer(modifier = modifier.height(2.dp))
} }
@ -157,20 +174,38 @@ fun TimelineItemEventRow(
@Composable @Composable
private fun MessageSenderInformation( private fun MessageSenderInformation(
sender: String, sender: String,
senderAvatar: AvatarData?, senderAvatar: AvatarData,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row(modifier = modifier) { val avatarStrokeSize = 3.dp
if (senderAvatar != null) { val avatarStrokeColor = MaterialTheme.colorScheme.background
val avatarSize = senderAvatar.size.dp
Box(
modifier = modifier
) {
// Background of Avatar, to erase the corner of the message content
Canvas(
modifier = Modifier
.size(size = avatarSize + avatarStrokeSize)
.clipToBounds()
) {
drawCircle(
color = avatarStrokeColor,
center = Offset(x = (avatarSize / 2).toPx(), y = (avatarSize / 2).toPx()),
radius = (avatarSize / 2 + avatarStrokeSize).toPx()
)
}
// Content
Row {
Avatar(senderAvatar) Avatar(senderAvatar)
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text(
text = sender,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium,
)
} }
Text(
text = sender,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.alignBy(LastBaseline)
)
} }
} }
@ -196,6 +231,7 @@ private fun MessageEventBubbleContent(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onMessageClick, onClick = onMessageClick,
onLongClick = onMessageLongClick, onLongClick = onMessageLongClick,
extraPadding = event.toExtraPadding(),
modifier = modifier, modifier = modifier,
) )
} }
@ -221,14 +257,14 @@ private fun MessageEventBubbleContent(
) )
} }
} else { } else {
Column(modifier) { Box(modifier) {
ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp)) ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp))
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, event = event,
onClick = onTimestampClicked, onClick = onTimestampClicked,
modifier = timestampModifier modifier = timestampModifier
.align(Alignment.End) .align(Alignment.BottomEnd)
.padding(horizontal = 8.dp, vertical = 2.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) )
} }
} }
@ -358,3 +394,91 @@ private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) =
) )
else -> null else -> null
} }
@Preview
@Composable
internal fun TimelineItemEventRowLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun TimelineItemEventRowDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
sequenceOf(false, true).forEach {
TimelineItemEventRow(
event = aTimelineItemEvent(
isMine = it,
content = aTimelineItemTextContent().copy(
body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors."
)
),
isHighlighted = false,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onTimestampClicked = {},
)
TimelineItemEventRow(
event = aTimelineItemEvent(
isMine = it,
content = aTimelineItemImageContent().copy(
aspectRatio = 5f
)
),
isHighlighted = false,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onTimestampClicked = {},
)
}
}
}
@Preview
@Composable
internal fun TimelineItemEventRowTimestampLightPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) =
ElementPreviewLight { ContentTimestampToPreview(event) }
@Preview
@Composable
internal fun TimelineItemEventRowTimestampDarkPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) =
ElementPreviewDark { ContentTimestampToPreview(event) }
@Composable
private fun ContentTimestampToPreview(event: TimelineItem.Event) {
Column {
val oldContent = event.content as TimelineItemTextContent
listOf(
"Text",
"Text longer, displayed on 1 line",
"Text which should be rendered on several lines",
).forEach { str ->
listOf(false, true).forEach { useDocument ->
TimelineItemEventRow(
event = event.copy(
content = oldContent.copy(
body = str,
htmlDocument = if (useDocument) Jsoup.parse(str) else null,
),
reactionsState = aTimelineItemReactions(count = 0),
senderDisplayName = if (useDocument) "Document case" else "Text case",
),
isHighlighted = false,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onTimestampClicked = {},
)
}
}
}
}

View file

@ -31,7 +31,6 @@ fun TimelineItemReactionsView(
reactionsState: TimelineItemReactions, reactionsState: TimelineItemReactions,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (reactionsState.reactions.isEmpty()) return
FlowRow( FlowRow(
modifier = modifier, modifier = modifier,
mainAxisSpacing = 2.dp, mainAxisSpacing = 2.dp,

View file

@ -25,11 +25,18 @@ import androidx.compose.runtime.Composable
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@Composable @Composable
fun TimelineItemStateEventRow( fun TimelineItemStateEventRow(
@ -60,8 +67,33 @@ fun TimelineItemStateEventRow(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
extraPadding = noExtraPadding,
modifier = Modifier.defaultTimelineContentPadding() modifier = Modifier.defaultTimelineContentPadding()
) )
} }
} }
} }
@Preview
@Composable
internal fun TimelineItemStateEventRowLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun TimelineItemStateEventRowDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
TimelineItemStateEventRow(
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None
),
isHighlighted = false,
onClick = {},
onLongClick = {},
)
}

View file

@ -0,0 +1,53 @@
/*
* 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.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.ui.strings.CommonStrings
// Allow to not overlap the timestamp with the text, in the message bubble.
// Compute the size of the worst case.
data class ExtraPadding(val str: String)
val noExtraPadding = ExtraPadding("")
/**
* See [io.element.android.features.messages.impl.timeline.components.TimelineEventTimestampView] for the related View.
* And https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1819%253A99506 for the design.
*/
@Composable
fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
val formattedTime = sentTime
val hasMessageSendingFailed = sendState is EventSendState.SendingFailed
val isMessageEdited = (content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
var strLen = 2
if (isMessageEdited) {
strLen += stringResource(id = CommonStrings.common_edited_suffix).length + 2
}
strLen += formattedTime.length
if (hasMessageSendingFailed) {
strLen += 5
}
// A space and a few unbreakable spaces
return ExtraPadding(" " + "\u00A0".repeat(strLen))
}

View file

@ -17,10 +17,8 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -35,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
fun TimelineItemEventContentView( fun TimelineItemEventContentView(
content: TimelineItemEventContent, content: TimelineItemEventContent,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
extraPadding: ExtraPadding,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -42,14 +41,17 @@ fun TimelineItemEventContentView(
when (content) { when (content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView( is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = content, content = content,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
is TimelineItemRedactedContent -> TimelineItemRedactedView( is TimelineItemRedactedContent -> TimelineItemRedactedView(
content = content, content = content,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
is TimelineItemTextBasedContent -> TimelineItemTextView( is TimelineItemTextBasedContent -> TimelineItemTextView(
content = content, content = content,
extraPadding = extraPadding,
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = modifier, modifier = modifier,
onTextClicked = onClick, onTextClicked = onClick,
@ -57,6 +59,7 @@ fun TimelineItemEventContentView(
) )
is TimelineItemUnknownContent -> TimelineItemUnknownView( is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = content, content = content,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
is TimelineItemImageContent -> TimelineItemImageView( is TimelineItemImageContent -> TimelineItemImageView(
@ -69,6 +72,7 @@ fun TimelineItemEventContentView(
) )
is TimelineItemFileContent -> TimelineItemFileView( is TimelineItemFileContent -> TimelineItemFileView(
content = content, content = content,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
is TimelineItemStateContent -> TimelineItemStateView( is TimelineItemStateContent -> TimelineItemStateView(

View file

@ -31,12 +31,14 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun TimelineItemEncryptedView( fun TimelineItemEncryptedView(
content: TimelineItemEncryptedContent, content: TimelineItemEncryptedContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
TimelineItemInformativeView( TimelineItemInformativeView(
text = stringResource(id = CommonStrings.common_decryption_error), text = stringResource(id = CommonStrings.common_decryption_error),
iconDescription = stringResource(id = CommonStrings.dialog_title_warning), iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
icon = Icons.Default.Warning, icon = Icons.Default.Warning,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
} }
@ -56,6 +58,7 @@ private fun ContentToPreview() {
TimelineItemEncryptedView( TimelineItemEncryptedView(
content = TimelineItemEncryptedContent( content = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.Unknown data = UnableToDecryptContent.Data.Unknown
) ),
extraPadding = noExtraPadding
) )
} }

View file

@ -47,6 +47,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable @Composable
fun TimelineItemFileView( fun TimelineItemFileView(
content: TimelineItemFileContent, content: TimelineItemFileContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@ -76,7 +77,7 @@ fun TimelineItemFileView(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = content.fileExtensionAndSize, text = content.fileExtensionAndSize + extraPadding.str,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
fontSize = 12.sp, fontSize = 12.sp,
maxLines = 1, maxLines = 1,
@ -98,5 +99,8 @@ internal fun TimelineItemFileViewDarkPreview(@PreviewParameter(TimelineItemFileC
@Composable @Composable
private fun ContentToPreview(content: TimelineItemFileContent) { private fun ContentToPreview(content: TimelineItemFileContent) {
TimelineItemFileView(content) TimelineItemFileView(
content,
extraPadding = noExtraPadding,
)
} }

View file

@ -41,6 +41,7 @@ fun TimelineItemInformativeView(
text: String, text: String,
iconDescription: String, iconDescription: String,
icon: ImageVector, icon: ImageVector,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
@ -58,7 +59,7 @@ fun TimelineItemInformativeView(
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
fontSize = 14.sp, fontSize = 14.sp,
text = text text = text + extraPadding.str
) )
} }
} }
@ -76,6 +77,7 @@ private fun ContentToPreview() {
TimelineItemInformativeView( TimelineItemInformativeView(
text = "Info", text = "Info",
iconDescription = "", iconDescription = "",
icon = Icons.Default.Delete icon = Icons.Default.Delete,
extraPadding = noExtraPadding,
) )
} }

View file

@ -30,12 +30,14 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun TimelineItemRedactedView( fun TimelineItemRedactedView(
content: TimelineItemRedactedContent, content: TimelineItemRedactedContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
TimelineItemInformativeView( TimelineItemInformativeView(
text = stringResource(id = CommonStrings.common_message_removed), text = stringResource(id = CommonStrings.common_message_removed),
iconDescription = stringResource(id = CommonStrings.common_message_removed), iconDescription = stringResource(id = CommonStrings.common_message_removed),
icon = Icons.Default.Delete, icon = Icons.Default.Delete,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
} }
@ -52,5 +54,8 @@ internal fun TimelineItemRedactedViewDarkPreview() =
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
TimelineItemRedactedView(TimelineItemRedactedContent) TimelineItemRedactedView(
TimelineItemRedactedContent,
extraPadding = noExtraPadding
)
} }

View file

@ -22,6 +22,9 @@ import android.text.util.Linkify.PHONE_NUMBERS
import android.text.util.Linkify.WEB_URLS import android.text.util.Linkify.WEB_URLS
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -29,6 +32,7 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.core.text.util.LinkifyCompat import androidx.core.text.util.LinkifyCompat
import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -37,30 +41,37 @@ import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.LinkColor import io.element.android.libraries.theme.LinkColor
import io.element.android.libraries.designsystem.text.toAnnotatedString
@Composable @Composable
fun TimelineItemTextView( fun TimelineItemTextView(
content: TimelineItemTextBasedContent, content: TimelineItemTextBasedContent,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onTextClicked: () -> Unit = {}, onTextClicked: () -> Unit = {},
onTextLongClicked: () -> Unit = {}, onTextLongClicked: () -> Unit = {},
) { ) {
val htmlDocument = content.htmlDocument val htmlDocument = content.htmlDocument
if (htmlDocument != null) { if (htmlDocument != null) {
HtmlDocument( // For now we ignore the extra padding for html content, so add some spacing
document = htmlDocument, // below the content (as previous behavior)
modifier = modifier, Column(modifier = modifier) {
onTextClicked = onTextClicked, HtmlDocument(
onTextLongClicked = onTextLongClicked, document = htmlDocument,
interactionSource = interactionSource modifier = Modifier,
) onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
Spacer(Modifier.height(16.dp))
}
} else { } else {
Box(modifier) { Box(modifier) {
val linkStyle = SpanStyle( val linkStyle = SpanStyle(
color = LinkColor, color = LinkColor,
) )
val styledText = remember(content.body) { content.body.linkify(linkStyle) } val styledText = remember(content.body) { content.body.linkify(linkStyle) + extraPadding.str.toAnnotatedString() }
ClickableLinkText( ClickableLinkText(
text = styledText, text = styledText,
linkAnnotationTag = "URL", linkAnnotationTag = "URL",
@ -109,6 +120,10 @@ internal fun TimelineItemTextViewDarkPreview(@PreviewParameter(TimelineItemTextB
@Composable @Composable
fun ContentToPreview(content: TimelineItemTextBasedContent) { fun ContentToPreview(content: TimelineItemTextBasedContent) {
TimelineItemTextView(content, MutableInteractionSource()) TimelineItemTextView(
content = content,
interactionSource = MutableInteractionSource(),
extraPadding = ExtraPadding(" (padding)"),
)
} }

View file

@ -30,12 +30,14 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun TimelineItemUnknownView( fun TimelineItemUnknownView(
content: TimelineItemUnknownContent, content: TimelineItemUnknownContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
TimelineItemInformativeView( TimelineItemInformativeView(
text = stringResource(id = CommonStrings.common_unsupported_event), text = stringResource(id = CommonStrings.common_unsupported_event),
iconDescription = stringResource(id = CommonStrings.dialog_title_warning), iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
icon = Icons.Default.Info, icon = Icons.Default.Info,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
} }
@ -52,5 +54,8 @@ internal fun TimelineItemUnknownViewDarkPreview() =
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
TimelineItemUnknownView(TimelineItemUnknownContent) TimelineItemUnknownView(
content = TimelineItemUnknownContent,
extraPadding = noExtraPadding
)
} }

View file

@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -39,30 +40,31 @@ internal fun TimelineItemDaySeparatorView(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box( Box(
modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight() .wrapContentHeight()
.padding(8.dp), .padding(16.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = model.formattedDate, text = model.formattedDate,
color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.primary,
) )
} }
} }
@Preview @Preview
@Composable @Composable
internal fun TimelineItemDaySeparatorViewLightPreview(@PreviewParameter( internal fun TimelineItemDaySeparatorViewLightPreview(
TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel @PreviewParameter(TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel
) = ) =
ElementPreviewLight { ContentToPreview(model) } ElementPreviewLight { ContentToPreview(model) }
@Preview @Preview
@Composable @Composable
internal fun TimelineItemDaySeparatorViewDarkPreview(@PreviewParameter( internal fun TimelineItemDaySeparatorViewDarkPreview(
TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel @PreviewParameter(TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel
) = ) =
ElementPreviewDark { ContentToPreview(model) } ElementPreviewDark { ContentToPreview(model) }

View file

@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
import io.element.android.libraries.core.bool.orTrue import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
@ -34,6 +35,7 @@ import javax.inject.Inject
class TimelineItemEventFactory @Inject constructor( class TimelineItemEventFactory @Inject constructor(
private val contentFactory: TimelineItemContentFactory, private val contentFactory: TimelineItemContentFactory,
private val matrixClient: MatrixClient,
) { ) {
fun create( fun create(
@ -67,7 +69,7 @@ class TimelineItemEventFactory @Inject constructor(
id = currentSender.value, id = currentSender.value,
name = senderDisplayName ?: currentSender.value, name = senderDisplayName ?: currentSender.value,
url = senderAvatarUrl, url = senderAvatarUrl,
size = AvatarSize.SMALL size = AvatarSize.TimelineSender
) )
return TimelineItem.Event( return TimelineItem.Event(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
@ -89,8 +91,13 @@ class TimelineItemEventFactory @Inject constructor(
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions { private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
val aggregatedReactions = event.reactions.map { val aggregatedReactions = event.reactions.map {
AggregatedReaction(key = it.key, count = it.count.toString(), isHighlighted = false) AggregatedReaction(
key = it.key,
count = it.count.toInt(),
isHighlighted = it.senderIds.contains(matrixClient.sessionId),
)
} }
aggregatedReactions.sortedByDescending { it.count }
return TimelineItemReactions(aggregatedReactions.toImmutableList()) return TimelineItemReactions(aggregatedReactions.toImmutableList())
} }

View file

@ -16,8 +16,13 @@
package io.element.android.features.messages.impl.timeline.model package io.element.android.features.messages.impl.timeline.model
/**
* @property key the reaction key (e.g. "👍")
* @property count the number of users who reacted with this key
* @property isHighlighted true if the reaction has (also) been sent by the current user.
*/
data class AggregatedReaction( data class AggregatedReaction(
val key: String, val key: String,
val count: String, val count: Int,
val isHighlighted: Boolean = false val isHighlighted: Boolean = false
) )

View file

@ -20,16 +20,20 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReaction> { open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReaction> {
override val values: Sequence<AggregatedReaction> override val values: Sequence<AggregatedReaction>
get() = sequenceOf( get() = sequenceOf(false, true).flatMap {
anAggregatedReaction(), sequenceOf(
anAggregatedReaction().copy(count = "88"), anAggregatedReaction(isHighlighted = it),
anAggregatedReaction().copy(isHighlighted = true), anAggregatedReaction(isHighlighted = it, count = 88),
anAggregatedReaction().copy(count = "88", isHighlighted = true), )
) }
} }
fun anAggregatedReaction() = AggregatedReaction( fun anAggregatedReaction(
key = "👍", key: String = "👍",
count = "1", // TODO Why is it a String? count: Int = 1,
isHighlighted = false, isHighlighted: Boolean = false,
) = AggregatedReaction(
key = key,
count = count,
isHighlighted = isHighlighted,
) )

View file

@ -39,11 +39,11 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> { class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> {
override val values = sequenceOf( override val values = sequenceOf(
aTimelineItemEmoteContent(), aTimelineItemEmoteContent(),
aTimelineItemEmoteContent().copy(htmlDocument = Jsoup.parse("Emote")), aTimelineItemEmoteContent().copy(htmlDocument = Jsoup.parse("Emote Document")),
aTimelineItemNoticeContent(), aTimelineItemNoticeContent(),
aTimelineItemNoticeContent().copy(htmlDocument = Jsoup.parse("Notice")), aTimelineItemNoticeContent().copy(htmlDocument = Jsoup.parse("Notice Document")),
aTimelineItemTextContent(), aTimelineItemTextContent(),
aTimelineItemTextContent().copy(htmlDocument = Jsoup.parse("Text")), aTimelineItemTextContent().copy(htmlDocument = Jsoup.parse("Text Document")),
) )
} }

View file

@ -29,7 +29,7 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
) )
} }
fun aTimelineItemFileContent(fileName: String) = TimelineItemFileContent( fun aTimelineItemFileContent(fileName: String = "A file.pdf") = TimelineItemFileContent(
body = fileName, body = fileName,
thumbnailSource = MediaSource(url = ""), thumbnailSource = MediaSource(url = ""),
fileSource = MediaSource(url = ""), fileSource = MediaSource(url = ""),

View file

@ -16,11 +16,12 @@
package io.element.android.features.messages.fixtures package io.element.android.features.messages.fixtures
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
@ -30,7 +31,6 @@ import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
import kotlinx.collections.immutable.persistentListOf
internal fun aMessageEvent( internal fun aMessageEvent(
eventId: EventId? = AN_EVENT_ID, eventId: EventId? = AN_EVENT_ID,
@ -43,11 +43,11 @@ internal fun aMessageEvent(
eventId = eventId, eventId = eventId,
senderId = A_USER_ID, senderId = A_USER_ID,
senderDisplayName = A_USER_NAME, senderDisplayName = A_USER_NAME,
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME), senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender),
content = content, content = content,
sentTime = "", sentTime = "",
isMine = isMine, isMine = isMine,
reactionsState = TimelineItemReactions(persistentListOf()), reactionsState = aTimelineItemReactions(count = 0),
sendState = EventSendState.Sent(AN_EVENT_ID), sendState = EventSendState.Sent(AN_EVENT_ID),
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
debugInfo = debugInfo, debugInfo = debugInfo,

View file

@ -36,6 +36,7 @@ import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -44,7 +45,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
return TimelineItemsFactory( return TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory( eventItemFactory = TimelineItemEventFactory(
TimelineItemContentFactory( contentFactory = TimelineItemContentFactory(
messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()), messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()),
redactedMessageFactory = TimelineItemContentRedactedFactory(), redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(), stickerFactory = TimelineItemContentStickerFactory(),
@ -54,7 +55,8 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
stateFactory = TimelineItemContentStateFactory(timelineEventFormatter), stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory() failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory()
) ),
matrixClient = FakeMatrixClient(),
), ),
virtualItemFactory = TimelineItemVirtualFactory( virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory( daySeparatorFactory = TimelineItemDaySeparatorFactory(

View file

@ -18,10 +18,9 @@ package io.element.android.features.messages.timeline.groups
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aMessageEvent import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData import io.element.android.libraries.designsystem.components.avatar.anAvatarData
@ -42,7 +41,7 @@ class TimelineItemGrouperTest {
senderAvatar = anAvatarData(), senderAvatar = anAvatarData(),
senderDisplayName = "", senderDisplayName = "",
content = TimelineItemStateEventContent(body = "a state event"), content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = TimelineItemReactions(emptyList<AggregatedReaction>().toImmutableList()), reactionsState = aTimelineItemReactions(count = 0),
sendState = EventSendState.Sent(AN_EVENT_ID), sendState = EventSendState.Sent(AN_EVENT_ID),
inReplyTo = null, inReplyTo = null,
debugInfo = aTimelineItemDebugInfo(), debugInfo = aTimelineItemDebugInfo(),

View file

@ -229,7 +229,7 @@ internal fun RoomHeaderSection(
Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.size(70.dp)) { Box(modifier = Modifier.size(70.dp)) {
Avatar( Avatar(
avatarData = AvatarData(roomId, roomName, avatarUrl, AvatarSize.HUGE), avatarData = AvatarData(roomId, roomName, avatarUrl, AvatarSize.RoomHeader),
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }

View file

@ -218,7 +218,7 @@ private fun EditableAvatarView(
when (state.roomAvatarUrl?.scheme) { when (state.roomAvatarUrl?.scheme) {
null, "mxc" -> { null, "mxc" -> {
Avatar( Avatar(
avatarData = AvatarData(state.roomId, state.roomName, state.roomAvatarUrl?.toString(), size = AvatarSize.HUGE), avatarData = AvatarData(state.roomId, state.roomName, state.roomAvatarUrl?.toString(), size = AvatarSize.RoomHeader),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }

View file

@ -186,7 +186,7 @@ private fun RoomInviteMembersSearchBar(
if (invitableUser.isUnresolved && !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined) { if (invitableUser.isUnresolved && !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined) {
CheckableUnresolvedUserRow( CheckableUnresolvedUserRow(
checked = invitableUser.isSelected, checked = invitableUser.isSelected,
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM), avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.UserListItem),
id = invitableUser.matrixUser.userId.value, id = invitableUser.matrixUser.userId.value,
onCheckedChange = { onUserToggled(invitableUser.matrixUser) }, onCheckedChange = { onUserToggled(invitableUser.matrixUser) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@ -195,7 +195,7 @@ private fun RoomInviteMembersSearchBar(
CheckableUserRow( CheckableUserRow(
checked = invitableUser.isSelected, checked = invitableUser.isSelected,
enabled = !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined, enabled = !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined,
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM), avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.UserListItem),
name = invitableUser.matrixUser.getBestName(), name = invitableUser.matrixUser.getBestName(),
subtext = when { subtext = when {
// If they're already invited or joined we show that information // If they're already invited or joined we show that information

View file

@ -191,7 +191,7 @@ private fun RoomMemberListItem(
displayName = roomMember.displayName, displayName = roomMember.displayName,
avatarUrl = roomMember.avatarUrl avatarUrl = roomMember.avatarUrl
), ),
avatarSize = AvatarSize.Custom(36.dp), avatarSize = AvatarSize.UserListItem,
) )
} }

View file

@ -112,7 +112,7 @@ internal fun RoomMemberHeaderSection(
Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.size(70.dp)) { Box(modifier = Modifier.size(70.dp)) {
Avatar( Avatar(
avatarData = AvatarData(userId, userName, avatarUrl, AvatarSize.HUGE), avatarData = AvatarData(userId, userName, avatarUrl, AvatarSize.UserHeader),
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }

View file

@ -37,6 +37,7 @@ interface RoomListEntryPoint : FeatureEntryPoint {
fun onSessionVerificationClicked() fun onSessionVerificationClicked()
fun onInvitesClicked() fun onInvitesClicked()
fun onRoomSettingsClicked(roomId: RoomId) fun onRoomSettingsClicked(roomId: RoomId)
fun onReportBugClicked()
} }
} }

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api) implementation(projects.libraries.eventformatter.api)
implementation(projects.libraries.deeplink)
implementation(projects.features.invitelist.api) implementation(projects.features.invitelist.api)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(projects.features.leaveroom.api) implementation(projects.features.leaveroom.api)

View file

@ -16,8 +16,10 @@
package io.element.android.features.roomlist.impl package io.element.android.features.roomlist.impl
import android.app.Activity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
@ -26,6 +28,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomlist.api.RoomListEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -33,7 +37,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
class RoomListNode @AssistedInject constructor( class RoomListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: RoomListPresenter, private val presenter: RoomListPresenter,
private val inviteFriendsUseCase: InviteFriendsUseCase,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private fun onRoomClicked(roomId: RoomId) { private fun onRoomClicked(roomId: RoomId) {
@ -60,9 +65,21 @@ class RoomListNode @AssistedInject constructor(
plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomSettingsClicked(roomId) } plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomSettingsClicked(roomId) }
} }
private fun onMenuActionClicked(activity: Activity, roomListMenuAction: RoomListMenuAction) {
when (roomListMenuAction) {
RoomListMenuAction.InviteFriends -> {
inviteFriendsUseCase.execute(activity)
}
RoomListMenuAction.ReportBug -> {
plugins<RoomListEntryPoint.Callback>().forEach { it.onReportBugClicked() }
}
}
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
val activity = LocalContext.current as Activity
RoomListView( RoomListView(
state = state, state = state,
onRoomClicked = this::onRoomClicked, onRoomClicked = this::onRoomClicked,
@ -71,6 +88,7 @@ class RoomListNode @AssistedInject constructor(
onVerifyClicked = this::onSessionVerificationClicked, onVerifyClicked = this::onSessionVerificationClicked,
onInvitesClicked = this::onInvitesClicked, onInvitesClicked = this::onInvitesClicked,
onRoomSettingsClicked = this::onRoomSettingsClicked, onRoomSettingsClicked = this::onRoomSettingsClicked,
onMenuActionClicked = { onMenuActionClicked(activity, it) },
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -37,6 +37,7 @@ import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
@ -190,7 +191,8 @@ class RoomListPresenter @Inject constructor(
val avatarData = AvatarData( val avatarData = AvatarData(
id = roomSummary.identifier(), id = roomSummary.identifier(),
name = roomSummary.details.name, name = roomSummary.details.name,
url = roomSummary.details.avatarURLString url = roomSummary.details.avatarURLString,
size = AvatarSize.RoomListItem,
) )
val roomIdentifier = roomSummary.identifier() val roomIdentifier = roomSummary.identifier()
RoomListRoomSummary( RoomListRoomSummary(

View file

@ -21,6 +21,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -68,7 +69,7 @@ internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
hasUnread = true, hasUnread = true,
timestamp = "14:18", timestamp = "14:18",
lastMessage = "A very very very very long message which suites on two lines", lastMessage = "A very very very very long message which suites on two lines",
avatarData = AvatarData("!id", "R"), avatarData = AvatarData("!id", "R", size = AvatarSize.RoomListItem),
id = "!roomId:domain", id = "!roomId:domain",
roomId = RoomId("!roomId:domain") roomId = RoomId("!roomId:domain")
), ),
@ -77,10 +78,11 @@ internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
hasUnread = false, hasUnread = false,
timestamp = "14:16", timestamp = "14:16",
lastMessage = "A short message", lastMessage = "A short message",
avatarData = AvatarData("!id", "Z"), avatarData = AvatarData("!id", "Z", size = AvatarSize.RoomListItem),
id = "!roomId2:domain", id = "!roomId2:domain",
roomId = RoomId("!roomId2:domain") roomId = RoomId("!roomId2:domain")
), ),
RoomListRoomSummaryPlaceholders.create("!roomId2:domain") RoomListRoomSummaryPlaceholders.create("!roomId2:domain"),
RoomListRoomSummaryPlaceholders.create("!roomId3:domain"),
) )
} }

View file

@ -21,23 +21,17 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar import androidx.compose.material3.Snackbar
@ -50,12 +44,10 @@ 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.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
@ -63,23 +55,20 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.features.leaveroom.api.LeaveRoomView import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.features.roomlist.impl.components.RequestVerificationHeader
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.features.roomlist.impl.components.RoomListTopBar import io.element.android.features.roomlist.impl.components.RoomListTopBar
import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchResultContent
import io.element.android.features.roomlist.impl.search.RoomListSearchResultView import io.element.android.features.roomlist.impl.search.RoomListSearchResultView
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Divider import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.noFontPadding
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -95,6 +84,7 @@ fun RoomListView(
onCreateRoomClicked: () -> Unit, onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit, onInvitesClicked: () -> Unit,
onRoomSettingsClicked: (roomId: RoomId) -> Unit, onRoomSettingsClicked: (roomId: RoomId) -> Unit,
onMenuActionClicked: (RoomListMenuAction) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
@ -118,12 +108,13 @@ fun RoomListView(
RoomListContent( RoomListContent(
state = state, state = state,
onVerifyClicked = onVerifyClicked,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) }, onRoomLongClicked = { onRoomLongClicked(it) },
onOpenSettings = onSettingsClicked, onOpenSettings = onSettingsClicked,
onVerifyClicked = onVerifyClicked,
onCreateRoomClicked = onCreateRoomClicked, onCreateRoomClicked = onCreateRoomClicked,
onInvitesClicked = onInvitesClicked, onInvitesClicked = onInvitesClicked,
onMenuActionClicked = onMenuActionClicked,
) )
// This overlaid view will only be visible when state.displaySearchResults is true // This overlaid view will only be visible when state.displaySearchResults is true
RoomListSearchResultView( RoomListSearchResultView(
@ -142,13 +133,14 @@ fun RoomListView(
@Composable @Composable
fun RoomListContent( fun RoomListContent(
state: RoomListState, state: RoomListState,
onVerifyClicked: () -> Unit,
onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onOpenSettings: () -> Unit,
onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit,
onMenuActionClicked: (RoomListMenuAction) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onVerifyClicked: () -> Unit = {},
onRoomClicked: (RoomId) -> Unit = {},
onRoomLongClicked: (RoomListRoomSummary) -> Unit = {},
onOpenSettings: () -> Unit = {},
onCreateRoomClicked: () -> Unit = {},
onInvitesClicked: () -> Unit = {},
) { ) {
fun onRoomClicked(room: RoomListRoomSummary) { fun onRoomClicked(room: RoomListRoomSummary) {
onRoomClicked(room.roomId) onRoomClicked(room.roomId)
@ -190,77 +182,45 @@ fun RoomListContent(
areSearchResultsDisplayed = state.displaySearchResults, areSearchResultsDisplayed = state.displaySearchResults,
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) }, onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) }, onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
onMenuActionClicked = onMenuActionClicked,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
}, },
content = { padding -> content = { padding ->
Column( LazyColumn(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
.nestedScroll(nestedScrollConnection),
state = lazyListState,
) { ) {
LazyColumn( if (state.displayVerificationPrompt) {
modifier = Modifier item {
.weight(1f) RequestVerificationHeader(
.nestedScroll(nestedScrollConnection), onVerifyClicked = onVerifyClicked,
state = lazyListState, onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
) {
if (state.displayVerificationPrompt) {
item {
RequestVerificationHeader(
onVerifyClicked = onVerifyClicked,
onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
)
}
}
if (state.invitesState != InvitesState.NoInvites) {
item {
Box(
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.clickable(role = Role.Button, onClick = onInvitesClicked)
.padding(horizontal = 16.dp)
.align(Alignment.CenterEnd)
.heightIn(min = 48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(CommonStrings.action_invites_list),
fontSize = 14.sp,
style = noFontPadding,
)
if (state.invitesState == InvitesState.NewInvites) {
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(MaterialTheme.roomListUnreadIndicator())
)
}
}
}
}
}
itemsIndexed(
items = state.roomList,
contentType = { _, room -> room.contentType() },
) { index, room ->
RoomSummaryRow(
room = room,
onClick = ::onRoomClicked,
onLongClick = onRoomLongClicked,
) )
if (index != state.roomList.lastIndex) { }
Divider() }
}
if (state.invitesState != InvitesState.NoInvites) {
item {
InvitesEntryPointView(onInvitesClicked, state)
}
}
itemsIndexed(
items = state.roomList,
contentType = { _, room -> room.contentType() },
) { index, room ->
RoomSummaryRow(
room = room,
onClick = ::onRoomClicked,
onLongClick = onRoomLongClicked,
)
if (index != state.roomList.lastIndex) {
Divider()
} }
} }
} }
@ -271,7 +231,12 @@ fun RoomListContent(
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
onClick = onCreateRoomClicked onClick = onCreateRoomClicked
) { ) {
Icon(resourceId = DrawableR.drawable.ic_edit_square, contentDescription = stringResource(id = R.string.screen_roomlist_a11y_create_message)) Icon(
// Correct icon alignment for better rendering.
modifier = Modifier.padding(start = 1.dp, bottom = 1.dp),
resourceId = DrawableR.drawable.ic_edit_square,
contentDescription = stringResource(id = R.string.screen_roomlist_a11y_create_message)
)
} }
}, },
snackbarHost = { snackbarHost = {
@ -287,67 +252,37 @@ fun RoomListContent(
} }
@Composable @Composable
internal fun RequestVerificationHeader( private fun InvitesEntryPointView(
onVerifyClicked: () -> Unit, onInvitesClicked: () -> Unit,
onDismissClicked: () -> Unit, state: RoomListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { Box(
Surface( modifier = modifier.fillMaxWidth(),
modifier.fillMaxWidth(), ) {
shape = MaterialTheme.shapes.small, Row(
color = MaterialTheme.colorScheme.surfaceVariant modifier = Modifier
.clickable(role = Role.Button, onClick = onInvitesClicked)
.padding(horizontal = 16.dp)
.align(Alignment.CenterEnd)
.heightIn(min = 48.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Column( Text(
Modifier text = stringResource(CommonStrings.action_invites_list),
.fillMaxWidth() fontSize = 14.sp,
.padding(horizontal = 16.dp, vertical = 12.dp) style = MaterialTheme.typography.bodyMedium,
) { )
Row {
Text( if (state.invitesState == InvitesState.NewInvites) {
stringResource(R.string.session_verification_banner_title), Spacer(Modifier.width(8.dp))
modifier = Modifier.weight(1f),
style = ElementTextStyles.Bold.body, UnreadIndicatorAtom()
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Start,
)
Icon(
modifier = Modifier.clickable(onClick = onDismissClicked),
imageVector = Icons.Default.Close,
contentDescription = stringResource(CommonStrings.action_close)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(stringResource(R.string.session_verification_banner_message), style = ElementTextStyles.Regular.bodyMD)
Spacer(modifier = Modifier.height(12.dp))
Button(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 7.dp),
onClick = onVerifyClicked,
) {
Text(stringResource(CommonStrings.action_continue), style = ElementTextStyles.Button)
}
} }
} }
} }
} }
@Preview
@Composable
internal fun PreviewRequestVerificationHeaderLight() {
ElementPreviewLight {
RequestVerificationHeader(onVerifyClicked = {}, onDismissClicked = {})
}
}
@Preview
@Composable
internal fun PreviewRequestVerificationHeaderDark() {
ElementPreviewDark {
RequestVerificationHeader(onVerifyClicked = {}, onDismissClicked = {})
}
}
internal fun RoomListRoomSummary.contentType() = isPlaceholder internal fun RoomListRoomSummary.contentType() = isPlaceholder
@Preview @Preview
@ -369,18 +304,7 @@ private fun ContentToPreview(state: RoomListState) {
onVerifyClicked = {}, onVerifyClicked = {},
onCreateRoomClicked = {}, onCreateRoomClicked = {},
onInvitesClicked = {}, onInvitesClicked = {},
onRoomSettingsClicked = {} onRoomSettingsClicked = {},
onMenuActionClicked = {},
) )
} }
@Preview
@Composable
internal fun RoomListSearchResultContentPreview() {
ElementPreviewLight {
RoomListSearchResultContent(
state = aRoomListState(),
onRoomClicked = {},
onRoomLongClicked = {}
)
}
}

View file

@ -0,0 +1,113 @@
/*
* 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.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun RequestVerificationHeader(
onVerifyClicked: () -> Unit,
onDismissClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Surface(
modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row {
Text(
stringResource(R.string.session_verification_banner_title),
modifier = Modifier.weight(1f),
style = ElementTextStyles.Bold.body,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Start,
)
Icon(
modifier = Modifier.clickable(onClick = onDismissClicked),
imageVector = Icons.Default.Close,
contentDescription = stringResource(CommonStrings.action_close)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
stringResource(R.string.session_verification_banner_message),
style = ElementTextStyles.Regular.bodyMD
)
Spacer(modifier = Modifier.height(12.dp))
Button(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 7.dp),
onClick = onVerifyClicked,
) {
Text(
stringResource(CommonStrings.action_continue),
style = ElementTextStyles.Button
)
}
}
}
}
}
@Preview
@Composable
internal fun PreviewRequestVerificationHeaderLight() {
ElementPreviewLight {
RequestVerificationHeader(onVerifyClicked = {}, onDismissClicked = {})
}
}
@Preview
@Composable
internal fun PreviewRequestVerificationHeaderDark() {
ElementPreviewDark {
RequestVerificationHeader(onVerifyClicked = {}, onDismissClicked = {})
}
}

View file

@ -0,0 +1,22 @@
/*
* 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.components
enum class RoomListMenuAction {
InviteFriends,
ReportBug
}

View file

@ -19,23 +19,33 @@ package io.element.android.features.roomlist.impl.components
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
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.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.roomlist.impl.R import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@ -57,6 +67,7 @@ fun RoomListTopBar(
areSearchResultsDisplayed: Boolean, areSearchResultsDisplayed: Boolean,
onFilterChanged: (String) -> Unit, onFilterChanged: (String) -> Unit,
onToggleSearch: () -> Unit, onToggleSearch: () -> Unit,
onMenuActionClicked: (RoomListMenuAction) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior, scrollBehavior: TopAppBarScrollBehavior,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -79,6 +90,7 @@ fun RoomListTopBar(
matrixUser = matrixUser, matrixUser = matrixUser,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onSearchClicked = onToggleSearch, onSearchClicked = onToggleSearch,
onMenuActionClicked = onMenuActionClicked,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
modifier = modifier, modifier = modifier,
) )
@ -89,16 +101,20 @@ fun RoomListTopBar(
private fun DefaultRoomListTopBar( private fun DefaultRoomListTopBar(
matrixUser: MatrixUser?, matrixUser: MatrixUser?,
scrollBehavior: TopAppBarScrollBehavior, scrollBehavior: TopAppBarScrollBehavior,
onOpenSettings: () -> Unit,
onSearchClicked: () -> Unit,
onMenuActionClicked: (RoomListMenuAction) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onOpenSettings: () -> Unit = {},
onSearchClicked: () -> Unit = {},
) { ) {
var showMenu by remember { mutableStateOf(false) }
MediumTopAppBar( MediumTopAppBar(
modifier = modifier modifier = modifier
.nestedScroll(scrollBehavior.nestedScrollConnection), .nestedScroll(scrollBehavior.nestedScrollConnection),
title = { title = {
val fontSize = if (scrollBehavior.state.collapsedFraction > 0.5) 20.sp else 22.sp
Text( Text(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.headlineMedium.copy(fontSize = fontSize),
text = stringResource(id = R.string.screen_roomlist_main_space_title) text = stringResource(id = R.string.screen_roomlist_main_space_title)
) )
}, },
@ -108,7 +124,11 @@ private fun DefaultRoomListTopBar(
modifier = Modifier.testTag(TestTags.homeScreenSettings), modifier = Modifier.testTag(TestTags.homeScreenSettings),
onClick = onOpenSettings onClick = onOpenSettings
) { ) {
val avatarData by remember { derivedStateOf { matrixUser.getAvatarData() } } val avatarData by remember {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
Avatar(avatarData, contentDescription = stringResource(CommonStrings.common_settings)) Avatar(avatarData, contentDescription = stringResource(CommonStrings.common_settings))
} }
} }
@ -119,6 +139,32 @@ private fun DefaultRoomListTopBar(
) { ) {
Icon(Icons.Default.Search, contentDescription = stringResource(CommonStrings.action_search)) Icon(Icons.Default.Search, contentDescription = stringResource(CommonStrings.action_search))
} }
IconButton(
onClick = { showMenu = !showMenu }
) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.InviteFriends)
},
text = { Text(stringResource(id = CommonStrings.action_invite)) },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
)
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.ReportBug)
},
text = { Text(stringResource(id = CommonStrings.common_report_a_bug)) },
leadingIcon = { Icon(Icons.Default.BugReport, contentDescription = null) }
)
}
}, },
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
windowInsets = WindowInsets(0.dp), windowInsets = WindowInsets(0.dp),
@ -139,5 +185,8 @@ private fun DefaultRoomListTopBarPreview() {
DefaultRoomListTopBar( DefaultRoomListTopBar(
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"), matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onSearchClicked = {},
onMenuActionClicked = {},
) )
} }

View file

@ -0,0 +1,103 @@
/*
* 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.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.ElementTheme
/**
* https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=6547%3A147623
*/
@Composable
internal fun RoomSummaryPlaceholderRow(
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(minHeight)
.padding(horizontal = 16.dp),
) {
Box(
modifier = Modifier
.size(AvatarSize.RoomListItem.dp)
.align(Alignment.CenterVertically)
.background(color = ElementTheme.compoundColors.textPlaceholder, shape = CircleShape)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, top = 19.dp, end = 4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(22.dp),
verticalAlignment = Alignment.CenterVertically
) {
PlaceholderAtom(width = 40.dp, height = 7.dp)
Spacer(modifier = Modifier.width(7.dp))
PlaceholderAtom(width = 45.dp, height = 7.dp)
Spacer(modifier = Modifier.weight(1f))
PlaceholderAtom(width = 22.dp, height = 4.dp)
}
Row(
modifier = Modifier
.height(25.dp),
verticalAlignment = Alignment.CenterVertically
) {
PlaceholderAtom(width = 70.dp, height = 6.dp)
Spacer(modifier = Modifier.width(6.dp))
PlaceholderAtom(width = 70.dp, height = 6.dp)
}
}
}
}
@Preview
@Composable
internal fun RoomSummaryPlaceholderRowLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun RoomSummaryPlaceholderRowDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
RoomSummaryPlaceholderRow()
}

View file

@ -17,28 +17,22 @@
package io.element.android.features.roomlist.impl.components package io.element.android.features.roomlist.impl.components
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -54,24 +48,21 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
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 com.google.accompanist.placeholder.material.placeholder
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvider import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvider
import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListPlaceHolder
import io.element.android.libraries.designsystem.theme.roomListRoomMessage import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
private val minHeight = 72.dp internal val minHeight = 84.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
internal fun RoomSummaryRow( internal fun RoomSummaryRow(
room: RoomListRoomSummary, room: RoomListRoomSummary,
@ -79,108 +70,111 @@ internal fun RoomSummaryRow(
onLongClick: (RoomListRoomSummary) -> Unit, onLongClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val clickModifier = if (room.isPlaceholder) { if (room.isPlaceholder) {
modifier RoomSummaryPlaceholderRow(
modifier = modifier,
)
} else { } else {
modifier.combinedClickable( RoomSummaryRealRow(
onClick = { onClick(room) }, room = room,
onLongClick = { onLongClick(room) }, onClick = onClick,
indication = rememberRipple(), onLongClick = onLongClick,
interactionSource = remember { MutableInteractionSource() } modifier = modifier
) )
} }
Box( }
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun RoomSummaryRealRow(
room: RoomListRoomSummary,
onClick: (RoomListRoomSummary) -> Unit,
onLongClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
) {
val clickModifier = Modifier.combinedClickable(
onClick = { onClick(room) },
onLongClick = { onLongClick(room) },
indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() }
)
Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = minHeight) .heightIn(min = minHeight)
.then(clickModifier) .then(clickModifier)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 11.dp)
.height(IntrinsicSize.Min),
) { ) {
DefaultRoomSummaryRow(room = room) Avatar(
room
.avatarData,
modifier = Modifier
.align(Alignment.CenterVertically)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp)
) {
Row(modifier = Modifier.fillMaxWidth()) {
NameAndTimestampRow(room = room)
}
Row(modifier = Modifier.fillMaxWidth()) {
LastMessageAndIndicatorRow(room = room)
}
}
} }
} }
@Composable @Composable
internal fun DefaultRoomSummaryRow( private fun RowScope.NameAndTimestampRow(room: RoomListRoomSummary) {
room: RoomListRoomSummary, // Name
) { Text(
Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(1f)
.padding(horizontal = 16.dp) .padding(end = 16.dp),
.height(IntrinsicSize.Min), fontSize = 16.sp,
verticalAlignment = CenterVertically fontWeight = FontWeight.SemiBold,
) { style = MaterialTheme.typography.bodyMedium,
Avatar( text = room.name,
room.avatarData, color = MaterialTheme.roomListRoomName(),
modifier = Modifier.placeholder( maxLines = 1,
visible = room.isPlaceholder, overflow = TextOverflow.Ellipsis
shape = CircleShape, )
color = ElementTheme.colors.roomListPlaceHolder(), // Timestamp
) Text(
) fontSize = 12.sp,
Column( text = room.timestamp ?: "",
modifier = Modifier color = MaterialTheme.roomListRoomMessageDate(),
.padding(start = 12.dp, end = 4.dp, top = 12.dp, bottom = 12.dp) )
.alignByBaseline() }
.weight(1f)
) { @Composable
// Name private fun RowScope.LastMessageAndIndicatorRow(room: RoomListRoomSummary) {
Text( // Last Message
modifier = Modifier.placeholder( val attributedLastMessage = (room.lastMessage as? AnnotatedString)
visible = room.isPlaceholder, ?: AnnotatedString(room.lastMessage.orEmpty().toString())
shape = TextPlaceholderShape, Text(
color = ElementTheme.colors.roomListPlaceHolder(), modifier = Modifier
), .weight(1f)
fontSize = 16.sp, .padding(end = 28.dp),
fontWeight = FontWeight.SemiBold, text = attributedLastMessage,
text = room.name, color = MaterialTheme.roomListRoomMessage(),
color = MaterialTheme.roomListRoomName(), fontSize = 14.sp,
maxLines = 1, style = MaterialTheme.typography.bodySmall,
overflow = TextOverflow.Ellipsis maxLines = 2,
) overflow = TextOverflow.Ellipsis
// Last Message )
val attributedLastMessage = (room.lastMessage as? AnnotatedString) // Unread
?: AnnotatedString(room.lastMessage.orEmpty().toString()) val unreadIndicatorColor =
Text( if (room.hasUnread) MaterialTheme.roomListUnreadIndicator() else Color.Transparent
modifier = Modifier.placeholder( UnreadIndicatorAtom(
visible = room.isPlaceholder, modifier = Modifier.padding(top = 3.dp),
shape = TextPlaceholderShape, color = unreadIndicatorColor,
color = ElementTheme.colors.roomListPlaceHolder(), )
),
text = attributedLastMessage,
color = MaterialTheme.roomListRoomMessage(),
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
// Timestamp and Unread
Column(
modifier = Modifier
.alignByBaseline(),
) {
Text(
modifier = Modifier.placeholder(
visible = room.isPlaceholder,
shape = TextPlaceholderShape,
color = ElementTheme.colors.roomListPlaceHolder(),
),
fontSize = 12.sp,
text = room.timestamp ?: "",
color = MaterialTheme.roomListRoomMessageDate(),
)
Spacer(Modifier.size(4.dp))
val unreadIndicatorColor =
if (room.hasUnread) MaterialTheme.roomListUnreadIndicator() else Color.Transparent
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(unreadIndicatorColor)
.align(Alignment.End),
)
}
}
} }
val TextPlaceholderShape = PercentRectangleSizeShape(0.5f) val TextPlaceholderShape = PercentRectangleSizeShape(0.5f)

View file

@ -17,18 +17,18 @@
package io.element.android.features.roomlist.impl.model package io.element.android.features.roomlist.impl.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@Immutable @Immutable
data class RoomListRoomSummary( data class RoomListRoomSummary constructor(
val id: String, val id: String,
val roomId: RoomId, val roomId: RoomId,
val name: String = "", val name: String = "",
val hasUnread: Boolean = false, val hasUnread: Boolean = false,
val timestamp: String? = null, val timestamp: String? = null,
val lastMessage: CharSequence? = null, val lastMessage: CharSequence? = null,
val avatarData: AvatarData = AvatarData(id, name), val avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
val isPlaceholder: Boolean = false, val isPlaceholder: Boolean = false,
) )

View file

@ -17,6 +17,7 @@
package io.element.android.features.roomlist.impl.model package io.element.android.features.roomlist.impl.model
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
object RoomListRoomSummaryPlaceholders { object RoomListRoomSummaryPlaceholders {
@ -29,7 +30,7 @@ object RoomListRoomSummaryPlaceholders {
name = "Short name", name = "Short name",
timestamp = "hh:mm", timestamp = "hh:mm",
lastMessage = "Last message for placeholder", lastMessage = "Last message for placeholder",
avatarData = AvatarData(id, "S") avatarData = AvatarData(id, "S", size = AvatarSize.RoomListItem)
) )
} }

View file

@ -18,6 +18,7 @@ package io.element.android.features.roomlist.impl.model
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> { open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> {
@ -28,7 +29,15 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
aRoomListRoomSummary().copy(hasUnread = true), aRoomListRoomSummary().copy(hasUnread = true),
aRoomListRoomSummary().copy(timestamp = "88:88"), aRoomListRoomSummary().copy(timestamp = "88:88"),
aRoomListRoomSummary().copy(timestamp = "88:88", hasUnread = true), aRoomListRoomSummary().copy(timestamp = "88:88", hasUnread = true),
aRoomListRoomSummary().copy(isPlaceholder = true), aRoomListRoomSummary().copy(isPlaceholder = true, timestamp = "88:88"),
aRoomListRoomSummary().copy(
name = "A very long room name that should be truncated",
lastMessage = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt" +
" ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea com" +
"modo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
timestamp = "yesterday",
hasUnread = true,
),
) )
} }
@ -39,6 +48,6 @@ fun aRoomListRoomSummary() = RoomListRoomSummary(
hasUnread = false, hasUnread = false,
timestamp = null, timestamp = null,
lastMessage = "Last message", lastMessage = "Last message",
avatarData = AvatarData("!roomId", "Room name"), avatarData = AvatarData("!roomId", "Room name", size = AvatarSize.RoomListItem),
isPlaceholder = false, isPlaceholder = false,
) )

View file

@ -48,15 +48,19 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.roomlist.impl.RoomListEvents import io.element.android.features.roomlist.impl.RoomListEvents
import io.element.android.features.roomlist.impl.RoomListState import io.element.android.features.roomlist.impl.RoomListState
import io.element.android.features.roomlist.impl.aRoomListState
import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.contentType import io.element.android.features.roomlist.impl.contentType
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.modifiers.applyIf import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
@ -109,6 +113,7 @@ internal fun RoomListSearchResultContent(
fun onBackButtonPressed() { fun onBackButtonPressed() {
state.eventSink(RoomListEvents.ToggleSearchResults) state.eventSink(RoomListEvents.ToggleSearchResults)
} }
fun onRoomClicked(room: RoomListRoomSummary) { fun onRoomClicked(room: RoomListRoomSummary) {
onRoomClicked(room.roomId) onRoomClicked(room.roomId)
} }
@ -212,3 +217,23 @@ internal fun RoomListSearchResultContent(
} }
} }
} }
@Preview
@Composable
internal fun RoomListSearchResultContentLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun RoomListSearchResultContentDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Preview
@Composable
internal fun ContentToPreview() {
RoomListSearchResultContent(
state = aRoomListState(),
onRoomClicked = {},
onRoomLongClicked = {}
)
}

View file

@ -28,6 +28,7 @@ import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
@ -400,6 +401,6 @@ private val aRoomListRoomSummary = RoomListRoomSummary(
hasUnread = true, hasUnread = true,
timestamp = A_FORMATTED_DATE, timestamp = A_FORMATTED_DATE,
lastMessage = "", lastMessage = "",
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME), avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem),
isPlaceholder = false, isPlaceholder = false,
) )

View file

@ -30,8 +30,12 @@ dependencies {
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(libs.dagger) implementation(libs.dagger)
implementation(libs.androidx.corektx) implementation(libs.androidx.corektx)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)

View file

@ -0,0 +1,54 @@
/*
* 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.libraries.deeplink.usecase
import android.app.Activity
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.services.toolbox.api.strings.StringProvider
import timber.log.Timber
import javax.inject.Inject
import io.element.android.libraries.androidutils.R as AndroidUtilsR
import io.element.android.libraries.ui.strings.CommonStrings
class InviteFriendsUseCase @Inject constructor(
private val stringProvider: StringProvider,
private val matrixClient: MatrixClient,
private val buildMeta: BuildMeta,
) {
fun execute(activity: Activity) {
val permalinkResult = PermalinkBuilder.permalinkForUser(matrixClient.sessionId)
permalinkResult.fold(
onSuccess = { permalink ->
val appName = buildMeta.applicationName
startSharePlainTextIntent(
context = activity,
activityResultLauncher = null,
chooserTitle = stringProvider.getString(CommonStrings.action_invite_friends),
text = stringProvider.getString(CommonStrings.invite_friends_text, appName, permalink),
extraTitle = stringProvider.getString(CommonStrings.invite_friends_rich_title, appName),
noActivityFoundMessage = stringProvider.getString(AndroidUtilsR.string.error_no_compatible_app_found)
)
},
onFailure = {
Timber.e(it)
}
)
}
}

View file

@ -0,0 +1,71 @@
/*
* 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.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.ElementTheme
@Composable
fun PlaceholderAtom(
width: Dp,
height: Dp,
modifier: Modifier = Modifier,
color: Color = ElementTheme.compoundColors.textPlaceholder,
) {
Box(
modifier = modifier
.width(width)
.height(height)
.background(
color = color,
shape = RoundedCornerShape(size = height / 2)
)
)
}
@Preview
@Composable
internal fun PlaceholderAtomLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun PlaceholderAtomDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
// Use a Red background to see the shape
Box(modifier = Modifier.background(color = Color.Red)) {
PlaceholderAtom(
width = 80.dp,
height = 12.dp
)
}
}

View file

@ -0,0 +1,62 @@
/*
* 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.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
@Composable
fun UnreadIndicatorAtom(
modifier: Modifier = Modifier,
size: Dp = 12.dp,
color: Color = MaterialTheme.roomListUnreadIndicator(),
) {
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(color)
)
}
@Preview
@Composable
internal fun UnreadIndicatorAtomLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun UnreadIndicatorAtomDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
UnreadIndicatorAtom()
}

View file

@ -17,7 +17,9 @@
package io.element.android.libraries.designsystem.components.avatar package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -30,11 +32,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.debugPlaceholderAvatar import io.element.android.libraries.designsystem.preview.debugPlaceholderAvatar
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.AvatarGradientEnd import io.element.android.libraries.theme.AvatarGradientEnd
import io.element.android.libraries.theme.AvatarGradientStart import io.element.android.libraries.theme.AvatarGradientStart
@ -100,7 +103,7 @@ private fun InitialsAvatar(
Text( Text(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
text = avatarData.initial, text = avatarData.initial,
fontSize = (avatarData.size.dp / 2).value.sp, fontSize = avatarData.size.dp.toSp() / 2,
color = Color.White, color = Color.White,
) )
} }
@ -109,4 +112,12 @@ private fun InitialsAvatar(
@Preview(group = PreviewGroup.Avatars) @Preview(group = PreviewGroup.Avatars)
@Composable @Composable
fun AvatarPreview(@PreviewParameter(AvatarDataProvider::class) avatarData: AvatarData) = fun AvatarPreview(@PreviewParameter(AvatarDataProvider::class) avatarData: AvatarData) =
ElementThemedPreview { Avatar(avatarData) } ElementThemedPreview {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Avatar(avatarData)
Text(text = avatarData.size.name + " " + avatarData.size.dp)
}
}

View file

@ -23,7 +23,7 @@ data class AvatarData(
val id: String, val id: String,
val name: String?, val name: String?,
val url: String? = null, val url: String? = null,
val size: AvatarSize = AvatarSize.MEDIUM val size: AvatarSize,
) { ) {
val initial by lazy { val initial by lazy {

View file

@ -20,15 +20,28 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class AvatarDataProvider : PreviewParameterProvider<AvatarData> { open class AvatarDataProvider : PreviewParameterProvider<AvatarData> {
override val values: Sequence<AvatarData> override val values: Sequence<AvatarData>
get() = sequenceOf( get() {
anAvatarData(), AvatarSize.values()
anAvatarData().copy(name = null), .also { it.sortBy { item -> item.name } }
anAvatarData().copy(url = "aUrl"), .asSequence()
) return AvatarSize.values().asSequence().map {
sequenceOf(
anAvatarData(size = it),
anAvatarData(size = it).copy(name = null),
anAvatarData(size = it).copy(url = "aUrl"),
)
}
.flatten()
}
} }
fun anAvatarData(id: String = "@id_of_alice:server.org", name: String = "Alice") = AvatarData( fun anAvatarData(
// Let's the id not start with a 'a'. // Let's the id not start with a 'a'.
id: String = "@id_of_alice:server.org",
name: String = "Alice",
size: AvatarSize = AvatarSize.RoomListItem,
) = AvatarData(
id = id, id = id,
name = name, name = name,
size = size,
) )

View file

@ -19,13 +19,25 @@ package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
sealed class AvatarSize(open val dp: Dp) { enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(28.dp),
object SMALL : AvatarSize(32.dp) RoomHeader(96.dp),
object MEDIUM : AvatarSize(40.dp) RoomListItem(52.dp),
object BIG : AvatarSize(48.dp)
object HUGE : AvatarSize(96.dp)
// FIXME maybe remove this field and switch back to an enum (or remove this class) when design system will be integrated ForwardRoomListItem(36.dp),
data class Custom(override val dp: Dp) : AvatarSize(dp)
UserHeader(96.dp),
UserListItem(36.dp),
SelectedUser(56.dp),
SelectedRoom(56.dp),
TimelineRoom(32.dp),
TimelineSender(32.dp),
MessageActionSender(32.dp),
RoomInviteItem(52.dp),
InviteSender(16.dp),
} }

View file

@ -0,0 +1,47 @@
/*
* 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.libraries.designsystem.text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
/**
* Convert Dp to Sp, regarding current density.
* Can be used for instance to use Dp unit for text.
*/
@Composable
fun Dp.toSp(): TextUnit = with(LocalDensity.current) { toSp() }
/**
* Convert Px value to Dp, regarding current density.
*/
@Composable
fun Int.toDp(): Dp = with(LocalDensity.current) { toDp() }
/**
* Convert Dp value to pixels, regarding current density.
*/
@Composable
fun Dp.toPx(): Float = with(LocalDensity.current) { toPx() }
/**
* Convert Dp value to pixels, regarding current density.
*/
@Composable
fun Dp.roundToPx(): Int = with(LocalDensity.current) { roundToPx() }

View file

@ -22,10 +22,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.ElementColors
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.theme.SystemGrey4Dark
import io.element.android.libraries.theme.SystemGrey6Light
import io.element.android.libraries.theme.previews.ColorListPreview import io.element.android.libraries.theme.previews.ColorListPreview
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
@ -44,9 +40,6 @@ fun MaterialTheme.roomListRoomMessageDate() = colorScheme.secondary
@Composable @Composable
fun MaterialTheme.roomListUnreadIndicator() = colorScheme.primary fun MaterialTheme.roomListUnreadIndicator() = colorScheme.primary
@Composable
fun ElementColors.roomListPlaceHolder() = if (isLight) SystemGrey6Light else SystemGrey4Dark
@Preview @Preview
@Composable @Composable
internal fun ColorAliasesLightPreview() = ElementPreviewLight { ContentToPreview() } internal fun ColorAliasesLightPreview() = ElementPreviewLight { ContentToPreview() }
@ -65,7 +58,6 @@ private fun ContentToPreview() {
"roomListRoomMessage" to MaterialTheme.roomListRoomMessage(), "roomListRoomMessage" to MaterialTheme.roomListRoomMessage(),
"roomListRoomMessageDate" to MaterialTheme.roomListRoomMessageDate(), "roomListRoomMessageDate" to MaterialTheme.roomListRoomMessageDate(),
"roomListUnreadIndicator" to MaterialTheme.roomListUnreadIndicator(), "roomListUnreadIndicator" to MaterialTheme.roomListUnreadIndicator(),
"roomListPlaceHolder" to ElementTheme.colors.roomListPlaceHolder(),
) )
) )
} }

View file

@ -22,6 +22,7 @@ import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -49,11 +50,21 @@ fun CircularProgressIndicator(
color: Color = ProgressIndicatorDefaults.circularColor, color: Color = ProgressIndicatorDefaults.circularColor,
strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth, strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth,
) { ) {
androidx.compose.material3.CircularProgressIndicator( if (LocalInspectionMode.current) {
modifier = modifier, // Use a determinate progress indicator to improve the preview rendering
color = color, androidx.compose.material3.CircularProgressIndicator(
strokeWidth = strokeWidth, modifier = modifier,
) progress = 0.75F,
color = color,
strokeWidth = strokeWidth,
)
} else {
androidx.compose.material3.CircularProgressIndicator(
modifier = modifier,
color = color,
strokeWidth = strokeWidth,
)
}
} }
@Preview(group = PreviewGroup.Progress) @Preview(group = PreviewGroup.Progress)
@ -68,7 +79,7 @@ private fun ContentToPreview() {
) )
// Fixed progress // Fixed progress
CircularProgressIndicator( CircularProgressIndicator(
progress = 0.75F progress = 0.90F
) )
} }
} }

View file

@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.theme.ElementTheme
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -50,7 +51,7 @@ fun ModalBottomSheet(
shape: Shape = BottomSheetDefaults.ExpandedShape, shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor, containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor), contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = BottomSheetDefaults.Elevation, tonalElevation: Dp = if (ElementTheme.colors.isLight) 0.dp else BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor, scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets, windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,

View file

@ -16,7 +16,10 @@
package io.element.android.libraries.matrix.api.timeline.item.event package io.element.android.libraries.matrix.api.timeline.item.event
import io.element.android.libraries.matrix.api.core.UserId
data class EventReaction( data class EventReaction(
val key: String, val key: String,
val count: Long val count: Long,
val senderIds: List<UserId>
) )

View file

@ -78,7 +78,8 @@ private fun List<Reaction>?.map(): List<EventReaction> {
return this?.map { return this?.map {
EventReaction( EventReaction(
key = it.key, key = it.key,
count = it.count.toLong() count = it.count.toLong(),
senderIds = it.senders.map { sender -> UserId(sender) }
) )
} ?: emptyList() } ?: emptyList()
} }

View file

@ -42,7 +42,7 @@ fun CheckableMatrixUserRow(
checked: Boolean, checked: Boolean,
matrixUser: MatrixUser, matrixUser: MatrixUser,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.MEDIUM, avatarSize: AvatarSize = AvatarSize.UserListItem,
onCheckedChange: (Boolean) -> Unit = {}, onCheckedChange: (Boolean) -> Unit = {},
enabled: Boolean = true, enabled: Boolean = true,
) = CheckableUserRow( ) = CheckableUserRow(

View file

@ -57,7 +57,7 @@ fun MatrixUserHeader(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Avatar( Avatar(
matrixUser.getAvatarData(size = AvatarSize.HUGE), matrixUser.getAvatarData(size = AvatarSize.UserHeader),
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Name // Name

View file

@ -16,13 +16,10 @@
package io.element.android.libraries.matrix.ui.components package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -49,7 +46,7 @@ import io.element.android.libraries.matrix.ui.model.getBestName
fun MatrixUserRow( fun MatrixUserRow(
matrixUser: MatrixUser, matrixUser: MatrixUser,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.Custom(36.dp), avatarSize: AvatarSize = AvatarSize.UserListItem,
) = UserRow( ) = UserRow(
avatarData = matrixUser.getAvatarData(avatarSize), avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(), name = matrixUser.getBestName(),
@ -67,16 +64,14 @@ fun UserRow(
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp) .heightIn(min = 56.dp)
.height(IntrinsicSize.Min), .padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Avatar(avatarData) Avatar(avatarData)
Column( Column(
modifier = Modifier modifier = Modifier
.padding(start = 12.dp) .padding(start = 12.dp),
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) { ) {
// Name // Name
Text( Text(

View file

@ -47,7 +47,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.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
@ -56,13 +55,14 @@ fun SelectedRoom(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onRoomRemoved: (RoomSummaryDetails) -> Unit = {}, onRoomRemoved: (RoomSummaryDetails) -> Unit = {},
) { ) {
Box(modifier = modifier Box(
.width(56.dp) modifier = modifier
.width(56.dp)
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Avatar(AvatarData(roomSummary.roomId.value, roomSummary.name, roomSummary.avatarURLString, AvatarSize.Custom(56.dp))) Avatar(AvatarData(roomSummary.roomId.value, roomSummary.name, roomSummary.avatarURLString, AvatarSize.SelectedRoom))
Text( Text(
text = roomSummary.name, text = roomSummary.name,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -102,8 +102,8 @@ internal fun SelectedRoomDarkPreview() = ElementPreviewDark { ContentToPreview()
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
SelectedRoom(roomSummary = SelectedRoom(
RoomSummaryDetails( roomSummary = RoomSummaryDetails(
roomId = RoomId("!room:domain"), roomId = RoomId("!room:domain"),
name = "roomName", name = "roomName",
canonicalAlias = null, canonicalAlias = null,

View file

@ -55,13 +55,14 @@ fun SelectedUser(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onUserRemoved: (MatrixUser) -> Unit = {}, onUserRemoved: (MatrixUser) -> Unit = {},
) { ) {
Box(modifier = modifier Box(
.width(56.dp) modifier = modifier
.width(AvatarSize.SelectedUser.dp)
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Avatar(matrixUser.getAvatarData(size = AvatarSize.Custom(56.dp))) Avatar(matrixUser.getAvatarData(size = AvatarSize.SelectedUser))
Text( Text(
text = matrixUser.getBestName(), text = matrixUser.getBestName(),
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,

View file

@ -36,11 +36,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -76,8 +76,8 @@ fun SelectedUsersList(
// users, the last visible user will be precisely half visible. This gives an obvious affordance that there are more entries and the list can be scrolled. // users, the last visible user will be precisely half visible. This gives an obvious affordance that there are more entries and the list can be scrolled.
// For efficiency, we assume that all the children are the same width. If they needed to be different sizes we'd have to do this calculation each time // For efficiency, we assume that all the children are the same width. If they needed to be different sizes we'd have to do this calculation each time
// they needed to be measured. // they needed to be measured.
val minimumSpacing = with(LocalDensity.current) { 24.dp.toPx() } val minimumSpacing = 24.dp.toPx()
val userWidth = with(LocalDensity.current) { 56.dp.toPx() } val userWidth = 56.dp.toPx()
val userSpacing by remember { val userSpacing by remember {
derivedStateOf { derivedStateOf {
if (rowWidth == 0) { if (rowWidth == 0) {

View file

@ -19,11 +19,9 @@ package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -44,6 +42,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.noFontPadding import io.element.android.libraries.designsystem.theme.noFontPadding
@ -59,15 +58,14 @@ fun UnresolvedUserRow(
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp) .heightIn(min = 56.dp)
.height(IntrinsicSize.Min), .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Avatar(avatarData) Avatar(avatarData)
Column( Column(
modifier = Modifier modifier = Modifier
.padding(start = 12.dp) .padding(start = 12.dp),
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
) { ) {
// ID // ID
@ -82,7 +80,11 @@ fun UnresolvedUserRow(
) )
// Warning // Warning
Row(modifier = Modifier.fillMaxWidth().padding(top = 3.dp)) { Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 3.dp)
) {
Icon( Icon(
imageVector = Icons.Filled.Error, imageVector = Icons.Filled.Error,
contentDescription = "", contentDescription = "",
@ -141,7 +143,7 @@ fun CheckableUnresolvedUserRow(
internal fun UnresolvedUserRowPreview() = internal fun UnresolvedUserRowPreview() =
ElementThemedPreview { ElementThemedPreview {
val matrixUser = aMatrixUser() val matrixUser = aMatrixUser()
UnresolvedUserRow(matrixUser.getAvatarData(), matrixUser.userId.value) UnresolvedUserRow(matrixUser.getAvatarData(size = AvatarSize.UserListItem), matrixUser.userId.value)
} }
@Preview @Preview
@ -150,9 +152,12 @@ internal fun CheckableUnresolvedUserRowPreview() =
ElementThemedPreview { ElementThemedPreview {
val matrixUser = aMatrixUser() val matrixUser = aMatrixUser()
Column { Column {
CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(AvatarSize.Custom(36.dp)), matrixUser.userId.value) CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(AvatarSize.UserListItem), matrixUser.userId.value)
CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(AvatarSize.Custom(36.dp)), matrixUser.userId.value) Divider()
CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(AvatarSize.Custom(36.dp)), matrixUser.userId.value, enabled = false) CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(AvatarSize.UserListItem), matrixUser.userId.value)
CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(AvatarSize.Custom(36.dp)), matrixUser.userId.value, enabled = false) Divider()
CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(AvatarSize.UserListItem), matrixUser.userId.value, enabled = false)
Divider()
CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(AvatarSize.UserListItem), matrixUser.userId.value, enabled = false)
} }
} }

View file

@ -20,7 +20,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
fun MatrixUser.getAvatarData(size: AvatarSize = AvatarSize.MEDIUM) = AvatarData( fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData(
id = userId.value, id = userId.value,
name = displayName, name = displayName,
url = avatarUrl, url = avatarUrl,

View file

@ -70,7 +70,6 @@ import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.modifiers.applyIf import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -79,6 +78,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.theme.LocalColors
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -92,13 +92,15 @@ fun TextComposer(
onSendMessage: (String) -> Unit = {}, onSendMessage: (String) -> Unit = {},
onResetComposerMode: () -> Unit = {}, onResetComposerMode: () -> Unit = {},
onComposerTextChange: (CharSequence) -> Unit = {}, onComposerTextChange: (CharSequence) -> Unit = {},
onAddAttachment:() -> Unit = {}, onAddAttachment: () -> Unit = {},
) { ) {
val text = composerText.orEmpty() val text = composerText.orEmpty()
Row(modifier.padding( Row(
horizontal = 12.dp, modifier.padding(
vertical = 8.dp horizontal = 12.dp,
), verticalAlignment = Alignment.Bottom) { vertical = 8.dp
), verticalAlignment = Alignment.Bottom
) {
AttachmentButton(onClick = onAddAttachment, modifier = Modifier.padding(vertical = 6.dp)) AttachmentButton(onClick = onAddAttachment, modifier = Modifier.padding(vertical = 6.dp))
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
var lineCount by remember { mutableStateOf(0) } var lineCount by remember { mutableStateOf(0) }
@ -206,10 +208,12 @@ private fun EditingModeView(
onResetComposerMode: () -> Unit, onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)) { .padding(horizontal = 12.dp, vertical = 8.dp)
) {
Icon( Icon(
resourceId = VectorIcons.Edit, resourceId = VectorIcons.Edit,
contentDescription = stringResource(CommonStrings.common_editing), contentDescription = stringResource(CommonStrings.common_editing),
@ -248,16 +252,11 @@ private fun ReplyToModeView(
onResetComposerMode: () -> Unit, onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val paddings = if (attachmentThumbnailInfo != null) {
PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp)
} else {
PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp)
}
Row( Row(
modifier modifier
.clip(RoundedCornerShape(13.dp)) .clip(RoundedCornerShape(13.dp))
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.padding(paddings) .padding(4.dp)
) { ) {
if (attachmentThumbnailInfo != null) { if (attachmentThumbnailInfo != null) {
AttachmentThumbnail( AttachmentThumbnail(
@ -267,45 +266,45 @@ private fun ReplyToModeView(
.size(36.dp) .size(36.dp)
.clip(RoundedCornerShape(9.dp)) .clip(RoundedCornerShape(9.dp))
) )
Spacer(modifier = Modifier.width(8.dp))
} }
Column(verticalArrangement = Arrangement.SpaceEvenly) { Spacer(modifier = Modifier.width(8.dp))
Row( Column(
horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier
modifier = Modifier.fillMaxWidth() .weight(1f)
) { .align(Alignment.CenterVertically)
Text( ) {
senderName, Text(
style = ElementTextStyles.Regular.caption2.copy(fontWeight = FontWeight.Medium), text = senderName,
textAlign = TextAlign.Start, modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary, style = ElementTextStyles.Regular.caption2.copy(fontWeight = FontWeight.Medium),
modifier = Modifier.weight(1f) textAlign = TextAlign.Start,
) color = MaterialTheme.colorScheme.primary,
Icon( )
imageVector = Icons.Default.Close,
contentDescription = stringResource(CommonStrings.action_close),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.size(16.dp)
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = MutableInteractionSource(),
indication = rememberRipple(bounded = false)
),
)
}
Text( Text(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = text.orEmpty(), text = text.orEmpty(),
style = ElementTextStyles.Regular.caption1, style = ElementTextStyles.Regular.caption1,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
color = LocalColors.current.placeholder, color = MaterialTheme.colorScheme.secondary,
maxLines = if (attachmentThumbnailInfo != null) 1 else 2, maxLines = if (attachmentThumbnailInfo != null) 1 else 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(CommonStrings.action_close),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp)
.size(16.dp)
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = MutableInteractionSource(),
indication = rememberRipple(bounded = false)
),
)
} }
} }

View file

@ -103,6 +103,7 @@ class RoomListScreen(
onCreateRoomClicked = {}, onCreateRoomClicked = {},
onInvitesClicked = {}, onInvitesClicked = {},
onRoomSettingsClicked = {}, onRoomSettingsClicked = {},
onMenuActionClicked = {},
modifier = modifier, modifier = modifier,
) )

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:01d54896176fd9da4f544d98c675a63135a014ef31eeecdc288f4d10958b6a21 oid sha256:add92c2e183badc5c0abc15a3364f820e24c4f3b4fa1fd9f33af174a6da74345
size 100991 size 101388

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8793296957822aa445599229ff8d7c7c079abfe7af02673381d44aed61a177fe oid sha256:7a6baba1197b01eb91e2dc6720482b97d9d0cdd5aaffa0d19d3e6b0860dadcf8
size 52697 size 52502

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7e16706d7108c057a068d884a429fa60cf5992b0cadf4444e08634c94bcf7a8f oid sha256:9db3469badeb7f71b5f9cb6df6b230158f8d383e48dae2b615e94b885b1f2145
size 80797 size 81076

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:373a1f1025a4c609c5a9ae5800993891d12bcb5ee7d997e889095963cba24f56 oid sha256:71eb9758ec399c188098a37ba527e3e32cf46aacc0296a4c9366b2b8d168e2b8
size 95919 size 91495

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e0c78298f09afcd5ee96cfef6bea6a7f3eeb0432d4fcaa13258fe1b4c3a978e9 oid sha256:f85f0a3b5bb3edd6d864a2a211437a278f6bb7a9b66367a9ed4d596f673dc197
size 80302 size 80258

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5c8d11bb4603264829024e23996342e8c86651a8ee06fc1415546d9784a09916 oid sha256:10fd32722844c7638b5200794ef021e1e25954d161a5a0f8e79207c481c4ef33
size 94975 size 90229

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cbc5d13ec61d0b5748e477663997f85ee692c4ad37b6578a1e74b93345fb22bf oid sha256:10422ab5d89261c0f2c4ec1060d265c43ef4aa25ccda22f2a203cc835db0b4e3
size 20658 size 22233

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1756353f38da7887c053a286d17867b38784b4a04831b2a2ff7fbeb5a008c060 oid sha256:aa46a03ec95b34565744c3de2e94b41bb471dcae8cbdd426b8ff9c7478054575
size 27946 size 28046

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:146bf1144c320343f8f7420ba0218f787282fc660f9853badf5078f0fd763874 oid sha256:145536794bb3e499a8efcd406d419ee6e8e858e8a10468f53401eb4f22e49f57
size 20941 size 22449

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:60c8046af4cc6f298c0ccb57caacb1e1b262577a0acf78cba711bb3592952903 oid sha256:5ba974bbeecee2446135a95eca579bea9935379c27abf272763a8cadf1e906c8
size 28799 size 28850

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1f720600c73dde446c99b95e8b4c0c5fe4dbfa901b4cb0ed6c3306fbddbdecd5 oid sha256:517708e2df231f0c849c45d8951273db716f8e57cc6c22a5276d012fc08653b3
size 10238 size 11784

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:59b5b4abb3d0a52870fbc48964a85dd7c36054a6e1ccca35f3c845cfc360cacd oid sha256:b5ee6026d78583d2c802ed200c70715afd94d390e0a00936da3986f8dece1b31
size 9251 size 10803

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d4d5c94e5d8ec1dda8ac7e40ed26b9253e1c76a0f2b6d31f75de2462b2648426 oid sha256:6ec7c0893d93a5b1c6b42e13826ac9978363d7acc0943e66db0312552b71d8db
size 142100 size 143797

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5334397dff4a0a4980884ac9433827d6e566147ac2d214e8dab83a90fab692b2 oid sha256:18db29d75fc1b8a2f79e03ebd4b7080ae76bdd01256f5a577c7fcaa1bc52f0f2
size 277318 size 278367

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d4d5c94e5d8ec1dda8ac7e40ed26b9253e1c76a0f2b6d31f75de2462b2648426 oid sha256:6ec7c0893d93a5b1c6b42e13826ac9978363d7acc0943e66db0312552b71d8db
size 142100 size 143797

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5334397dff4a0a4980884ac9433827d6e566147ac2d214e8dab83a90fab692b2 oid sha256:18db29d75fc1b8a2f79e03ebd4b7080ae76bdd01256f5a577c7fcaa1bc52f0f2
size 277318 size 278367

Some files were not shown because too many files have changed in this diff Show more