UX cleanup: room details (#2816)
* UX cleanup: room details screen Add new CTA buttons for Invite and Call actions * Update screenshots * Fix maestro --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
d86b7d24db
commit
46b22d7db7
34 changed files with 149 additions and 94 deletions
|
|
@ -16,7 +16,7 @@ appId: ${MAESTRO_APP_ID}
|
||||||
- tapOn: "Create"
|
- tapOn: "Create"
|
||||||
- takeScreenshot: build/maestro/320-createAndDeleteRoom
|
- takeScreenshot: build/maestro/320-createAndDeleteRoom
|
||||||
- tapOn: "aRoomName"
|
- tapOn: "aRoomName"
|
||||||
- tapOn: "Invite people"
|
- tapOn: "Invite"
|
||||||
# assert there's 1 member and 1 invitee
|
# assert there's 1 member and 1 invitee
|
||||||
- tapOn: "Search for someone"
|
- tapOn: "Search for someone"
|
||||||
- inputText: ${MAESTRO_INVITEE2_MXID}
|
- inputText: ${MAESTRO_INVITEE2_MXID}
|
||||||
|
|
|
||||||
1
changelog.d/2814.misc
Normal file
1
changelog.d/2814.misc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
UX cleanup: room details screen, add new CTA buttons for Invite and Call actions.
|
||||||
|
|
@ -86,6 +86,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||||
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.matrix.ui.room.canCall
|
||||||
import io.element.android.libraries.matrix.ui.room.canRedactOtherAsState
|
import io.element.android.libraries.matrix.ui.room.canRedactOtherAsState
|
||||||
import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState
|
import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState
|
||||||
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
|
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
|
||||||
|
|
@ -158,9 +159,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
var canJoinCall by rememberSaveable {
|
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// Remove the unread flag on entering but don't send read receipts
|
// Remove the unread flag on entering but don't send read receipts
|
||||||
|
|
@ -170,12 +169,6 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(syncUpdateFlow.value) {
|
|
||||||
withContext(dispatchers.io) {
|
|
||||||
canJoinCall = room.canUserJoinCall(room.sessionId).getOrDefault(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val inviteProgress = remember { mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) }
|
val inviteProgress = remember { mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) }
|
||||||
var showReinvitePrompt by remember { mutableStateOf(false) }
|
var showReinvitePrompt by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow.value) {
|
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow.value) {
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@ class MessagesPresenterTest {
|
||||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent()))
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent()))
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -298,10 +298,9 @@ class MessagesPresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(2)
|
val initialState = awaitFirstItem()
|
||||||
val initialState = awaitItem()
|
|
||||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(initialState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
// Otherwise we would have some extra items here
|
// Otherwise we would have some extra items here
|
||||||
ensureAllEventsConsumed()
|
ensureAllEventsConsumed()
|
||||||
}
|
}
|
||||||
|
|
@ -335,7 +334,7 @@ class MessagesPresenterTest {
|
||||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||||
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
||||||
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -368,7 +367,7 @@ class MessagesPresenterTest {
|
||||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||||
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
||||||
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -394,7 +393,7 @@ class MessagesPresenterTest {
|
||||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||||
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
||||||
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -408,7 +407,7 @@ class MessagesPresenterTest {
|
||||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent()))
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent()))
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Edit::class.java)
|
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Edit::class.java)
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -732,7 +731,7 @@ class MessagesPresenterTest {
|
||||||
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
||||||
assertThat(replyMode.attachmentThumbnailInfo?.textContent)
|
assertThat(replyMode.attachmentThumbnailInfo?.textContent)
|
||||||
.isEqualTo("What type of food should we have at the party?")
|
.isEqualTo("What type of food should we have at the party?")
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,9 @@ dependencies {
|
||||||
api(projects.libraries.usersearch.api)
|
api(projects.libraries.usersearch.api)
|
||||||
api(projects.services.apperror.api)
|
api(projects.services.apperror.api)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(projects.features.leaveroom.api)
|
implementation(projects.features.call)
|
||||||
implementation(projects.features.createroom.api)
|
implementation(projects.features.createroom.api)
|
||||||
|
implementation(projects.features.leaveroom.api)
|
||||||
implementation(projects.features.userprofile.shared)
|
implementation(projects.features.userprofile.shared)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
implementation(projects.features.poll.api)
|
implementation(projects.features.poll.api)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.roomdetails.impl
|
package io.element.android.features.roomdetails.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -28,6 +29,8 @@ import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import dagger.assisted.Assisted
|
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.call.CallType
|
||||||
|
import io.element.android.features.call.ui.ElementCallActivity
|
||||||
import io.element.android.features.poll.api.history.PollHistoryEntryPoint
|
import io.element.android.features.poll.api.history.PollHistoryEntryPoint
|
||||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
|
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
|
||||||
|
|
@ -42,10 +45,12 @@ import io.element.android.libraries.architecture.BackstackView
|
||||||
import io.element.android.libraries.architecture.BaseFlowNode
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
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
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
@ -54,7 +59,9 @@ import kotlinx.parcelize.Parcelize
|
||||||
class RoomDetailsFlowNode @AssistedInject constructor(
|
class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
private val pollHistoryEntryPoint: PollHistoryEntryPoint,
|
private val pollHistoryEntryPoint: PollHistoryEntryPoint,
|
||||||
|
private val room: MatrixRoom,
|
||||||
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
|
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
|
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
|
||||||
|
|
@ -129,6 +136,14 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
override fun openAdminSettings() {
|
override fun openAdminSettings() {
|
||||||
backstack.push(NavTarget.AdminSettings)
|
backstack.push(NavTarget.AdminSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onJoinCall() {
|
||||||
|
val inputs = CallType.RoomCall(
|
||||||
|
sessionId = room.sessionId,
|
||||||
|
roomId = room.roomId,
|
||||||
|
)
|
||||||
|
ElementCallActivity.start(context, inputs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
|
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||||
fun openAvatarPreview(name: String, url: String)
|
fun openAvatarPreview(name: String, url: String)
|
||||||
fun openPollHistory()
|
fun openPollHistory()
|
||||||
fun openAdminSettings()
|
fun openAdminSettings()
|
||||||
|
fun onJoinCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callbacks = plugins<Callback>()
|
private val callbacks = plugins<Callback>()
|
||||||
|
|
@ -86,6 +87,10 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||||
callbacks.forEach { it.openPollHistory() }
|
callbacks.forEach { it.openPollHistory() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onJoinCall() {
|
||||||
|
callbacks.forEach { it.onJoinCall() }
|
||||||
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.onShareRoom(context: Context) = launch {
|
private fun CoroutineScope.onShareRoom(context: Context) = launch {
|
||||||
room.getPermalink()
|
room.getPermalink()
|
||||||
.onSuccess { permalink ->
|
.onSuccess { permalink ->
|
||||||
|
|
@ -162,6 +167,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||||
openAvatarPreview = ::openAvatarPreview,
|
openAvatarPreview = ::openAvatarPreview,
|
||||||
openPollHistory = ::openPollHistory,
|
openPollHistory = ::openPollHistory,
|
||||||
openAdminSettings = this::openAdminSettings,
|
openAdminSettings = this::openAdminSettings,
|
||||||
|
onJoinCallClicked = ::onJoinCall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.api.room.StateEventType
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
|
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
|
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
|
||||||
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
||||||
|
import io.element.android.libraries.matrix.ui.room.canCall
|
||||||
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
||||||
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
|
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
@ -86,11 +87,14 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val syncUpdateTimestamp by room.syncUpdateFlow.collectAsState()
|
||||||
|
|
||||||
val membersState by room.membersStateFlow.collectAsState()
|
val membersState by room.membersStateFlow.collectAsState()
|
||||||
val canInvite by getCanInvite(membersState)
|
val canInvite by getCanInvite(membersState)
|
||||||
val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME)
|
val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME)
|
||||||
val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR)
|
val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR)
|
||||||
val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
|
val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
|
||||||
|
val canJoinCall by room.canCall(updateKey = syncUpdateTimestamp)
|
||||||
val dmMember by room.getDirectRoomMember(membersState)
|
val dmMember by room.getDirectRoomMember(membersState)
|
||||||
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
|
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
|
||||||
val roomType by getRoomType(dmMember)
|
val roomType by getRoomType(dmMember)
|
||||||
|
|
@ -138,6 +142,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
canInvite = canInvite,
|
canInvite = canInvite,
|
||||||
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
|
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
|
||||||
canShowNotificationSettings = canShowNotificationSettings.value,
|
canShowNotificationSettings = canShowNotificationSettings.value,
|
||||||
|
canCall = canJoinCall,
|
||||||
roomType = roomType,
|
roomType = roomType,
|
||||||
roomMemberDetailsState = roomMemberDetailsState,
|
roomMemberDetailsState = roomMemberDetailsState,
|
||||||
leaveRoomState = leaveRoomState,
|
leaveRoomState = leaveRoomState,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ data class RoomDetailsState(
|
||||||
val canEdit: Boolean,
|
val canEdit: Boolean,
|
||||||
val canInvite: Boolean,
|
val canInvite: Boolean,
|
||||||
val canShowNotificationSettings: Boolean,
|
val canShowNotificationSettings: Boolean,
|
||||||
|
val canCall: Boolean,
|
||||||
val leaveRoomState: LeaveRoomState,
|
val leaveRoomState: LeaveRoomState,
|
||||||
val roomNotificationSettings: RoomNotificationSettings?,
|
val roomNotificationSettings: RoomNotificationSettings?,
|
||||||
val isFavorite: Boolean,
|
val isFavorite: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
|
||||||
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
|
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
|
||||||
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
|
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
|
||||||
),
|
),
|
||||||
|
aRoomDetailsState(canCall = false, canInvite = false),
|
||||||
// Add other state here
|
// Add other state here
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -89,6 +90,7 @@ fun aRoomDetailsState(
|
||||||
canInvite: Boolean = false,
|
canInvite: Boolean = false,
|
||||||
canEdit: Boolean = false,
|
canEdit: Boolean = false,
|
||||||
canShowNotificationSettings: Boolean = true,
|
canShowNotificationSettings: Boolean = true,
|
||||||
|
canCall: Boolean = true,
|
||||||
roomType: RoomDetailsType = RoomDetailsType.Room,
|
roomType: RoomDetailsType = RoomDetailsType.Room,
|
||||||
roomMemberDetailsState: UserProfileState? = null,
|
roomMemberDetailsState: UserProfileState? = null,
|
||||||
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
||||||
|
|
@ -107,6 +109,7 @@ fun aRoomDetailsState(
|
||||||
canInvite = canInvite,
|
canInvite = canInvite,
|
||||||
canEdit = canEdit,
|
canEdit = canEdit,
|
||||||
canShowNotificationSettings = canShowNotificationSettings,
|
canShowNotificationSettings = canShowNotificationSettings,
|
||||||
|
canCall = canCall,
|
||||||
roomType = roomType,
|
roomType = roomType,
|
||||||
roomMemberDetailsState = roomMemberDetailsState,
|
roomMemberDetailsState = roomMemberDetailsState,
|
||||||
leaveRoomState = leaveRoomState,
|
leaveRoomState = leaveRoomState,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
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.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
|
@ -100,6 +99,7 @@ fun RoomDetailsView(
|
||||||
openAvatarPreview: (name: String, url: String) -> Unit,
|
openAvatarPreview: (name: String, url: String) -> Unit,
|
||||||
openPollHistory: () -> Unit,
|
openPollHistory: () -> Unit,
|
||||||
openAdminSettings: () -> Unit,
|
openAdminSettings: () -> Unit,
|
||||||
|
onJoinCallClicked: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun onShareMember() {
|
fun onShareMember() {
|
||||||
|
|
@ -137,7 +137,9 @@ fun RoomDetailsView(
|
||||||
)
|
)
|
||||||
MainActionsSection(
|
MainActionsSection(
|
||||||
state = state,
|
state = state,
|
||||||
onShareRoom = onShareRoom
|
onShareRoom = onShareRoom,
|
||||||
|
onInvitePeople = invitePeople,
|
||||||
|
onCall = onJoinCallClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,20 +190,12 @@ fun RoomDetailsView(
|
||||||
}
|
}
|
||||||
|
|
||||||
val displayMemberListItem = state.roomType is RoomDetailsType.Room
|
val displayMemberListItem = state.roomType is RoomDetailsType.Room
|
||||||
val displayInviteMembersItem = state.canInvite
|
if (displayMemberListItem) {
|
||||||
if (displayMemberListItem || displayInviteMembersItem) {
|
|
||||||
PreferenceCategory {
|
PreferenceCategory {
|
||||||
if (displayMemberListItem) {
|
MembersItem(
|
||||||
MembersItem(
|
memberCount = state.memberCount,
|
||||||
memberCount = state.memberCount,
|
openRoomMemberList = openRoomMemberList,
|
||||||
openRoomMemberList = openRoomMemberList,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
if (displayInviteMembersItem) {
|
|
||||||
InviteItem(
|
|
||||||
invitePeople = invitePeople
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,10 +261,12 @@ private fun RoomDetailsTopBar(
|
||||||
private fun MainActionsSection(
|
private fun MainActionsSection(
|
||||||
state: RoomDetailsState,
|
state: RoomDetailsState,
|
||||||
onShareRoom: () -> Unit,
|
onShareRoom: () -> Unit,
|
||||||
|
onInvitePeople: () -> Unit,
|
||||||
|
onCall: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
) {
|
) {
|
||||||
val roomNotificationSettings = state.roomNotificationSettings
|
val roomNotificationSettings = state.roomNotificationSettings
|
||||||
if (state.canShowNotificationSettings && roomNotificationSettings != null) {
|
if (state.canShowNotificationSettings && roomNotificationSettings != null) {
|
||||||
|
|
@ -292,9 +288,22 @@ private fun MainActionsSection(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(20.dp))
|
if (state.canCall) {
|
||||||
|
MainActionButton(
|
||||||
|
title = stringResource(CommonStrings.action_call),
|
||||||
|
imageVector = CompoundIcons.VideoCall(),
|
||||||
|
onClick = onCall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.roomType is RoomDetailsType.Room && state.canInvite) {
|
||||||
|
MainActionButton(
|
||||||
|
title = stringResource(CommonStrings.action_invite),
|
||||||
|
imageVector = CompoundIcons.UserAdd(),
|
||||||
|
onClick = onInvitePeople,
|
||||||
|
)
|
||||||
|
}
|
||||||
MainActionButton(
|
MainActionButton(
|
||||||
title = stringResource(R.string.screen_room_details_share_room_title),
|
title = stringResource(CommonStrings.action_share),
|
||||||
imageVector = CompoundIcons.ShareAndroid(),
|
imageVector = CompoundIcons.ShareAndroid(),
|
||||||
onClick = onShareRoom
|
onClick = onShareRoom
|
||||||
)
|
)
|
||||||
|
|
@ -410,17 +419,6 @@ private fun MembersItem(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun InviteItem(
|
|
||||||
invitePeople: () -> Unit,
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(stringResource(R.string.screen_room_details_invite_people_title)) },
|
|
||||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
|
|
||||||
onClick = invitePeople,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PollsSection(
|
private fun PollsSection(
|
||||||
openPollHistory: () -> Unit,
|
openPollHistory: () -> Unit,
|
||||||
|
|
@ -491,5 +489,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
|
||||||
openAvatarPreview = { _, _ -> },
|
openAvatarPreview = { _, _ -> },
|
||||||
openPollHistory = {},
|
openPollHistory = {},
|
||||||
openAdminSettings = {},
|
openAdminSettings = {},
|
||||||
|
onJoinCallClicked = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ class RoomDetailsViewTest {
|
||||||
rule.setRoomDetailView(
|
rule.setRoomDetailView(
|
||||||
onShareRoom = callback,
|
onShareRoom = callback,
|
||||||
)
|
)
|
||||||
rule.clickOn(R.string.screen_room_details_share_room_title)
|
rule.clickOn(CommonStrings.action_share)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,9 +112,8 @@ class RoomDetailsViewTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Config(qualifiers = "h1024dp")
|
|
||||||
@Test
|
@Test
|
||||||
fun `click on invite people invokes expected callback`() {
|
fun `click on invite invokes expected callback`() {
|
||||||
ensureCalledOnce { callback ->
|
ensureCalledOnce { callback ->
|
||||||
rule.setRoomDetailView(
|
rule.setRoomDetailView(
|
||||||
state = aRoomDetailsState(
|
state = aRoomDetailsState(
|
||||||
|
|
@ -123,7 +122,21 @@ class RoomDetailsViewTest {
|
||||||
),
|
),
|
||||||
invitePeople = callback,
|
invitePeople = callback,
|
||||||
)
|
)
|
||||||
rule.clickOn(R.string.screen_room_details_invite_people_title)
|
rule.clickOn(CommonStrings.action_invite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `click on call invokes expected callback`() {
|
||||||
|
ensureCalledOnce { callback ->
|
||||||
|
rule.setRoomDetailView(
|
||||||
|
state = aRoomDetailsState(
|
||||||
|
eventSink = EventsRecorder(expectEvents = false),
|
||||||
|
canInvite = true,
|
||||||
|
),
|
||||||
|
onJoinCallClicked = callback,
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.action_call)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,6 +271,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
|
||||||
openAvatarPreview: (name: String, url: String) -> Unit = EnsureNeverCalledWithTwoParams(),
|
openAvatarPreview: (name: String, url: String) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||||
openPollHistory: () -> Unit = EnsureNeverCalled(),
|
openPollHistory: () -> Unit = EnsureNeverCalled(),
|
||||||
openAdminSettings: () -> Unit = EnsureNeverCalled(),
|
openAdminSettings: () -> Unit = EnsureNeverCalled(),
|
||||||
|
onJoinCallClicked: () -> Unit = EnsureNeverCalled(),
|
||||||
) {
|
) {
|
||||||
setContent {
|
setContent {
|
||||||
RoomDetailsView(
|
RoomDetailsView(
|
||||||
|
|
@ -272,6 +286,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
|
||||||
openAvatarPreview = openAvatarPreview,
|
openAvatarPreview = openAvatarPreview,
|
||||||
openPollHistory = openPollHistory,
|
openPollHistory = openPollHistory,
|
||||||
openAdminSettings = openAdminSettings,
|
openAdminSettings = openAdminSettings,
|
||||||
|
onJoinCallClicked = onJoinCallClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.material.ripple.rememberRipple
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -53,12 +54,14 @@ fun MainActionButton(
|
||||||
val ripple = rememberRipple(bounded = false)
|
val ripple = rememberRipple(bounded = false)
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
Column(
|
Column(
|
||||||
modifier.clickable(
|
modifier
|
||||||
enabled = enabled,
|
.clickable(
|
||||||
interactionSource = interactionSource,
|
enabled = enabled,
|
||||||
onClick = onClick,
|
interactionSource = interactionSource,
|
||||||
indication = ripple
|
onClick = onClick,
|
||||||
),
|
indication = ripple
|
||||||
|
)
|
||||||
|
.widthIn(min = 76.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
val tintColor = if (enabled) LocalContentColor.current else MaterialTheme.colorScheme.secondary
|
val tintColor = if (enabled) LocalContentColor.current else MaterialTheme.colorScheme.secondary
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@ fun MatrixRoom.canRedactOtherAsState(updateKey: Long): State<Boolean> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MatrixRoom.canCall(updateKey: Long): State<Boolean> {
|
||||||
|
return produceState(initialValue = false, key1 = updateKey) {
|
||||||
|
value = canUserJoinCall(sessionId).getOrElse { false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MatrixRoom.isOwnUserAdmin(): Boolean {
|
fun MatrixRoom.isOwnUserAdmin(): Boolean {
|
||||||
val roomInfo by roomInfoFlow.collectAsState(initial = null)
|
val roomInfo by roomInfoFlow.collectAsState(initial = null)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
<string name="action_accept">"Accept"</string>
|
<string name="action_accept">"Accept"</string>
|
||||||
<string name="action_add_to_timeline">"Add to timeline"</string>
|
<string name="action_add_to_timeline">"Add to timeline"</string>
|
||||||
<string name="action_back">"Back"</string>
|
<string name="action_back">"Back"</string>
|
||||||
|
<string name="action_call">"Call"</string>
|
||||||
<string name="action_cancel">"Cancel"</string>
|
<string name="action_cancel">"Cancel"</string>
|
||||||
<string name="action_choose_photo">"Choose photo"</string>
|
<string name="action_choose_photo">"Choose photo"</string>
|
||||||
<string name="action_clear">"Clear"</string>
|
<string name="action_clear">"Clear"</string>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:fe5642aa6804a99d9b3505bb63fe0c328015592f35e095415c910d9a5abfa052
|
oid sha256:ba1133eb6569d4821b12fe56c3347a472bd95ad5b269546fed57f249e365d432
|
||||||
size 47238
|
size 47379
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:7465cc5ca6b9f49ab4ac1a529fc2ee21547afb85a231d74491df6b2a2781bd18
|
oid sha256:d0f46ff6cfe976d3d96ecbf323fcf2ed2ed57c326c3bed3c450c30e851442567
|
||||||
size 33075
|
size 33239
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c3a1bc43adac954bc9ab1b9b9d69814e376d2f2a41006b8126acdeb19bb93df7
|
||||||
|
size 44471
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:93d4eb6dce460237fe6650e965b901c28b020057907867ac9f73665032a85f88
|
oid sha256:84dcef17832aad71a9bf0505346731b27f882795d9bf75b194b647dfad40eca2
|
||||||
size 35427
|
size 35595
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c8a655fb4e64fa300c6169d332fa894b6bb215a9acd09c0fbe876c7430f3ae1d
|
oid sha256:72b731453adaad784d3c037c06defd7b24939435ab54ee47a693f6aabcc0dce7
|
||||||
size 34786
|
size 34928
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4329c322042bc8489177886e37f6d3a876cbfee8a8a73e3aae6414a17e92ad67
|
oid sha256:c675a4d9422ba2d73e860420e25f10a21c899b33e780a4f61a41f71921f3078a
|
||||||
size 42330
|
size 42471
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8359c8e69df44350b30b5aaf9ad99ace52fe378ba6db2cfd86a4deb35aa3abd1
|
oid sha256:78a182e17a2ef55a4d7dbcb4ad952d5f06f12044da4dc3578a5cd3cb7b7d34a1
|
||||||
size 46347
|
size 45723
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8659b897a22fd88915a8df3de3f0ba8d6775057f51b1c5cd1db83b0e58497576
|
oid sha256:ccf59aaaba1ebbf35518840ba069f2ea587c5f5590ed1f0be5904922d10321e0
|
||||||
size 44394
|
size 44535
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:7a195980dff4ad4343eec99438a35ab655abd124346f5e4d66011c3c1736938e
|
oid sha256:972414b2900a7b2fddc7c45640f0718bbed3ff799067a0dde4f9e3f2fb70266a
|
||||||
size 44589
|
size 44574
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:29abf008345cd7c7150a64b17c6bd1c0af06c8d960c5e91a2cf0e4b1c0365954
|
oid sha256:f89ab41056584b086cf6fbeabff68cee7ee7dde4d52fcef9849dcdc296c69a1e
|
||||||
size 48791
|
size 48983
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e6228ac8af567a9d4fc402d2b14ff65315f6cfca2d02407664b0db00df5fa68e
|
oid sha256:d3dead46255af9a750e9afe09f2e5201a4f12adea8f2e1a3d0806062775e3ea0
|
||||||
size 34363
|
size 34587
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:52ded1bfd8ff7a95041172bdca31be2a869f5e2b81f1b181f0ece14ffdf4c7aa
|
||||||
|
size 45708
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3987ef26780da018bedbe05b1af43c3a9c3f31963aff1eb17833a284118d29ec
|
oid sha256:82107faba76f5c6cc992ec47d6e4d765761743094fcae65c5ed948fce3cf1f05
|
||||||
size 36951
|
size 37183
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c74c580b51aaa480a1ca15f2f33e3e6acb9e2a5581d5501ff67b6c4b56d92099
|
oid sha256:73a016fc3d40517042f8d231ab682d71e477843195fbe4e080cd62fc12e8b954
|
||||||
size 35582
|
size 35778
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d81c82c5d5f07bb57911488e18e2cf0867009233ad678557a8ead13e6827ba2b
|
oid sha256:b9250d7927307d397b969e0803ed9965a7ae301347446fef264004d690d064f6
|
||||||
size 43640
|
size 43826
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bdfe3fb1c1594335fda27a62f2c1b4740815778248ba76923a2184937db02144
|
oid sha256:fd42eba5e0daa676298cfb2813d595083e8abd1c5abf8dfb416910fbbc2ea02c
|
||||||
size 47743
|
size 47128
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d23cfed590306aec6eb92664c616e38f18e54274bb381e2e044e22916e8b1129
|
oid sha256:58cdd2ba7d10769a29ad59fa2cff7c972767d01352d656a06bfcb922522980b3
|
||||||
size 45723
|
size 45918
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:33e9d17227fda64a35cd885d029b70bab0088d5748cc5440c32d1cfa0a3bbf52
|
oid sha256:43d176f6e953006f72b09ba374f95c28388f10ce600c9d495761c4c11ccc109b
|
||||||
size 45908
|
size 45874
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3a4cd869d7be5ebd07dc72947fe36f6d601fa55494bb4f7c75b828d1569d983a
|
oid sha256:9c5d638b74379f27b9774309848b4cb068514faa856f5882f8f0f8614ecaad12
|
||||||
size 15105
|
size 15712
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue