diff --git a/vector/src/main/java/im/vector/app/core/ui/views/ActiveConferenceView.kt b/vector/src/main/java/im/vector/app/core/ui/views/ActiveConferenceView.kt deleted file mode 100644 index 256f2d963e..0000000000 --- a/vector/src/main/java/im/vector/app/core/ui/views/ActiveConferenceView.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright (c) 2020 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 im.vector.app.core.ui.views - -import android.content.Context -import android.text.SpannableString -import android.text.method.LinkMovementMethod -import android.text.style.ClickableSpan -import android.util.AttributeSet -import android.view.View -import android.widget.RelativeLayout -import androidx.core.view.isVisible -import im.vector.app.R -import im.vector.app.core.utils.tappableMatchingText -import im.vector.app.databinding.ViewActiveConferenceViewBinding -import im.vector.app.features.home.room.detail.RoomDetailViewState -import im.vector.app.features.themes.ThemeUtils -import org.matrix.android.sdk.api.session.room.model.Membership -import org.matrix.android.sdk.api.session.widgets.model.Widget -import org.matrix.android.sdk.api.session.widgets.model.WidgetType - -class ActiveConferenceView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : RelativeLayout(context, attrs, defStyleAttr) { - - interface Callback { - fun onTapJoinAudio(jitsiWidget: Widget) - fun onTapJoinVideo(jitsiWidget: Widget) - fun onDelete(jitsiWidget: Widget) - } - - var callback: Callback? = null - private var jitsiWidget: Widget? = null - - private lateinit var views: ViewActiveConferenceViewBinding - - init { - setupView() - } - - private fun setupView() { - inflate(context, R.layout.view_active_conference_view, this) - views = ViewActiveConferenceViewBinding.bind(this) - setBackgroundColor(ThemeUtils.getColor(context, R.attr.colorPrimary)) - - // "voice" and "video" texts are underlined and clickable - val voiceString = context.getString(R.string.ongoing_conference_call_voice) - val videoString = context.getString(R.string.ongoing_conference_call_video) - - val fullMessage = context.getString(R.string.ongoing_conference_call, voiceString, videoString) - - val styledText = SpannableString(fullMessage) - styledText.tappableMatchingText(voiceString, object : ClickableSpan() { - override fun onClick(widget: View) { - jitsiWidget?.let { - callback?.onTapJoinAudio(it) - } - } - }) - styledText.tappableMatchingText(videoString, object : ClickableSpan() { - override fun onClick(widget: View) { - jitsiWidget?.let { - callback?.onTapJoinVideo(it) - } - } - }) - - views.activeConferenceInfo.apply { - text = styledText - movementMethod = LinkMovementMethod.getInstance() - } - - views.deleteWidgetButton.setOnClickListener { - jitsiWidget?.let { callback?.onDelete(it) } - } - } - - fun render(state: RoomDetailViewState) { - val summary = state.asyncRoomSummary() - if (summary?.membership == Membership.JOIN) { - // We only display banner for 'live' widgets - jitsiWidget = state.activeRoomWidgets()?.firstOrNull { - // for now only jitsi? - it.type == WidgetType.Jitsi - } - - isVisible = jitsiWidget != null - // if sent by me or if i can moderate? - views.deleteWidgetButton.isVisible = state.isAllowedToManageWidgets - } else { - isVisible = false - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/call/conference/RemoveJitsiWidgetView.kt b/vector/src/main/java/im/vector/app/features/call/conference/RemoveJitsiWidgetView.kt new file mode 100644 index 0000000000..cb26d5416b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/conference/RemoveJitsiWidgetView.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2021 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 im.vector.app.features.call.conference + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.ColorStateList +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.core.widget.ImageViewCompat +import im.vector.app.R +import im.vector.app.databinding.ViewRemoveJitsiWidgetBinding +import im.vector.app.features.home.room.detail.RoomDetailViewState +import org.matrix.android.sdk.api.session.room.model.Membership + +@SuppressLint("ClickableViewAccessibility") class RemoveJitsiWidgetView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private sealed class State { + object Unmount : State() + object Idle : State() + data class Sliding(val initialX: Float, val translationX: Float, val hasReachedActivationThreshold: Boolean) : State() + object Progress : State() + } + + private val views: ViewRemoveJitsiWidgetBinding + private var state: State = State.Unmount + var onCompleteSliding: (() -> Unit)? = null + + init { + inflate(context, R.layout.view_remove_jitsi_widget, this) + views = ViewRemoveJitsiWidgetBinding.bind(this) + views.removeJitsiSlidingContainer.setOnTouchListener { _, event -> + val currentState = state + return@setOnTouchListener when (event.action) { + MotionEvent.ACTION_DOWN -> { + if (currentState == State.Idle) { + val initialX = views.removeJitsiSlidingContainer.x - event.rawX + updateState(State.Sliding(initialX, 0f, false)) + } + true + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + if (currentState is State.Sliding) { + if (currentState.hasReachedActivationThreshold) { + updateState(State.Progress) + } else { + updateState(State.Idle) + } + } + true + } + MotionEvent.ACTION_MOVE -> { + if (currentState is State.Sliding) { + val translationX = (currentState.initialX + event.rawX).coerceAtLeast(0f) + val hasReachedActivationThreshold = views.removeJitsiSlidingContainer.width + translationX >= views.removeJitsiHangupContainer.x + updateState(State.Sliding(currentState.initialX, translationX, hasReachedActivationThreshold)) + } + true + } + else -> false + } + } + renderInternalState(state) + } + + fun render(roomDetailViewState: RoomDetailViewState) { + val summary = roomDetailViewState.asyncRoomSummary() + val newState = if (summary?.membership != Membership.JOIN || !roomDetailViewState.isAllowedToManageWidgets || roomDetailViewState.jitsiState.widgetId == null) { + State.Unmount + } else if (roomDetailViewState.jitsiState.deleteWidgetInProgress) { + State.Progress + } else { + State.Idle + } + // Don't force Idle if we are already sliding + if (state is State.Sliding && newState is State.Idle) { + return + } else { + updateState(newState) + } + } + + private fun updateState(newState: State) { + if (newState == state) { + return + } + renderInternalState(newState) + state = newState + if (state == State.Progress) { + onCompleteSliding?.invoke() + } + } + + private fun renderInternalState(state: State) { + isVisible = state != State.Unmount + when (state) { + State.Progress -> { + isVisible = true + views.updateVisibilities(true) + views.updateHangupColors(true) + } + State.Idle -> { + isVisible = true + views.updateVisibilities(false) + views.removeJitsiSlidingContainer.translationX = 0f + views.updateHangupColors(false) + } + is State.Sliding -> { + isVisible = true + views.updateVisibilities(false) + views.removeJitsiSlidingContainer.translationX = state.translationX + views.updateHangupColors(state.hasReachedActivationThreshold) + } + else -> Unit + } + } + + private fun ViewRemoveJitsiWidgetBinding.updateVisibilities(isProgress: Boolean) { + removeJitsiProgressContainer.isVisible = isProgress + removeJitsiHangupContainer.isVisible = !isProgress + removeJitsiSlidingContainer.isVisible = !isProgress + } + + private fun ViewRemoveJitsiWidgetBinding.updateHangupColors(activated: Boolean) { + val iconTintColor: Int + val bgColor: Int + if (activated) { + bgColor = ContextCompat.getColor(context, R.color.palette_vermilion) + iconTintColor = ContextCompat.getColor(context, R.color.palette_white) + } else { + bgColor = ContextCompat.getColor(context, android.R.color.transparent) + iconTintColor = ContextCompat.getColor(context, R.color.palette_vermilion) + } + removeJitsiHangupContainer.setBackgroundColor(bgColor) + ImageViewCompat.setImageTintList(removeJitsiHangupIcon, ColorStateList.valueOf(iconTintColor)) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index 3e55b2b924..224c52b30f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -67,7 +67,6 @@ import com.airbnb.mvrx.Success import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState -import com.facebook.react.bridge.JavaOnlyMap import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.jakewharton.rxbinding3.view.focusChanges import com.jakewharton.rxbinding3.widget.textChanges @@ -90,7 +89,6 @@ import im.vector.app.core.intent.getMimeTypeFromUri import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.showOptimizedSnackbar import im.vector.app.core.resources.ColorProvider -import im.vector.app.core.ui.views.ActiveConferenceView import im.vector.app.core.ui.views.CurrentCallsCardView import im.vector.app.core.ui.views.FailedMessagesWarningView import im.vector.app.core.ui.views.NotificationAreaView @@ -124,7 +122,6 @@ import im.vector.app.features.call.VectorCallActivity import im.vector.app.features.call.conference.JitsiBroadcastEmitter import im.vector.app.features.call.conference.JitsiBroadcastEventObserver import im.vector.app.features.call.conference.JitsiCallViewModel -import im.vector.app.features.call.conference.extractConferenceUrl import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.command.Command import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreActivity @@ -178,7 +175,6 @@ import nl.dionsegijn.konfetti.models.Shape import nl.dionsegijn.konfetti.models.Size import org.billcarsonfr.jsonviewer.JSonViewerDialog import org.commonmark.parser.Parser -import org.jitsi.meet.sdk.BroadcastEmitter import org.jitsi.meet.sdk.BroadcastEvent import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.content.ContentAttachmentData @@ -331,9 +327,10 @@ class RoomDetailFragment @Inject constructor( setupJumpToReadMarkerView() setupActiveCallView() setupJumpToBottomView() - setupConfBannerView() setupEmojiPopup() setupFailedMessagesWarningView() + setupRemoveJitsiWidgetView() + views.roomToolbarContentView.debouncedClicks { navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId) @@ -397,7 +394,7 @@ class RoomDetailFragment @Inject constructor( RoomDetailViewEvents.OpenActiveWidgetBottomSheet -> onViewWidgetsClicked() is RoomDetailViewEvents.ShowInfoOkDialog -> showDialogWithMessage(it.message) is RoomDetailViewEvents.JoinJitsiConference -> joinJitsiRoom(it.widget, it.withVideo) - RoomDetailViewEvents.LeaveJitsiConference -> leaveJitsiConference() + RoomDetailViewEvents.LeaveJitsiConference -> leaveJitsiConference() RoomDetailViewEvents.ShowWaitingView -> vectorBaseActivity.showWaitingView() RoomDetailViewEvents.HideWaitingView -> vectorBaseActivity.hideWaitingView() is RoomDetailViewEvents.RequestNativeWidgetPermission -> requestNativeWidgetPermission(it) @@ -420,6 +417,18 @@ class RoomDetailFragment @Inject constructor( } } + private fun setupRemoveJitsiWidgetView() { + views.removeJitsiWidgetView.onCompleteSliding = { + withState(roomDetailViewModel) { + val jitsiWidgetId = it.jitsiState.widgetId ?: return@withState + if (it.jitsiState.hasJoined) { + leaveJitsiConference() + } + roomDetailViewModel.handle(RoomDetailAction.RemoveWidget(jitsiWidgetId)) + } + } + } + private fun leaveJitsiConference() { JitsiBroadcastEmitter(vectorBaseActivity).emitConferenceEnded() } @@ -530,31 +539,6 @@ class RoomDetailFragment @Inject constructor( ) } - private fun setupConfBannerView() { - views.activeConferenceView.callback = object : ActiveConferenceView.Callback { - override fun onTapJoinAudio(jitsiWidget: Widget) { - // need to check if allowed first - roomDetailViewModel.handle(RoomDetailAction.EnsureNativeWidgetAllowed( - widget = jitsiWidget, - userJustAccepted = false, - grantedEvents = RoomDetailViewEvents.JoinJitsiConference(jitsiWidget, false)) - ) - } - - override fun onTapJoinVideo(jitsiWidget: Widget) { - roomDetailViewModel.handle(RoomDetailAction.EnsureNativeWidgetAllowed( - widget = jitsiWidget, - userJustAccepted = false, - grantedEvents = RoomDetailViewEvents.JoinJitsiConference(jitsiWidget, true)) - ) - } - - override fun onDelete(jitsiWidget: Widget) { - roomDetailViewModel.handle(RoomDetailAction.RemoveWidget(jitsiWidget.widgetId)) - } - } - } - private fun setupEmojiPopup() { emojiPopup = EmojiPopup .Builder @@ -1261,7 +1245,7 @@ class RoomDetailFragment @Inject constructor( invalidateOptionsMenu() val summary = state.asyncRoomSummary() renderToolbar(summary, state.typingMessage) - views.activeConferenceView.render(state) + views.removeJitsiWidgetView.render(state) views.failedMessagesWarningView.render(state.hasFailedSending) val inviter = state.asyncInviter() if (summary?.membership == Membership.JOIN) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index 106c753068..29b50eb77e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -60,12 +60,12 @@ import io.reactivex.Observable import io.reactivex.rxkotlin.subscribeBy import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import org.jitsi.meet.sdk.BroadcastEvent -import org.jitsi.meet.sdk.JitsiMeet import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.MatrixPatterns import org.matrix.android.sdk.api.extensions.orFalse @@ -241,18 +241,25 @@ class RoomDetailViewModel @AssistedInject constructor( widgets.filter { it.isActive } } .execute { widgets -> - val jitsiWidget = widgets()?.firstOrNull { it.type == WidgetType.Jitsi } - val jitsiConfId = jitsiWidget?.let { - jitsiService.extractProperties(it)?.confId - } copy( activeRoomWidgets = widgets, - jitsiState = jitsiState.copy( - confId = jitsiConfId, - widgetId = jitsiWidget?.widgetId - ) ) } + + asyncSubscribe(RoomDetailViewState::activeRoomWidgets) { widgets -> + setState { + val jitsiWidget = widgets.firstOrNull { it.type == WidgetType.Jitsi } + val jitsiConfId = jitsiWidget?.let { + jitsiService.extractProperties(it)?.confId + } + copy( + jitsiState = jitsiState.copy( + confId = jitsiConfId, + widgetId = jitsiWidget?.widgetId + ) + ) + } + } } private fun observeMyRoomMember() { @@ -318,8 +325,8 @@ class RoomDetailViewModel @AssistedInject constructor( is RoomDetailAction.ManageIntegrations -> handleManageIntegrations() is RoomDetailAction.AddJitsiWidget -> handleAddJitsiConference(action) is RoomDetailAction.UpdateJoinJitsiCallStatus -> handleJitsiCallJoinStatus(action) - is RoomDetailAction.JoinJitsiCall -> handleJoinJitsiCall() - is RoomDetailAction.LeaveJitsiCall -> handleLeaveJitsiCall() + is RoomDetailAction.JoinJitsiCall -> handleJoinJitsiCall() + is RoomDetailAction.LeaveJitsiCall -> handleLeaveJitsiCall() is RoomDetailAction.RemoveWidget -> handleDeleteWidget(action.widgetId) is RoomDetailAction.EnsureNativeWidgetAllowed -> handleCheckWidgetAllowed(action) is RoomDetailAction.CancelSend -> handleCancel(action) @@ -363,8 +370,8 @@ class RoomDetailViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.LeaveJitsiConference) } - private fun handleJoinJitsiCall() = withState{ state -> - val jitsiWidget = state.activeRoomWidgets()?.firstOrNull { it.widgetId == state.jitsiState.widgetId} ?: return@withState + private fun handleJoinJitsiCall() = withState { state -> + val jitsiWidget = state.activeRoomWidgets()?.firstOrNull { it.widgetId == state.jitsiState.widgetId } ?: return@withState val action = RoomDetailAction.EnsureNativeWidgetAllowed(jitsiWidget, false, RoomDetailViewEvents.JoinJitsiConference(jitsiWidget, true)) handleCheckWidgetAllowed(action) } @@ -477,10 +484,15 @@ class RoomDetailViewModel @AssistedInject constructor( } } - private fun handleDeleteWidget(widgetId: String) { - _viewEvents.post(RoomDetailViewEvents.ShowWaitingView) + private fun handleDeleteWidget(widgetId: String) = withState { state -> + val isJitsiWidget = state.jitsiState.widgetId == widgetId viewModelScope.launch(Dispatchers.IO) { try { + if (isJitsiWidget) { + setState { copy(jitsiState = jitsiState.copy(deleteWidgetInProgress = true)) } + } else { + _viewEvents.post(RoomDetailViewEvents.ShowWaitingView) + } session.widgetService().destroyRoomWidget(room.roomId, widgetId) // local echo setState { @@ -496,7 +508,11 @@ class RoomDetailViewModel @AssistedInject constructor( } catch (failure: Throwable) { _viewEvents.post(RoomDetailViewEvents.ShowMessage(stringProvider.getString(R.string.failed_to_remove_widget))) } finally { - _viewEvents.post(RoomDetailViewEvents.HideWaitingView) + if (isJitsiWidget) { + setState { copy(jitsiState = jitsiState.copy(deleteWidgetInProgress = false)) } + } else { + _viewEvents.post(RoomDetailViewEvents.HideWaitingView) + } } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index 75650ed322..f368036b9e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -59,7 +59,8 @@ data class JitsiState( val hasJoined: Boolean = false, // Not null if we have an active jitsi widget on the room val confId: String? = null, - val widgetId: String? = null + val widgetId: String? = null, + val deleteWidgetInProgress: Boolean = false ) data class RoomDetailViewState( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt index 84867e15c6..e856907717 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt @@ -38,13 +38,8 @@ class WidgetItemFactory @Inject constructor( private val avatarSizeProvider: AvatarSizeProvider, private val messageColorProvider: MessageColorProvider, private val avatarRenderer: AvatarRenderer, - private val activeSessionDataSource: ActiveSessionDataSource, private val roomSummariesHolder: RoomSummariesHolder ) { - private val currentUserId: String? - get() = activeSessionDataSource.currentValue?.orNull()?.myUserId - - private fun Event.isSentByCurrentUser() = senderId != null && senderId == currentUserId fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? { val event = params.event diff --git a/vector/src/main/res/layout/fragment_room_detail.xml b/vector/src/main/res/layout/fragment_room_detail.xml index 65da9156c9..3b96146c4d 100644 --- a/vector/src/main/res/layout/fragment_room_detail.xml +++ b/vector/src/main/res/layout/fragment_room_detail.xml @@ -100,13 +100,14 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/appBarLayout" /> - + android:visibility="visible" + android:background="?android:colorBackground" + android:minHeight="54dp" + app:layout_constraintTop_toBottomOf="@id/syncStateView"/> diff --git a/vector/src/main/res/layout/view_active_conference_view.xml b/vector/src/main/res/layout/view_active_conference_view.xml deleted file mode 100644 index 9f26ed9a1a..0000000000 --- a/vector/src/main/res/layout/view_active_conference_view.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - -