diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/failure/CreateRoomFailure.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/failure/CreateRoomFailure.kt index 208cdd45563..deab0ca3e75 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/failure/CreateRoomFailure.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/failure/CreateRoomFailure.kt @@ -21,7 +21,7 @@ import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.session.room.alias.RoomAliasError sealed class CreateRoomFailure : Failure.FeatureFailure() { - object CreatedWithTimeout : CreateRoomFailure() + data class CreatedWithTimeout(val roomID: String) : CreateRoomFailure() data class CreatedWithFederationFailure(val matrixError: MatrixError) : CreateRoomFailure() data class AliasError(val aliasError: RoomAliasError) : CreateRoomFailure() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/peeking/PeekResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/peeking/PeekResult.kt index a27e88acedb..c213eee08d1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/peeking/PeekResult.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/peeking/PeekResult.kt @@ -24,6 +24,7 @@ sealed class PeekResult { val topic: String?, val avatarUrl: String?, val numJoinedMembers: Int?, + val roomType: String?, val viaServers: List ) : PeekResult() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt index 01a0dbc9292..0172b3701b2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt @@ -17,11 +17,17 @@ package org.matrix.android.sdk.api.session.space import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.model.RoomSummary interface Space { fun asRoom() : Room + /** + * A current snapshot of [RoomSummary] associated with the room + */ + fun spaceSummary(): SpaceSummary? + suspend fun addChildren(roomId: String, viaServers: List, order: String?, autoJoin: Boolean = false) suspend fun removeRoom(roomId: String) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt index 383dd876d3d..1cceae89358 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt @@ -72,6 +72,7 @@ internal class DefaultRoomService @Inject constructor( return createRoomTask .configureWith(createRoomParams) { this.callback = callback + this.retryCount = 3 } .executeBy(taskExecutor) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/SpaceGetter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/SpaceGetter.kt new file mode 100644 index 00000000000..f440a67710a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/SpaceGetter.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.room + +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.space.Space +import org.matrix.android.sdk.internal.session.space.DefaultSpace +import org.matrix.android.sdk.internal.session.space.SpaceSummaryDataSource +import javax.inject.Inject + +internal interface SpaceGetter { + fun get(spaceId: String): Space? +} + +internal class DefaultSpaceGetter @Inject constructor( + private val roomGetter: RoomGetter, + private val spaceSummaryDataSource: SpaceSummaryDataSource +) : SpaceGetter { + + override fun get(spaceId: String): Space? { + return roomGetter.getRoom(spaceId) + ?.takeIf { it.roomSummary()?.roomType == RoomType.SPACE } + ?.let { DefaultSpace(it, spaceSummaryDataSource) } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt index 9c16bd1b0f2..7732dd619d3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt @@ -100,7 +100,7 @@ internal class DefaultCreateRoomTask @Inject constructor( .equalTo(RoomEntityFields.ROOM_ID, roomId) } } catch (exception: TimeoutCancellationException) { - throw CreateRoomFailure.CreatedWithTimeout + throw CreateRoomFailure.CreatedWithTimeout(roomId) } if (otherUserId != null) { handleDirectChatCreation(roomId, otherUserId) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/PeekRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/PeekRoomTask.kt index 5b211c505fe..5d452593043 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/PeekRoomTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/PeekRoomTask.kt @@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomCanonicalAliasContent import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility import org.matrix.android.sdk.api.session.room.model.RoomNameContent import org.matrix.android.sdk.api.session.room.model.RoomTopicContent +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsFilter import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams import org.matrix.android.sdk.api.session.room.peeking.PeekResult @@ -100,7 +101,8 @@ internal class DefaultPeekRoomTask @Inject constructor( name = publicRepoResult.name, topic = publicRepoResult.topic, numJoinedMembers = publicRepoResult.numJoinedMembers, - viaServers = serverList + viaServers = serverList, + roomType = null // would be nice to get that from directory... ) } @@ -130,6 +132,10 @@ internal class DefaultPeekRoomTask @Inject constructor( .distinctBy { it.stateKey } .count() + val roomType = stateEvents + .lastOrNull { it.type == EventType.STATE_ROOM_CREATE } + ?.let { it.content?.toModel()?.type } + return PeekResult.Success( roomId = roomId, alias = alias, @@ -137,6 +143,7 @@ internal class DefaultPeekRoomTask @Inject constructor( name = name, topic = topic, numJoinedMembers = memberCount, + roomType = roomType, viaServers = serverList ) } catch (failure: Throwable) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt index f2640fd1e73..df90f7fe84b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt @@ -75,7 +75,7 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private eventType = eventType, body = body.toSafeJson(eventType) ) - sendStateTask.execute(params) + sendStateTask.executeRetry(params, 3) } private fun JsonDict.toSafeJson(eventType: String): JsonDict { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/CreateSpaceTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/CreateSpaceTask.kt new file mode 100644 index 00000000000..5f174587d0e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/CreateSpaceTask.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.space + +import io.realm.RealmConfiguration +import kotlinx.coroutines.TimeoutCancellationException +import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.internal.database.awaitNotEmptyResult +import org.matrix.android.sdk.internal.database.model.SpaceSummaryEntity +import org.matrix.android.sdk.internal.database.model.SpaceSummaryEntityFields +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.session.room.create.CreateRoomTask +import org.matrix.android.sdk.internal.task.Task +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +/** + * A simple wrapper of create room task that adds waiting for DB entities of spaces + */ +internal interface CreateSpaceTask : Task + +internal class DefaultCreateSpaceTask @Inject constructor( + private val createRoomTask: CreateRoomTask, + @SessionDatabase private val realmConfiguration: RealmConfiguration +) : CreateSpaceTask { + + override suspend fun execute(params: CreateRoomParams): String { + val spaceId = createRoomTask.execute(params) + + try { + awaitNotEmptyResult(realmConfiguration, TimeUnit.MINUTES.toMillis(1L)) { realm -> + realm.where(SpaceSummaryEntity::class.java) + .equalTo(SpaceSummaryEntityFields.SPACE_ID, spaceId) + } + } catch (exception: TimeoutCancellationException) { + throw CreateRoomFailure.CreatedWithTimeout(spaceId) + } + + return spaceId + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt index 264cfd44ed2..efba103ab7f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt @@ -22,15 +22,19 @@ import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.Room import org.matrix.android.sdk.api.session.space.Space +import org.matrix.android.sdk.api.session.space.SpaceSummary import org.matrix.android.sdk.api.session.space.model.SpaceChildContent -import java.lang.IllegalArgumentException -class DefaultSpace(private val room: Room) : Space { +internal class DefaultSpace(private val room: Room, private val spaceSummaryDataSource: SpaceSummaryDataSource) : Space { override fun asRoom(): Room { return room } + override fun spaceSummary(): SpaceSummary? { + return spaceSummaryDataSource.getSpaceSummary(asRoom().roomId) + } + override suspend fun addChildren(roomId: String, viaServers: List, order: String?, autoJoin: Boolean) { asRoom().sendStateEvent( eventType = EventType.STATE_SPACE_CHILD, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt index 906886d5e81..0210c81e5df 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt @@ -22,7 +22,6 @@ import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.RoomSummary -import org.matrix.android.sdk.api.session.room.model.RoomType import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo import org.matrix.android.sdk.api.session.room.model.create.CreateRoomPreset import org.matrix.android.sdk.api.session.space.CreateSpaceParams @@ -32,40 +31,33 @@ import org.matrix.android.sdk.api.session.space.SpaceSummary import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams import org.matrix.android.sdk.api.session.space.model.SpaceChildContent import org.matrix.android.sdk.internal.di.SessionDatabase -import org.matrix.android.sdk.internal.session.room.RoomGetter -import org.matrix.android.sdk.internal.session.room.alias.DeleteRoomAliasTask -import org.matrix.android.sdk.internal.session.room.alias.GetRoomIdByAliasTask -import org.matrix.android.sdk.internal.session.room.create.CreateRoomTask -import org.matrix.android.sdk.internal.session.room.membership.RoomChangeMembershipStateDataSource -import org.matrix.android.sdk.internal.session.room.membership.joining.JoinRoomTask +import org.matrix.android.sdk.internal.session.room.SpaceGetter import org.matrix.android.sdk.internal.session.room.membership.leaving.LeaveRoomTask -import org.matrix.android.sdk.internal.session.room.read.MarkAllRoomsReadTask import org.matrix.android.sdk.internal.session.space.peeking.PeekSpaceTask import org.matrix.android.sdk.internal.session.space.peeking.SpacePeekResult -import org.matrix.android.sdk.internal.session.user.accountdata.UpdateBreadcrumbsTask -import org.matrix.android.sdk.internal.task.TaskExecutor import javax.inject.Inject internal class DefaultSpaceService @Inject constructor( @SessionDatabase private val monarchy: Monarchy, - private val createRoomTask: CreateRoomTask, - private val joinRoomTask: JoinRoomTask, + private val createSpaceTask: CreateSpaceTask, +// private val joinRoomTask: JoinRoomTask, private val joinSpaceTask: JoinSpaceTask, - private val markAllRoomsReadTask: MarkAllRoomsReadTask, - private val updateBreadcrumbsTask: UpdateBreadcrumbsTask, - private val roomIdByAliasTask: GetRoomIdByAliasTask, - private val deleteRoomAliasTask: DeleteRoomAliasTask, - private val roomGetter: RoomGetter, + private val spaceGetter: SpaceGetter, +// private val markAllRoomsReadTask: MarkAllRoomsReadTask, +// private val updateBreadcrumbsTask: UpdateBreadcrumbsTask, +// private val roomIdByAliasTask: GetRoomIdByAliasTask, +// private val deleteRoomAliasTask: DeleteRoomAliasTask, +// private val roomGetter: RoomGetter, private val spaceSummaryDataSource: SpaceSummaryDataSource, private val peekSpaceTask: PeekSpaceTask, private val resolveSpaceInfoTask: ResolveSpaceInfoTask, - private val leaveRoomTask: LeaveRoomTask, - private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, - private val taskExecutor: TaskExecutor + private val leaveRoomTask: LeaveRoomTask +// private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, +// private val taskExecutor: TaskExecutor ) : SpaceService { override suspend fun createSpace(params: CreateSpaceParams): String { - return createRoomTask.execute(params) + return createSpaceTask.executeRetry(params, 3) } override suspend fun createSpace(name: String, topic: String?, avatarUri: Uri?, isPublic: Boolean): String { @@ -78,9 +70,7 @@ internal class DefaultSpaceService @Inject constructor( } override fun getSpace(spaceId: String): Space? { - return roomGetter.getRoom(spaceId) - ?.takeIf { it.roomSummary()?.roomType == RoomType.SPACE } - ?.let { DefaultSpace(it) } + return spaceGetter.get(spaceId) } override fun getSpaceSummariesLive(queryParams: SpaceSummaryQueryParams): LiveData> { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceModule.kt index 4612d9e1422..84a2d5267f9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceModule.kt @@ -20,6 +20,8 @@ import dagger.Binds import dagger.Module import dagger.Provides import org.matrix.android.sdk.internal.session.SessionScope +import org.matrix.android.sdk.internal.session.room.DefaultSpaceGetter +import org.matrix.android.sdk.internal.session.room.SpaceGetter import org.matrix.android.sdk.internal.session.space.peeking.DefaultPeekSpaceTask import org.matrix.android.sdk.internal.session.space.peeking.PeekSpaceTask import retrofit2.Retrofit @@ -45,4 +47,10 @@ internal abstract class SpaceModule { @Binds abstract fun bindJoinSpaceTask(task: DefaultJoinSpaceTask): JoinSpaceTask + + @Binds + abstract fun bindSpaceGetter(getter: DefaultSpaceGetter): SpaceGetter + + @Binds + abstract fun bindCreateSpaceTask(getter: DefaultCreateSpaceTask): CreateSpaceTask } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/ConfigurableTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/ConfigurableTask.kt index 97f9a0dd513..bc80cf7ee8b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/ConfigurableTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/ConfigurableTask.kt @@ -37,7 +37,8 @@ internal data class ConfigurableTask( val id: UUID, val callbackThread: TaskThread, val executionThread: TaskThread, - val callback: MatrixCallback + val callback: MatrixCallback, + val maxRetryCount: Int = 0 ) : Task by task { @@ -57,7 +58,8 @@ internal data class ConfigurableTask( id = id, callbackThread = callbackThread, executionThread = executionThread, - callback = callback + callback = callback, + maxRetryCount = retryCount ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/Task.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/Task.kt index a6c80a0b1a8..a5d031e02ae 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/Task.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/Task.kt @@ -16,7 +16,29 @@ package org.matrix.android.sdk.internal.task +import kotlinx.coroutines.delay +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.shouldBeRetried +import timber.log.Timber + internal interface Task { suspend fun execute(params: PARAMS): RESULT + + suspend fun executeRetry(params: PARAMS, remainingRetry: Int) : RESULT { + return try { + execute(params) + } catch (failure: Throwable) { + if (failure.shouldBeRetried() && remainingRetry > 0) { + Timber.d(failure, "## TASK: Retriable error") + if (failure is Failure.ServerError) { + val waitTime = failure.error.retryAfterMillis ?: 0L + Timber.d(failure, "## TASK: Quota wait time $waitTime") + delay(waitTime + 100) + } + return executeRetry(params, remainingRetry - 1) + } + throw failure + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/TaskExecutor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/TaskExecutor.kt index 478a3564325..4da16eff226 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/TaskExecutor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/TaskExecutor.kt @@ -40,9 +40,9 @@ internal class TaskExecutor @Inject constructor(private val coroutineDispatchers .launch(task.callbackThread.toDispatcher()) { val resultOrFailure = runCatching { withContext(task.executionThread.toDispatcher()) { - Timber.v("Enqueue task $task") - Timber.v("Execute task $task on ${Thread.currentThread().name}") - task.execute(task.params) + Timber.v("## TASK: Enqueue task $task") + Timber.v("## TASK: Execute task $task on ${Thread.currentThread().name}") + task.executeRetry(task.params, task.maxRetryCount) } } resultOrFailure diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index caf3944d19d..8a234bf633a 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -270,6 +270,7 @@ + () { @EpoxyAttribute var inputType: Int? = null + @EpoxyAttribute + var singleLine: Boolean? = null + + @EpoxyAttribute + var imeOptions: Int? = null + + @EpoxyAttribute + var endIconMode: Int? = null + @EpoxyAttribute var onTextChange: ((String) -> Unit)? = null @@ -65,10 +75,16 @@ abstract class FormEditTextItem : VectorEpoxyModel() { holder.textInputLayout.hint = hint holder.textInputLayout.error = errorMessage + endIconMode?.let { mode -> + holder.textInputLayout.endIconMode = mode + } + // Update only if text is different and value is not null holder.textInputEditText.setTextSafe(value) holder.textInputEditText.isEnabled = enabled inputType?.let { holder.textInputEditText.inputType = it } + holder.textInputEditText.isSingleLine = singleLine ?: false + holder.textInputEditText.imeOptions = imeOptions ?: EditorInfo.IME_ACTION_NONE holder.textInputEditText.addTextChangedListener(onTextChangeListener) holder.bottomSeparator.isVisible = showBottomSeparator diff --git a/vector/src/main/java/im/vector/app/features/form/FormEditableSquareAvatarItem.kt b/vector/src/main/java/im/vector/app/features/form/FormEditableSquareAvatarItem.kt new file mode 100644 index 00000000000..cbb545825d9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/form/FormEditableSquareAvatarItem.kt @@ -0,0 +1,95 @@ +/* + * 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.features.form + +import android.net.Uri +import android.util.TypedValue +import android.view.View +import android.widget.ImageView +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import com.airbnb.epoxy.EpoxyModelWithHolder +import com.bumptech.glide.load.MultiTransformation +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import im.vector.app.R +import im.vector.app.core.epoxy.ClickListener +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.onClick +import im.vector.app.core.glide.GlideApp +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.util.MatrixItem + +@EpoxyModelClass(layout = R.layout.item_editable_square_avatar) +abstract class FormEditableSquareAvatarItem : EpoxyModelWithHolder() { + + @EpoxyAttribute + var avatarRenderer: AvatarRenderer? = null + + @EpoxyAttribute + var matrixItem: MatrixItem? = null + + @EpoxyAttribute + var enabled: Boolean = true + + @EpoxyAttribute + var imageUri: Uri? = null + + @EpoxyAttribute + var clickListener: ClickListener? = null + + @EpoxyAttribute + var deleteListener: ClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.imageContainer.onClick(clickListener?.takeIf { enabled }) + when { + imageUri != null -> { + val corner = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8f, + holder.view.resources.displayMetrics + ).toInt() + GlideApp.with(holder.image) + .load(imageUri) + .transform(MultiTransformation(CenterCrop(), RoundedCorners(corner))) + .into(holder.image) + } + matrixItem != null -> { + avatarRenderer?.renderSpace(matrixItem!!, holder.image) + } + else -> { + avatarRenderer?.clear(holder.image) + } + } + holder.delete.isVisible = enabled && (imageUri != null || matrixItem?.avatarUrl?.isNotEmpty() == true) + holder.delete.onClick(deleteListener?.takeIf { enabled }) + } + + override fun unbind(holder: Holder) { + avatarRenderer?.clear(holder.image) + GlideApp.with(holder.image).clear(holder.image) + super.unbind(holder) + } + + class Holder : VectorEpoxyHolder() { + val imageContainer by bind(R.id.itemEditableAvatarImageContainer) + val image by bind(R.id.itemEditableAvatarImage) + val delete by bind(R.id.itemEditableAvatarDelete) + } +} diff --git a/vector/src/main/java/im/vector/app/features/grouplist/HomeSpaceSummaryItem.kt b/vector/src/main/java/im/vector/app/features/grouplist/HomeSpaceSummaryItem.kt index ade86a9d893..f4c95215b85 100644 --- a/vector/src/main/java/im/vector/app/features/grouplist/HomeSpaceSummaryItem.kt +++ b/vector/src/main/java/im/vector/app/features/grouplist/HomeSpaceSummaryItem.kt @@ -22,6 +22,7 @@ import android.util.TypedValue import android.widget.ImageView import android.widget.TextView import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R @@ -35,6 +36,10 @@ abstract class HomeSpaceSummaryItem : VectorEpoxyModel Unit)? = null + override fun getViewType(): Int { + // mm.. it's reusing the same layout for basic space item + return R.id.space_item_home + } override fun bind(holder: Holder) { super.bind(holder) holder.rootView.setOnClickListener { listener?.invoke() } @@ -44,12 +49,14 @@ abstract class HomeSpaceSummaryItem : VectorEpoxyModel(R.id.groupAvatarImageView) val groupNameView by bind(R.id.groupNameView) val rootView by bind(R.id.itemGroupLayout) + val leaveView by bind(R.id.groupTmpLeave) } fun dpToPx(resources: Resources, dp: Int): Int { diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt index caad39c6fc0..e9f3d829a6e 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home +import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri @@ -35,6 +36,7 @@ import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ScreenComponent import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.ToolbarConfigurable import im.vector.app.core.platform.VectorBaseActivity @@ -51,6 +53,7 @@ import im.vector.app.features.popup.VerificationVectorAlert import im.vector.app.features.rageshake.VectorUncaughtExceptionHandler import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity +import im.vector.app.features.spaces.SpaceCreationActivity import im.vector.app.features.spaces.SpacePreviewActivity import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.workers.signout.ServerBackupStatusViewModel @@ -96,6 +99,19 @@ class HomeActivity : @Inject lateinit var permalinkHandler: PermalinkHandler @Inject lateinit var avatarRenderer: AvatarRenderer + private val createSpaceResultLauncher = registerStartForActivityResult { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + val spaceId = activityResult.data?.extras?.getString(SpaceCreationActivity.RESULT_DATA_CREATED_SPACE_ID) + val defaultRoomsId = activityResult.data?.extras?.getString(SpaceCreationActivity.RESULT_DATA_DEFAULT_ROOM_ID) + views.drawerLayout.closeDrawer(GravityCompat.START) + + // Here we want to change current space to the newly created one, and then immediately open the default room + if (spaceId != null) { + navigator.switchToSpace(this, spaceId, defaultRoomsId, true) + } + } + } + private val drawerListener = object : DrawerLayout.SimpleDrawerListener() { override fun onDrawerStateChanged(newState: Int) { hideKeyboard() @@ -139,6 +155,9 @@ class HomeActivity : is HomeActivitySharedAction.OpenSpacePreview -> { startActivity(SpacePreviewActivity.newIntent(this, sharedAction.spaceId)) } + is HomeActivitySharedAction.AddSpace -> { + createSpaceResultLauncher.launch(SpaceCreationActivity.newIntent(this)) + } }.exhaustive } .disposeOnDestroy() @@ -405,6 +424,23 @@ class HomeActivity : return true } + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { + if (roomId == null) return false + val listener = object : MatrixToBottomSheet.InteractionListener { + override fun navigateToRoom(roomId: String) { + navigator.openRoom(this@HomeActivity, roomId) + } + + override fun switchToSpace(spaceId: String) { + navigator.switchToSpace(this@HomeActivity, spaceId, null, false) + } + } + + MatrixToBottomSheet.withLink(deepLink.toString(), listener) + .show(supportFragmentManager, "HA#MatrixToBottomSheet") + return true + } + companion object { fun newIntent(context: Context, clearNotification: Boolean = false, accountCreation: Boolean = false): Intent { val args = HomeActivityArgs( diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivitySharedAction.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivitySharedAction.kt index f72354465ba..72e35233360 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivitySharedAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivitySharedAction.kt @@ -25,5 +25,6 @@ sealed class HomeActivitySharedAction : VectorSharedAction { object OpenDrawer : HomeActivitySharedAction() object CloseDrawer : HomeActivitySharedAction() object OpenGroup : HomeActivitySharedAction() + object AddSpace : HomeActivitySharedAction() data class OpenSpacePreview(val spaceId: String) : HomeActivitySharedAction() } 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 c511c9e6661..e14b4d4ede8 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 @@ -90,10 +90,10 @@ import im.vector.app.core.intent.getFilenameFromUri import im.vector.app.core.intent.getMimeTypeFromUri import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.resources.ColorProvider -import im.vector.app.core.ui.views.CurrentCallsView -import im.vector.app.core.ui.views.KnownCallsViewHolder import im.vector.app.core.ui.views.ActiveConferenceView +import im.vector.app.core.ui.views.CurrentCallsView import im.vector.app.core.ui.views.JumpToReadMarkerView +import im.vector.app.core.ui.views.KnownCallsViewHolder import im.vector.app.core.ui.views.NotificationAreaView import im.vector.app.core.utils.Debouncer import im.vector.app.core.utils.KeyboardStateUtils @@ -159,6 +159,7 @@ import im.vector.app.features.roomprofile.RoomProfileActivity import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity import im.vector.app.features.share.SharedData +import im.vector.app.features.spaces.ShareSpaceBottomSheet import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.widgets.WidgetActivity import im.vector.app.features.widgets.WidgetArgs @@ -207,7 +208,8 @@ import javax.inject.Inject data class RoomDetailArgs( val roomId: String, val eventId: String? = null, - val sharedData: SharedData? = null + val sharedData: SharedData? = null, + val openShareSpaceForId: String? = null ) : Parcelable class RoomDetailFragment @Inject constructor( @@ -289,7 +291,7 @@ class RoomDetailFragment @Inject constructor( private lateinit var attachmentsHelper: AttachmentsHelper private lateinit var keyboardStateUtils: KeyboardStateUtils - private lateinit var callActionsHandler : StartCallActionsHandler + private lateinit var callActionsHandler: StartCallActionsHandler private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView @@ -353,9 +355,9 @@ class RoomDetailFragment @Inject constructor( } when (mode) { is SendMode.REGULAR -> renderRegularMode(mode.text) - is SendMode.EDIT -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text) - is SendMode.QUOTE -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.quote, mode.text) - is SendMode.REPLY -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text) + is SendMode.EDIT -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text) + is SendMode.QUOTE -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.quote, mode.text) + is SendMode.REPLY -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text) } } @@ -365,43 +367,44 @@ class RoomDetailFragment @Inject constructor( roomDetailViewModel.observeViewEvents { when (it) { - is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable) - is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds) - is RoomDetailViewEvents.ActionSuccess -> displayRoomDetailActionSuccess(it) - is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it) - is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message, Snackbar.LENGTH_LONG) - is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it) - is RoomDetailViewEvents.FileTooBigError -> displayFileTooBigError(it) - is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it) - is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) - is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(it) - is RoomDetailViewEvents.ShowE2EErrorMessage -> displayE2eError(it.withHeldCode) - RoomDetailViewEvents.DisplayPromptForIntegrationManager -> displayPromptForIntegrationManager() - is RoomDetailViewEvents.OpenStickerPicker -> openStickerPicker(it) + is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable) + is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds) + is RoomDetailViewEvents.ActionSuccess -> displayRoomDetailActionSuccess(it) + is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it) + is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message, Snackbar.LENGTH_LONG) + is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it) + is RoomDetailViewEvents.FileTooBigError -> displayFileTooBigError(it) + is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it) + is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) + is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(it) + is RoomDetailViewEvents.ShowE2EErrorMessage -> displayE2eError(it.withHeldCode) + RoomDetailViewEvents.DisplayPromptForIntegrationManager -> displayPromptForIntegrationManager() + is RoomDetailViewEvents.OpenStickerPicker -> openStickerPicker(it) is RoomDetailViewEvents.DisplayEnableIntegrationsWarning -> displayDisabledIntegrationDialog() - is RoomDetailViewEvents.OpenIntegrationManager -> openIntegrationManager() - is RoomDetailViewEvents.OpenFile -> startOpenFileIntent(it) - RoomDetailViewEvents.OpenActiveWidgetBottomSheet -> onViewWidgetsClicked() - is RoomDetailViewEvents.ShowInfoOkDialog -> showDialogWithMessage(it.message) - is RoomDetailViewEvents.JoinJitsiConference -> joinJitsiRoom(it.widget, it.withVideo) - RoomDetailViewEvents.ShowWaitingView -> vectorBaseActivity.showWaitingView() - RoomDetailViewEvents.HideWaitingView -> vectorBaseActivity.hideWaitingView() - is RoomDetailViewEvents.RequestNativeWidgetPermission -> requestNativeWidgetPermission(it) - is RoomDetailViewEvents.OpenRoom -> handleOpenRoom(it) - RoomDetailViewEvents.OpenInvitePeople -> navigator.openInviteUsersToRoom(requireContext(), roomDetailArgs.roomId) - RoomDetailViewEvents.OpenSetRoomAvatarDialog -> galleryOrCameraDialogHelper.show() - RoomDetailViewEvents.OpenRoomSettings -> handleOpenRoomSettings() - is RoomDetailViewEvents.ShowRoomAvatarFullScreen -> it.matrixItem?.let { item -> + is RoomDetailViewEvents.OpenIntegrationManager -> openIntegrationManager() + is RoomDetailViewEvents.OpenFile -> startOpenFileIntent(it) + RoomDetailViewEvents.OpenActiveWidgetBottomSheet -> onViewWidgetsClicked() + is RoomDetailViewEvents.ShowInfoOkDialog -> showDialogWithMessage(it.message) + is RoomDetailViewEvents.JoinJitsiConference -> joinJitsiRoom(it.widget, it.withVideo) + RoomDetailViewEvents.ShowWaitingView -> vectorBaseActivity.showWaitingView() + RoomDetailViewEvents.HideWaitingView -> vectorBaseActivity.hideWaitingView() + is RoomDetailViewEvents.RequestNativeWidgetPermission -> requestNativeWidgetPermission(it) + is RoomDetailViewEvents.OpenRoom -> handleOpenRoom(it) + RoomDetailViewEvents.OpenInvitePeople -> navigator.openInviteUsersToRoom(requireContext(), roomDetailArgs.roomId) + RoomDetailViewEvents.OpenSetRoomAvatarDialog -> galleryOrCameraDialogHelper.show() + RoomDetailViewEvents.OpenRoomSettings -> handleOpenRoomSettings() + is RoomDetailViewEvents.ShowRoomAvatarFullScreen -> it.matrixItem?.let { item -> navigator.openBigImageViewer(requireActivity(), it.view, item) } - is RoomDetailViewEvents.StartChatEffect -> handleChatEffect(it.type) - RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects() - is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it) + is RoomDetailViewEvents.StartChatEffect -> handleChatEffect(it.type) + RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects() + is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it) }.exhaustive } if (savedInstanceState == null) { handleShareData() + handleSpaceShare() } } @@ -414,7 +417,7 @@ class RoomDetailFragment @Inject constructor( startActivity(intent) } - private fun handleChatEffect(chatEffect: ChatEffect) { + private fun handleChatEffect(chatEffect: ChatEffect) { when (chatEffect) { ChatEffect.CONFETTI -> { views.viewKonfetti.isVisible = true @@ -429,7 +432,7 @@ class RoomDetailFragment @Inject constructor( .setPosition(-50f, views.viewKonfetti.width + 50f, -50f, -50f) .streamFor(150, 3000L) } - ChatEffect.SNOW -> { + ChatEffect.SNOW -> { views.viewSnowFall.isVisible = true views.viewSnowFall.restartFalling() } @@ -603,17 +606,26 @@ class RoomDetailFragment @Inject constructor( private fun handleShareData() { when (val sharedData = roomDetailArgs.sharedData) { - is SharedData.Text -> { + is SharedData.Text -> { roomDetailViewModel.handle(RoomDetailAction.EnterRegularMode(sharedData.text, fromSharing = true)) } is SharedData.Attachments -> { // open share edition onContentAttachmentsReady(sharedData.attachmentData) } - null -> Timber.v("No share data to process") + null -> Timber.v("No share data to process") }.exhaustive } + private fun handleSpaceShare() { + roomDetailArgs.openShareSpaceForId?.let { spaceId -> + ShareSpaceBottomSheet.show(childFragmentManager, spaceId) + view?.post { + handleChatEffect(ChatEffect.CONFETTI) + } + } + } + override fun onDestroyView() { timelineEventController.callback = null timelineEventController.removeModelBuildListener(modelBuildListener) @@ -736,8 +748,8 @@ class RoomDetailFragment @Inject constructor( withState(roomDetailViewModel) { state -> // Set the visual state of the call buttons (voice/video) to enabled/disabled according to user permissions val callButtonsEnabled = when (state.asyncRoomSummary.invoke()?.joinedMembersCount) { - 1 -> false - 2 -> state.isAllowedToStartWebRTCCall + 1 -> false + 2 -> state.isAllowedToStartWebRTCCall else -> state.isAllowedToManageWidgets } setOf(R.id.voice_call, R.id.video_call).forEach { @@ -767,7 +779,7 @@ class RoomDetailFragment @Inject constructor( override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { - R.id.invite -> { + R.id.invite -> { navigator.openInviteUsersToRoom(requireActivity(), roomDetailArgs.roomId) true } @@ -775,7 +787,7 @@ class RoomDetailFragment @Inject constructor( navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId) true } - R.id.resend_all -> { + R.id.resend_all -> { roomDetailViewModel.handle(RoomDetailAction.ResendAll) true } @@ -787,19 +799,19 @@ class RoomDetailFragment @Inject constructor( callActionsHandler.onVoiceCallClicked() true } - R.id.video_call -> { + R.id.video_call -> { callActionsHandler.onVideoCallClicked() true } - R.id.hangup_call -> { + R.id.hangup_call -> { roomDetailViewModel.handle(RoomDetailAction.EndCall) true } - R.id.search -> { + R.id.search -> { handleSearchAction() true } - R.id.dev_tools -> { + R.id.dev_tools -> { navigator.openDevTools(requireContext(), roomDetailArgs.roomId) true } @@ -891,9 +903,9 @@ class RoomDetailFragment @Inject constructor( when (roomDetailPendingAction) { is RoomDetailPendingAction.JumpToReadReceipt -> roomDetailViewModel.handle(RoomDetailAction.JumpToReadReceipt(roomDetailPendingAction.userId)) - is RoomDetailPendingAction.MentionUser -> + is RoomDetailPendingAction.MentionUser -> insertUserDisplayNameInTextEditor(roomDetailPendingAction.userId) - is RoomDetailPendingAction.OpenOrCreateDm -> + is RoomDetailPendingAction.OpenOrCreateDm -> roomDetailViewModel.handle(RoomDetailAction.OpenOrCreateDm(roomDetailPendingAction.userId)) }.exhaustive } @@ -1048,9 +1060,9 @@ class RoomDetailFragment @Inject constructor( withState(roomDetailViewModel) { val showJumpToUnreadBanner = when (it.unreadState) { UnreadState.Unknown, - UnreadState.HasNoUnread -> false + UnreadState.HasNoUnread -> false is UnreadState.ReadMarkerNotLoaded -> true - is UnreadState.HasUnread -> { + is UnreadState.HasUnread -> { if (it.canShowJumpToReadMarker) { val lastVisibleItem = layoutManager.findLastVisibleItemPosition() val positionOfReadMarker = timelineEventController.getPositionOfReadMarker() @@ -1232,7 +1244,7 @@ class RoomDetailFragment @Inject constructor( navigator.openRoom(vectorBaseActivity, async()) vectorBaseActivity.finish() } - is Fail -> { + is Fail -> { vectorBaseActivity.hideWaitingView() vectorBaseActivity.toast(errorFormatter.toHumanReadable(async.error)) } @@ -1241,19 +1253,19 @@ class RoomDetailFragment @Inject constructor( private fun renderSendMessageResult(sendMessageResult: RoomDetailViewEvents.SendMessageResult) { when (sendMessageResult) { - is RoomDetailViewEvents.SlashCommandHandled -> { + is RoomDetailViewEvents.SlashCommandHandled -> { sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) } } - is RoomDetailViewEvents.SlashCommandError -> { + is RoomDetailViewEvents.SlashCommandError -> { displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) } - is RoomDetailViewEvents.SlashCommandUnknown -> { + is RoomDetailViewEvents.SlashCommandUnknown -> { displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command)) } - is RoomDetailViewEvents.SlashCommandResultOk -> { + is RoomDetailViewEvents.SlashCommandResultOk -> { updateComposerText("") } - is RoomDetailViewEvents.SlashCommandResultError -> { + is RoomDetailViewEvents.SlashCommandResultError -> { displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable)) } is RoomDetailViewEvents.SlashCommandNotImplemented -> { @@ -1275,7 +1287,7 @@ class RoomDetailFragment @Inject constructor( private fun displayE2eError(withHeldCode: WithHeldCode?) { val msgId = when (withHeldCode) { WithHeldCode.BLACKLISTED -> R.string.crypto_error_withheld_blacklisted - WithHeldCode.UNVERIFIED -> R.string.crypto_error_withheld_unverified + WithHeldCode.UNVERIFIED -> R.string.crypto_error_withheld_unverified WithHeldCode.UNAUTHORISED, WithHeldCode.UNAVAILABLE -> R.string.crypto_error_withheld_generic else -> R.string.notice_crypto_unable_to_decrypt_friendly_desc @@ -1326,7 +1338,7 @@ class RoomDetailFragment @Inject constructor( private fun displayRoomDetailActionSuccess(result: RoomDetailViewEvents.ActionSuccess) { when (val data = result.action) { - is RoomDetailAction.ReportContent -> { + is RoomDetailAction.ReportContent -> { when { data.spam -> { AlertDialog.Builder(requireActivity()) @@ -1363,7 +1375,7 @@ class RoomDetailFragment @Inject constructor( } } } - is RoomDetailAction.RequestVerification -> { + is RoomDetailAction.RequestVerification -> { Timber.v("## SAS RequestVerification action") VerificationBottomSheet.withArgs( roomDetailArgs.roomId, @@ -1378,7 +1390,7 @@ class RoomDetailFragment @Inject constructor( data.transactionId ).show(parentFragmentManager, "REQ") } - is RoomDetailAction.ResumeVerification -> { + is RoomDetailAction.ResumeVerification -> { val otherUserId = data.otherUserId ?: return VerificationBottomSheet().apply { arguments = Bundle().apply { @@ -1395,7 +1407,7 @@ class RoomDetailFragment @Inject constructor( override fun onUrlClicked(url: String, title: String): Boolean { permalinkHandler .launch(requireActivity(), url, object : NavigationInterceptor { - override fun navToRoom(roomId: String?, eventId: String?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { // Same room? if (roomId == roomDetailArgs.roomId) { // Navigation to same room @@ -1521,11 +1533,11 @@ class RoomDetailFragment @Inject constructor( is MessageVerificationRequestContent -> { roomDetailViewModel.handle(RoomDetailAction.ResumeVerification(informationData.eventId, null)) } - is MessageWithAttachmentContent -> { + is MessageWithAttachmentContent -> { val action = RoomDetailAction.DownloadOrOpen(informationData.eventId, informationData.senderId, messageContent) roomDetailViewModel.handle(action) } - is EncryptedEventContent -> { + is EncryptedEventContent -> { roomDetailViewModel.handle(RoomDetailAction.TapOnFailedToDecrypt(informationData.eventId)) } } @@ -1587,7 +1599,7 @@ class RoomDetailFragment @Inject constructor( override fun onRoomCreateLinkClicked(url: String) { permalinkHandler .launch(requireContext(), url, object : NavigationInterceptor { - override fun navToRoom(roomId: String?, eventId: String?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { requireActivity().finish() return false } @@ -1672,75 +1684,75 @@ class RoomDetailFragment @Inject constructor( private fun handleActions(action: EventSharedAction) { when (action) { - is EventSharedAction.OpenUserProfile -> { + is EventSharedAction.OpenUserProfile -> { openRoomMemberProfile(action.userId) } - is EventSharedAction.AddReaction -> { + is EventSharedAction.AddReaction -> { emojiActivityResultLauncher.launch(EmojiReactionPickerActivity.intent(requireContext(), action.eventId)) } - is EventSharedAction.ViewReactions -> { + is EventSharedAction.ViewReactions -> { ViewReactionsBottomSheet.newInstance(roomDetailArgs.roomId, action.messageInformationData) .show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS") } - is EventSharedAction.Copy -> { + is EventSharedAction.Copy -> { // I need info about the current selected message :/ copyToClipboard(requireContext(), action.content, false) showSnackWithMessage(getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT) } - is EventSharedAction.Redact -> { + is EventSharedAction.Redact -> { promptConfirmationToRedactEvent(action) } - is EventSharedAction.Share -> { + is EventSharedAction.Share -> { onShareActionClicked(action) } - is EventSharedAction.Save -> { + is EventSharedAction.Save -> { onSaveActionClicked(action) } - is EventSharedAction.ViewEditHistory -> { + is EventSharedAction.ViewEditHistory -> { onEditedDecorationClicked(action.messageInformationData) } - is EventSharedAction.ViewSource -> { + is EventSharedAction.ViewSource -> { JSonViewerDialog.newInstance( action.content, -1, createJSonViewerStyleProvider(colorProvider) ).show(childFragmentManager, "JSON_VIEWER") } - is EventSharedAction.ViewDecryptedSource -> { + is EventSharedAction.ViewDecryptedSource -> { JSonViewerDialog.newInstance( action.content, -1, createJSonViewerStyleProvider(colorProvider) ).show(childFragmentManager, "JSON_VIEWER") } - is EventSharedAction.QuickReact -> { + is EventSharedAction.QuickReact -> { // eventId,ClickedOn,Add roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add)) } - is EventSharedAction.Edit -> { + is EventSharedAction.Edit -> { roomDetailViewModel.handle(RoomDetailAction.EnterEditMode(action.eventId, views.composerLayout.text.toString())) } - is EventSharedAction.Quote -> { + is EventSharedAction.Quote -> { roomDetailViewModel.handle(RoomDetailAction.EnterQuoteMode(action.eventId, views.composerLayout.text.toString())) } - is EventSharedAction.Reply -> { + is EventSharedAction.Reply -> { roomDetailViewModel.handle(RoomDetailAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString())) } - is EventSharedAction.CopyPermalink -> { + is EventSharedAction.CopyPermalink -> { val permalink = session.permalinkService().createPermalink(roomDetailArgs.roomId, action.eventId) copyToClipboard(requireContext(), permalink, false) showSnackWithMessage(getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT) } - is EventSharedAction.Resend -> { + is EventSharedAction.Resend -> { roomDetailViewModel.handle(RoomDetailAction.ResendMessage(action.eventId)) } - is EventSharedAction.Remove -> { + is EventSharedAction.Remove -> { roomDetailViewModel.handle(RoomDetailAction.RemoveFailedEcho(action.eventId)) } - is EventSharedAction.Cancel -> { + is EventSharedAction.Cancel -> { roomDetailViewModel.handle(RoomDetailAction.CancelSend(action.eventId)) } - is EventSharedAction.ReportContentSpam -> { + is EventSharedAction.ReportContentSpam -> { roomDetailViewModel.handle(RoomDetailAction.ReportContent( action.eventId, action.senderId, "This message is spam", spam = true)) } @@ -1748,22 +1760,22 @@ class RoomDetailFragment @Inject constructor( roomDetailViewModel.handle(RoomDetailAction.ReportContent( action.eventId, action.senderId, "This message is inappropriate", inappropriate = true)) } - is EventSharedAction.ReportContentCustom -> { + is EventSharedAction.ReportContentCustom -> { promptReasonToReportContent(action) } - is EventSharedAction.IgnoreUser -> { + is EventSharedAction.IgnoreUser -> { action.senderId?.let { askConfirmationToIgnoreUser(it) } } - is EventSharedAction.OnUrlClicked -> { + is EventSharedAction.OnUrlClicked -> { onUrlClicked(action.url, action.title) } - is EventSharedAction.OnUrlLongClicked -> { + is EventSharedAction.OnUrlLongClicked -> { onUrlLongClicked(action.url) } - is EventSharedAction.ReRequestKey -> { + is EventSharedAction.ReRequestKey -> { roomDetailViewModel.handle(RoomDetailAction.ReRequestKeys(action.eventId)) } - is EventSharedAction.UseKeyBackup -> { + is EventSharedAction.UseKeyBackup -> { context?.let { startActivity(KeysBackupRestoreActivity.intent(it)) } @@ -1903,10 +1915,10 @@ class RoomDetailFragment @Inject constructor( private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) { when (type) { - AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera(requireContext(), attachmentPhotoActivityResultLauncher) - AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) + AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera(requireContext(), attachmentPhotoActivityResultLauncher) + AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentImageActivityResultLauncher) - AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio(attachmentAudioActivityResultLauncher) + AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio(attachmentAudioActivityResultLauncher) AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) AttachmentTypeSelectorView.Type.STICKER -> roomDetailViewModel.handle(RoomDetailAction.SelectStickerAttachment) }.exhaustive diff --git a/vector/src/main/java/im/vector/app/features/matrixto/MatrixToAction.kt b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToAction.kt index e1c68004946..1c9d0bbb18d 100644 --- a/vector/src/main/java/im/vector/app/features/matrixto/MatrixToAction.kt +++ b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToAction.kt @@ -21,4 +21,11 @@ import org.matrix.android.sdk.api.util.MatrixItem sealed class MatrixToAction : VectorViewModelAction { data class StartChattingWithUser(val matrixItem: MatrixItem) : MatrixToAction() + object FailedToResolveUser : MatrixToAction() + object FailedToStartChatting : MatrixToAction() + data class JoinSpace(val spaceID: String, val viaServers: List?) : MatrixToAction() + data class JoinRoom(val roomId: String, val viaServers: List?) : MatrixToAction() + data class OpenSpace(val spaceID: String) : MatrixToAction() + data class OpenRoom(val roomId: String) : MatrixToAction() +// data class OpenSpace(val spaceID: String) : MatrixToAction() } diff --git a/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheet.kt b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheet.kt index 1d897477a26..b9ef8338507 100644 --- a/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheet.kt @@ -21,22 +21,23 @@ import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.view.isInvisible +import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible -import com.airbnb.mvrx.Fail -import com.airbnb.mvrx.Loading +import androidx.fragment.app.Fragment +import com.airbnb.mvrx.Incomplete import com.airbnb.mvrx.MvRx -import com.airbnb.mvrx.Success -import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState +import im.vector.app.R import im.vector.app.core.di.ScreenComponent -import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment import im.vector.app.databinding.BottomSheetMatrixToCardBinding import im.vector.app.features.home.AvatarRenderer import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.api.session.permalinks.PermalinkData import javax.inject.Inject +import kotlin.reflect.KClass class MatrixToBottomSheet : VectorBaseBottomSheetDialogFragment() { @@ -65,63 +66,41 @@ class MatrixToBottomSheet : interface InteractionListener { fun navigateToRoom(roomId: String) + fun switchToSpace(spaceId: String) {} } override fun invalidate() = withState(viewModel) { state -> super.invalidate() - when (val item = state.matrixItem) { - Uninitialized -> { - views.matrixToCardContentLoading.isVisible = false - views.matrixToCardUserContentVisibility.isVisible = false + when (state.linkType) { + is PermalinkData.RoomLink -> { + views.matrixToCardContentLoading.isVisible = state.roomPeekResult is Incomplete + showFragment(MatrixToRoomSpaceFragment::class, Bundle()) } - is Loading -> { - views.matrixToCardContentLoading.isVisible = true - views.matrixToCardUserContentVisibility.isVisible = false + is PermalinkData.UserLink -> { + views.matrixToCardContentLoading.isVisible = state.matrixItem is Incomplete + showFragment(MatrixToUserFragment::class, Bundle()) } - is Success -> { - views.matrixToCardContentLoading.isVisible = false - views.matrixToCardUserContentVisibility.isVisible = true - views.matrixToCardNameText.setTextOrHide(item.invoke().displayName) - views.matrixToCardUserIdText.setTextOrHide(item.invoke().id) - avatarRenderer.render(item.invoke(), views.matrixToCardAvatar) + is PermalinkData.GroupLink -> { } - is Fail -> { - // TODO display some error copy? - dismiss() + is PermalinkData.FallbackLink -> { } } + } - when (state.startChattingState) { - Uninitialized -> { - views.matrixToCardButtonLoading.isVisible = false - views.matrixToCardSendMessageButton.isVisible = false - } - is Success -> { - views.matrixToCardButtonLoading.isVisible = false - views.matrixToCardSendMessageButton.isVisible = true - } - is Fail -> { - views.matrixToCardButtonLoading.isVisible = false - views.matrixToCardSendMessageButton.isVisible = true - // TODO display some error copy? - dismiss() - } - is Loading -> { - views.matrixToCardButtonLoading.isVisible = true - views.matrixToCardSendMessageButton.isInvisible = true + private fun showFragment(fragmentClass: KClass, bundle: Bundle) { + if (childFragmentManager.findFragmentByTag(fragmentClass.simpleName) == null) { + childFragmentManager.commitTransaction { + replace(views.matrixToCardFragmentContainer.id, + fragmentClass.java, + bundle, + fragmentClass.simpleName + ) } } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - views.matrixToCardSendMessageButton.debouncedClicks { - withState(viewModel) { - it.matrixItem.invoke()?.let { item -> - viewModel.handle(MatrixToAction.StartChattingWithUser(item)) - } - } - } viewModel.observeViewEvents { when (it) { @@ -130,6 +109,16 @@ class MatrixToBottomSheet : dismiss() } MatrixToViewEvents.Dismiss -> dismiss() + is MatrixToViewEvents.NavigateToSpace -> { + interactionListener?.switchToSpace(it.spaceId) + dismiss() + } + is MatrixToViewEvents.ShowModalError -> { + AlertDialog.Builder(requireContext()) + .setMessage(it.error) + .setPositiveButton(getString(R.string.ok), null) + .show() + } } } } diff --git a/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheetState.kt b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheetState.kt index 9b1ce9fea83..c0d42d60dd4 100644 --- a/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheetState.kt +++ b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheetState.kt @@ -19,15 +19,45 @@ package im.vector.app.features.matrixto import com.airbnb.mvrx.Async import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.Uninitialized +import org.matrix.android.sdk.api.session.permalinks.PermalinkData +import org.matrix.android.sdk.api.session.permalinks.PermalinkParser +import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.util.MatrixItem data class MatrixToBottomSheetState( val deepLink: String, + val linkType: PermalinkData, val matrixItem: Async = Uninitialized, - val startChattingState: Async = Uninitialized + val startChattingState: Async = Uninitialized, + val roomPeekResult: Async = Uninitialized ) : MvRxState { constructor(args: MatrixToBottomSheet.MatrixToArgs) : this( - deepLink = args.matrixToLink + deepLink = args.matrixToLink, + linkType = PermalinkParser.parse(args.matrixToLink) ) } + +sealed class RoomInfoResult { + data class FullInfo( + val roomItem: MatrixItem.RoomItem, + val name: String, + val topic: String, + val memberCount: Int?, + val alias: String?, + val membership: Membership, + val roomType: String?, + val viaServers: List? + ) : RoomInfoResult() + + data class PartialInfo( + val roomId: String?, + val viaServers: List + ) : RoomInfoResult() + + data class UnknownAlias( + val alias: String? + ) : RoomInfoResult() + + object NotFound : RoomInfoResult() +} diff --git a/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheetViewModel.kt b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheetViewModel.kt index e5bdc9bca81..8c84b31f4e3 100644 --- a/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheetViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheetViewModel.kt @@ -25,9 +25,10 @@ import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.ViewModelContext import dagger.assisted.Assisted -import dagger.assisted.AssistedInject import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import im.vector.app.R +import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider @@ -35,12 +36,16 @@ import im.vector.app.features.createdirect.DirectRoomHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.matrix.android.sdk.api.extensions.tryOrNull -import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.permalinks.PermalinkData -import org.matrix.android.sdk.api.session.permalinks.PermalinkParser +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.peeking.PeekResult +import org.matrix.android.sdk.api.session.space.SpaceService import org.matrix.android.sdk.api.session.user.model.User +import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toMatrixItem +import org.matrix.android.sdk.internal.session.room.alias.RoomAliasDescription import org.matrix.android.sdk.internal.util.awaitCallback class MatrixToBottomSheetViewModel @AssistedInject constructor( @@ -48,7 +53,8 @@ class MatrixToBottomSheetViewModel @AssistedInject constructor( private val session: Session, private val stringProvider: StringProvider, private val directRoomHelper: DirectRoomHelper, - private val rawService: RawService) : VectorViewModel(initialState) { + private val errorFormatter: ErrorFormatter) + : VectorViewModel(initialState) { @AssistedFactory interface Factory { @@ -56,8 +62,23 @@ class MatrixToBottomSheetViewModel @AssistedInject constructor( } init { - setState { - copy(matrixItem = Loading()) + when (initialState.linkType) { + is PermalinkData.RoomLink -> { + setState { + copy(roomPeekResult = Loading()) + } + } + is PermalinkData.UserLink -> { + setState { + copy(matrixItem = Loading()) + } + } + is PermalinkData.GroupLink -> { + // Not yet supported + } + is PermalinkData.FallbackLink -> { + // Not yet supported + } } viewModelScope.launch(Dispatchers.IO) { resolveLink(initialState) @@ -65,7 +86,7 @@ class MatrixToBottomSheetViewModel @AssistedInject constructor( } private suspend fun resolveLink(initialState: MatrixToBottomSheetState) { - val permalinkData = PermalinkParser.parse(initialState.deepLink) + val permalinkData = initialState.linkType if (permalinkData is PermalinkData.FallbackLink) { setState { copy( @@ -76,8 +97,8 @@ class MatrixToBottomSheetViewModel @AssistedInject constructor( return } - when (permalinkData) { - is PermalinkData.UserLink -> { + when (permalinkData) { + is PermalinkData.UserLink -> { val user = resolveUser(permalinkData.userId) setState { copy( @@ -86,11 +107,78 @@ class MatrixToBottomSheetViewModel @AssistedInject constructor( ) } } - is PermalinkData.RoomLink -> { - // not yet supported - _viewEvents.post(MatrixToViewEvents.Dismiss) + is PermalinkData.RoomLink -> { + // could this room be already known + val knownRoom = if (permalinkData.isRoomAlias) { + tryOrNull { + awaitCallback> { + session.getRoomIdByAlias(permalinkData.roomIdOrAlias, false, it) + } + } + ?.getOrNull() + ?.roomId?.let { + session.getRoom(it) + } + } else { + session.getRoom(permalinkData.roomIdOrAlias) + }?.roomSummary() + + if (knownRoom != null) { + setState { + copy( + roomPeekResult = Success( + RoomInfoResult.FullInfo( + roomItem = knownRoom.toMatrixItem(), + name = knownRoom.name, + topic = knownRoom.topic, + memberCount = knownRoom.joinedMembersCount, + alias = knownRoom.canonicalAlias, + membership = knownRoom.membership, + roomType = knownRoom.roomType, + viaServers = null + ) + ) + ) + } + } else { + val result = when (val peekResult = tryOrNull { resolveRoom(permalinkData.roomIdOrAlias) }) { + is PeekResult.Success -> { + RoomInfoResult.FullInfo( + roomItem = MatrixItem.RoomItem(peekResult.roomId, peekResult.name, peekResult.avatarUrl), + name = peekResult.name ?: "", + topic = peekResult.topic ?: "", + memberCount = peekResult.numJoinedMembers, + alias = peekResult.alias, + membership = Membership.NONE, + roomType = peekResult.roomType, + viaServers = peekResult.viaServers.takeIf { it.isNotEmpty() } ?: permalinkData.viaParameters + ) + } + is PeekResult.PeekingNotAllowed -> { + RoomInfoResult.PartialInfo( + roomId = permalinkData.roomIdOrAlias, + viaServers = permalinkData.viaParameters + ) + } + PeekResult.UnknownAlias -> { + RoomInfoResult.UnknownAlias(permalinkData.roomIdOrAlias) + } + null -> { + RoomInfoResult.PartialInfo( + roomId = permalinkData.roomIdOrAlias, + viaServers = permalinkData.viaParameters + ).takeIf { permalinkData.isRoomAlias.not() } + ?: RoomInfoResult.NotFound + } + } + setState { + copy( + roomPeekResult = Success(result) + ) + } + } } - is PermalinkData.GroupLink -> { + is PermalinkData.GroupLink -> { // not yet supported _viewEvents.post(MatrixToViewEvents.Dismiss) } @@ -110,6 +198,16 @@ class MatrixToBottomSheetViewModel @AssistedInject constructor( ?: User(userId, null, null) } + /** + * Let's try to get some information about that room, + * main thing is trying to see if it's a space or a room + */ + private suspend fun resolveRoom(roomIdOrAlias: String): PeekResult { + return awaitCallback { + session.peekRoom(roomIdOrAlias, it) + } + } + companion object : MvRxViewModelFactory { override fun create(viewModelContext: ViewModelContext, state: MatrixToBottomSheetState): MatrixToBottomSheetViewModel? { val fragment: MatrixToBottomSheet = (viewModelContext as FragmentViewModelContext).fragment() @@ -121,14 +219,73 @@ class MatrixToBottomSheetViewModel @AssistedInject constructor( override fun handle(action: MatrixToAction) { when (action) { is MatrixToAction.StartChattingWithUser -> handleStartChatting(action) + MatrixToAction.FailedToResolveUser -> { + _viewEvents.post(MatrixToViewEvents.Dismiss) + } + MatrixToAction.FailedToStartChatting -> { + _viewEvents.post(MatrixToViewEvents.Dismiss) + } + is MatrixToAction.JoinSpace -> handleJoinSpace(action) + is MatrixToAction.JoinRoom -> handleJoinRoom(action) + is MatrixToAction.OpenSpace -> { + _viewEvents.post(MatrixToViewEvents.NavigateToSpace(action.spaceID)) + } + is MatrixToAction.OpenRoom -> { + _viewEvents.post(MatrixToViewEvents.NavigateToRoom(action.roomId)) + } }.exhaustive } - private fun handleStartChatting(action: MatrixToAction.StartChattingWithUser) { + private fun handleJoinSpace(joinSpace: MatrixToAction.JoinSpace) { + setState { + copy(startChattingState = Loading()) + } viewModelScope.launch { - setState { - copy(startChattingState = Loading()) + try { + val joinResult = session.spaceService().joinSpace(joinSpace.spaceID, null, joinSpace.viaServers?.take(3) ?: emptyList()) + if (joinResult.isSuccess()) { + _viewEvents.post(MatrixToViewEvents.NavigateToSpace(joinSpace.spaceID)) + } else { + val errMsg = errorFormatter.toHumanReadable((joinResult as? SpaceService.JoinSpaceResult.Fail)?.error) + _viewEvents.post(MatrixToViewEvents.ShowModalError(errMsg)) + } + } catch (failure: Throwable) { + _viewEvents.post(MatrixToViewEvents.ShowModalError(errorFormatter.toHumanReadable(failure))) + } finally { + setState { + // we can hide this button has we will navigate out + copy(startChattingState = Uninitialized) + } } + } + } + + private fun handleJoinRoom(action: MatrixToAction.JoinRoom) { + setState { + copy(startChattingState = Loading()) + } + viewModelScope.launch { + try { + awaitCallback { + session.joinRoom(action.roomId, null, action.viaServers?.take(3) ?: emptyList(), it) + } + _viewEvents.post(MatrixToViewEvents.NavigateToRoom(action.roomId)) + } catch (failure: Throwable) { + _viewEvents.post(MatrixToViewEvents.ShowModalError(errorFormatter.toHumanReadable(failure))) + } finally { + setState { + // we can hide this button has we will navigate out + copy(startChattingState = Uninitialized) + } + } + } + } + + private fun handleStartChatting(action: MatrixToAction.StartChattingWithUser) { + setState { + copy(startChattingState = Loading()) + } + viewModelScope.launch { val roomId = try { directRoomHelper.ensureDMExists(action.matrixItem.id) } catch (failure: Throwable) { diff --git a/vector/src/main/java/im/vector/app/features/matrixto/MatrixToRoomSpaceFragment.kt b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToRoomSpaceFragment.kt new file mode 100644 index 00000000000..31dc5373959 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToRoomSpaceFragment.kt @@ -0,0 +1,217 @@ +/* + * 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.matrixto + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.parentFragmentViewModel +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.databinding.FragmentMatrixToRoomSpaceCardBinding +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomType +import javax.inject.Inject + +class MatrixToRoomSpaceFragment @Inject constructor( + private val avatarRenderer: AvatarRenderer +) : VectorBaseFragment() { + + private val sharedViewModel: MatrixToBottomSheetViewModel by parentFragmentViewModel() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentMatrixToRoomSpaceCardBinding { + return FragmentMatrixToRoomSpaceCardBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + views.matrixToCardMainButton.debouncedClicks { + mainButtonClicked() + } + views.matrixToCardSecondaryButton.debouncedClicks { + secondaryButtonClicked() + } + } + + override fun invalidate() = withState(sharedViewModel) { state -> + when (val item = state.roomPeekResult) { + Uninitialized -> { + views.matrixToCardContentVisibility.isVisible = false + } + is Loading -> { + views.matrixToCardContentVisibility.isVisible = false + } + is Success -> { + views.matrixToCardContentVisibility.isVisible = true + when (val peek = item.invoke()) { + is RoomInfoResult.FullInfo -> { + val matrixItem = peek.roomItem + if (peek.roomType == RoomType.SPACE) { + avatarRenderer.renderSpace(matrixItem, views.matrixToCardAvatar) + } else { + avatarRenderer.render(matrixItem, views.matrixToCardAvatar) + } + views.matrixToCardNameText.setTextOrHide(peek.name) + views.matrixToCardAliasText.setTextOrHide(peek.alias) + views.matrixToCardDescText.setTextOrHide(peek.topic) + val memberCount = peek.memberCount + if (memberCount != null) { + views.matrixToMemberPills.isVisible = true + views.spaceChildMemberCountText.text = resources.getQuantityString(R.plurals.room_title_members, memberCount, memberCount) + } else { + // hide the pill + views.matrixToMemberPills.isVisible = false + } + + val joinTextRes = if (peek.roomType == RoomType.SPACE) { + R.string.join_space + } else { + R.string.join_room + } + + when (peek.membership) { + Membership.LEAVE, + Membership.NONE -> { + views.matrixToCardMainButton.isVisible = true + views.matrixToCardMainButton.text = getString(joinTextRes) + views.matrixToCardSecondaryButton.isVisible = false + } + Membership.INVITE -> { + views.matrixToCardMainButton.isVisible = true + views.matrixToCardSecondaryButton.isVisible = true + views.matrixToCardMainButton.text = getString(joinTextRes) + views.matrixToCardSecondaryButton.text = getString(R.string.decline) + } + Membership.JOIN -> { + views.matrixToCardMainButton.isVisible = true + views.matrixToCardSecondaryButton.isVisible = false + views.matrixToCardMainButton.text = getString(R.string.action_open) + } + Membership.KNOCK, + Membership.BAN -> { + // What to do here ? + views.matrixToCardMainButton.isVisible = false + views.matrixToCardSecondaryButton.isVisible = false + } + } + } + is RoomInfoResult.PartialInfo -> { + // It may still be possible to join + views.matrixToCardNameText.text = peek.roomId + views.matrixToCardAliasText.isVisible = false + views.matrixToMemberPills.isVisible = false + views.matrixToCardDescText.setTextOrHide(getString(R.string.room_preview_no_preview)) + + views.matrixToCardMainButton.text = getString(R.string.join_anyway) + views.matrixToCardSecondaryButton.isVisible = false + } + RoomInfoResult.NotFound -> { + // we cannot join :/ + views.matrixToCardNameText.isVisible = false + views.matrixToCardAliasText.isVisible = false + views.matrixToMemberPills.isVisible = false + views.matrixToCardDescText.setTextOrHide(getString(R.string.room_preview_not_found)) + + views.matrixToCardMainButton.isVisible = false + views.matrixToCardSecondaryButton.isVisible = false + } + is RoomInfoResult.UnknownAlias -> { + views.matrixToCardNameText.isVisible = false + views.matrixToCardAliasText.isVisible = false + views.spaceChildMemberCountText.isVisible = false + views.matrixToCardDescText.setTextOrHide(getString(R.string.room_alias_preview_not_found)) + + views.matrixToCardMainButton.isVisible = false + views.matrixToCardSecondaryButton.isVisible = false + } + } + } + is Fail -> { + // TODO display some error copy? + sharedViewModel.handle(MatrixToAction.FailedToResolveUser) + } + } + + when (state.startChattingState) { + Uninitialized -> { + views.matrixToCardButtonLoading.isVisible = false +// views.matrixToCardMainButton.isVisible = false + } + is Success -> { + views.matrixToCardButtonLoading.isVisible = false + views.matrixToCardMainButton.isVisible = true + } + is Fail -> { + views.matrixToCardButtonLoading.isVisible = false + views.matrixToCardMainButton.isVisible = true + // TODO display some error copy? + } + is Loading -> { + views.matrixToCardButtonLoading.isVisible = true + views.matrixToCardMainButton.isInvisible = true + } + } + } + + private fun mainButtonClicked() = withState(sharedViewModel) { state -> + when (val info = state.roomPeekResult.invoke()) { + is RoomInfoResult.FullInfo -> { + when (info.membership) { + Membership.NONE, + Membership.INVITE, + Membership.LEAVE -> { + if (info.roomType == RoomType.SPACE) { + sharedViewModel.handle(MatrixToAction.JoinSpace(info.roomItem.id, info.viaServers)) + } else { + sharedViewModel.handle(MatrixToAction.JoinRoom(info.roomItem.id, info.viaServers)) + } + } + Membership.JOIN -> { + if (info.roomType == RoomType.SPACE) { + sharedViewModel.handle(MatrixToAction.OpenSpace(info.roomItem.id)) + } else { + sharedViewModel.handle(MatrixToAction.OpenRoom(info.roomItem.id)) + } + } + else -> { + } + } + } + is RoomInfoResult.PartialInfo -> { + // we can try to join anyway + if (info.roomId != null) { + sharedViewModel.handle(MatrixToAction.JoinRoom(info.roomId, info.viaServers)) + } + } + else -> { + } + } + } + + fun secondaryButtonClicked() = withState(sharedViewModel) { state -> + } +} diff --git a/vector/src/main/java/im/vector/app/features/matrixto/MatrixToUserFragment.kt b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToUserFragment.kt new file mode 100644 index 00000000000..3792183bca2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToUserFragment.kt @@ -0,0 +1,99 @@ +/* + * 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.matrixto + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.parentFragmentViewModel +import com.airbnb.mvrx.withState +import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.databinding.FragmentMatrixToUserCardBinding +import im.vector.app.features.home.AvatarRenderer +import javax.inject.Inject + +class MatrixToUserFragment @Inject constructor( + private val avatarRenderer: AvatarRenderer +) : VectorBaseFragment() { + + private val sharedViewModel: MatrixToBottomSheetViewModel by parentFragmentViewModel() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentMatrixToUserCardBinding { + return FragmentMatrixToUserCardBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + views.matrixToCardSendMessageButton.debouncedClicks { + withState(sharedViewModel) { + it.matrixItem.invoke()?.let { item -> + sharedViewModel.handle(MatrixToAction.StartChattingWithUser(item)) + } + } + } + } + + override fun invalidate() = withState(sharedViewModel) { state -> + when (val item = state.matrixItem) { + Uninitialized -> { + views.matrixToCardUserContentVisibility.isVisible = false + } + is Loading -> { + views.matrixToCardUserContentVisibility.isVisible = false + } + is Success -> { + views.matrixToCardUserContentVisibility.isVisible = true + views.matrixToCardNameText.setTextOrHide(item.invoke().displayName) + views.matrixToCardUserIdText.setTextOrHide(item.invoke().id) + avatarRenderer.render(item.invoke(), views.matrixToCardAvatar) + } + is Fail -> { + // TODO display some error copy? + sharedViewModel.handle(MatrixToAction.FailedToResolveUser) + } + } + + when (state.startChattingState) { + Uninitialized -> { + views.matrixToCardButtonLoading.isVisible = false + views.matrixToCardSendMessageButton.isVisible = false + } + is Success -> { + views.matrixToCardButtonLoading.isVisible = false + views.matrixToCardSendMessageButton.isVisible = true + } + is Fail -> { + views.matrixToCardButtonLoading.isVisible = false + views.matrixToCardSendMessageButton.isVisible = true + // TODO display some error copy? + sharedViewModel.handle(MatrixToAction.FailedToStartChatting) + } + is Loading -> { + views.matrixToCardButtonLoading.isVisible = true + views.matrixToCardSendMessageButton.isInvisible = true + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/matrixto/MatrixToViewEvents.kt b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToViewEvents.kt index f9491fd3613..2c7bc07b23b 100644 --- a/vector/src/main/java/im/vector/app/features/matrixto/MatrixToViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToViewEvents.kt @@ -20,5 +20,7 @@ import im.vector.app.core.platform.VectorViewEvents sealed class MatrixToViewEvents : VectorViewEvents { data class NavigateToRoom(val roomId: String) : MatrixToViewEvents() + data class NavigateToSpace(val spaceId: String) : MatrixToViewEvents() + data class ShowModalError(val error: String) : MatrixToViewEvents() object Dismiss : MatrixToViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt index 76e4cad28ff..27c29ae42b5 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt @@ -29,6 +29,7 @@ import androidx.core.app.ActivityOptionsCompat import androidx.core.app.TaskStackBuilder import androidx.core.util.Pair import androidx.core.view.ViewCompat +import arrow.core.Option import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.error.fatalError @@ -46,12 +47,14 @@ import im.vector.app.features.crypto.verification.SupportedVerificationMethodsPr import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.debug.DebugMenuActivity import im.vector.app.features.devtools.RoomDevToolActivity +import im.vector.app.features.grouplist.SelectedSpaceDataSource import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.home.room.detail.RoomDetailArgs import im.vector.app.features.home.room.detail.search.SearchActivity import im.vector.app.features.home.room.detail.search.SearchArgs import im.vector.app.features.home.room.filtered.FilteredRoomsActivity import im.vector.app.features.invite.InviteUsersToRoomActivity +import im.vector.app.features.matrixto.MatrixToBottomSheet import im.vector.app.features.media.AttachmentData import im.vector.app.features.media.BigImageViewerActivity import im.vector.app.features.media.VectorAttachmentViewerActivity @@ -68,6 +71,7 @@ import im.vector.app.features.roomprofile.RoomProfileActivity import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity import im.vector.app.features.share.SharedData +import im.vector.app.features.spaces.SpacePreviewActivity import im.vector.app.features.terms.ReviewTermsActivity import im.vector.app.features.widgets.WidgetActivity import im.vector.app.features.widgets.WidgetArgsBuilder @@ -77,6 +81,7 @@ import org.matrix.android.sdk.api.session.room.model.thirdparty.RoomDirectoryDat import org.matrix.android.sdk.api.session.terms.TermsService import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.api.session.widgets.model.WidgetType +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -85,6 +90,7 @@ class DefaultNavigator @Inject constructor( private val sessionHolder: ActiveSessionHolder, private val vectorPreferences: VectorPreferences, private val widgetArgsBuilder: WidgetArgsBuilder, + private val selectedSpaceDataSource: SelectedSpaceDataSource, private val supportedVerificationMethodsProvider: SupportedVerificationMethodsProvider ) : Navigator { @@ -98,6 +104,35 @@ class DefaultNavigator @Inject constructor( startActivity(context, intent, buildTask) } + override fun switchToSpace(context: Context, spaceId: String, roomId: String?, openShareSheet: Boolean) { + if (sessionHolder.getSafeActiveSession()?.spaceService()?.getSpace(spaceId) == null) { + fatalError("Trying to open an unknown space $spaceId", vectorPreferences.failFast()) + return + } + + sessionHolder.getSafeActiveSession()?.spaceService()?.getSpace(spaceId)?.spaceSummary()?.let { + Timber.d("## Nav: Switching to space $spaceId / ${it.roomSummary.name}") + selectedSpaceDataSource.post(Option.just(it)) + } ?: kotlin.run { + Timber.d("## Nav: Failed to switch to space $spaceId") + } + if (roomId != null) { + val args = RoomDetailArgs(roomId, eventId = null, openShareSpaceForId = spaceId.takeIf { openShareSheet }) + val intent = RoomDetailActivity.newIntent(context, args) + startActivity(context, intent, false) + } else { + // go back to home if we are showing room details? + // This is a bit ugly, but the navigator is supposed to know about the activity stack + if (context is RoomDetailActivity) { + context.finish() + } + } + } + + override fun openSpacePreview(context: Context, spaceId: String) { + startActivity(context, SpacePreviewActivity.newIntent(context, spaceId), false) + } + override fun performDeviceVerification(context: Context, otherUserId: String, sasTransactionId: String) { val session = sessionHolder.getSafeActiveSession() ?: return val tx = session.cryptoService().verificationService().getExistingTransaction(otherUserId, sasTransactionId) @@ -197,6 +232,23 @@ class DefaultNavigator @Inject constructor( context.startActivity(intent) } + override fun openMatrixToBottomSheet(context: Context, link: String) { + if (context is AppCompatActivity) { + val listener = object : MatrixToBottomSheet.InteractionListener { + override fun navigateToRoom(roomId: String) { + openRoom(context, roomId) + } + + override fun switchToSpace(spaceId: String) { + this@DefaultNavigator.switchToSpace(context, spaceId, null, openShareSheet = false) + } + } + // TODO check if there is already one?? + MatrixToBottomSheet.withLink(link, listener) + .show(context.supportFragmentManager, "HA#MatrixToBottomSheet") + } + } + override fun openRoomDirectory(context: Context, initialFilter: String) { val intent = RoomDirectoryActivity.getIntent(context, initialFilter) context.startActivity(intent) diff --git a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt index b4bd677b0c7..489cd379870 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt @@ -38,6 +38,10 @@ interface Navigator { fun openRoom(context: Context, roomId: String, eventId: String? = null, buildTask: Boolean = false) + fun switchToSpace(context: Context, spaceId: String, roomId: String?, openShareSheet: Boolean) + + fun openSpacePreview(context: Context, spaceId: String) + fun performDeviceVerification(context: Context, otherUserId: String, sasTransactionId: String) fun requestSessionVerification(context: Context, otherSessionId: String) @@ -54,6 +58,8 @@ interface Navigator { fun openRoomPreview(context: Context, roomPreviewData: RoomPreviewData) + fun openMatrixToBottomSheet(context: Context, link: String) + fun openCreateRoom(context: Context, initialName: String = "") fun openCreateDirectRoom(context: Context) diff --git a/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt b/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt index a7d69c783c0..d14b42ece71 100644 --- a/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt +++ b/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt @@ -22,7 +22,6 @@ import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.utils.toast import im.vector.app.features.navigation.Navigator -import im.vector.app.features.roomdirectory.roompreview.RoomPreviewData import io.reactivex.Single import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers @@ -30,6 +29,7 @@ import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.permalinks.PermalinkData import org.matrix.android.sdk.api.session.permalinks.PermalinkParser import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomType import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.rx.rx @@ -77,27 +77,28 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti buildTask: Boolean ): Single { return when (permalinkData) { - is PermalinkData.RoomLink -> { + is PermalinkData.RoomLink -> { permalinkData.getRoomId() .observeOn(AndroidSchedulers.mainThread()) .map { val roomId = it.getOrNull() - if (navigationInterceptor?.navToRoom(roomId, permalinkData.eventId) != true) { + if (navigationInterceptor?.navToRoom(roomId, permalinkData.eventId, rawLink) != true) { openRoom( context = context, roomId = roomId, permalinkData = permalinkData, + rawLink = rawLink, buildTask = buildTask ) } true } } - is PermalinkData.GroupLink -> { + is PermalinkData.GroupLink -> { navigator.openGroupDetail(permalinkData.groupId, context, buildTask) Single.just(true) } - is PermalinkData.UserLink -> { + is PermalinkData.UserLink -> { if (navigationInterceptor?.navToMemberProfile(permalinkData.userId, rawLink) != true) { navigator.openRoomMemberProfile(userId = permalinkData.userId, roomId = null, context = context, buildTask = buildTask) } @@ -133,6 +134,7 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti context: Context, roomId: String?, permalinkData: PermalinkData.RoomLink, + rawLink: Uri, buildTask: Boolean ) { val session = activeSessionHolder.getSafeActiveSession() ?: return @@ -144,35 +146,22 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti val membership = roomSummary?.membership val eventId = permalinkData.eventId val roomAlias = permalinkData.getRoomAliasOrNull() + val isSpace = roomSummary?.roomType == RoomType.SPACE return when { membership == Membership.BAN -> context.toast(R.string.error_opening_banned_room) membership?.isActive().orFalse() -> { - navigator.openRoom(context, roomId, eventId, buildTask) - } - else -> { - if (roomSummary == null) { - // we don't know this room, try to peek - val roomPreviewData = RoomPreviewData( - roomId = roomId, - roomAlias = roomAlias, - peekFromServer = true, - buildTask = buildTask, - homeServers = permalinkData.viaParameters - ) - navigator.openRoomPreview(context, roomPreviewData) + if (!isSpace && membership == Membership.JOIN) { + // If it's a room you're in, let's just open it, you can tap back if needed + navigator.openRoom(context, roomId, eventId, buildTask) } else { - val roomPreviewData = RoomPreviewData( - roomId = roomId, - eventId = eventId, - roomAlias = roomAlias ?: roomSummary.canonicalAlias, - roomName = roomSummary.displayName, - avatarUrl = roomSummary.avatarUrl, - buildTask = buildTask, - homeServers = permalinkData.viaParameters - ) - navigator.openRoomPreview(context, roomPreviewData) + // maybe open space preview navigator.openSpacePreview(context, roomId)? if already joined? + navigator.openMatrixToBottomSheet(context, rawLink.toString()) } } + else -> { + // XXX this could trigger another server load + navigator.openMatrixToBottomSheet(context, rawLink.toString()) + } } } } @@ -182,7 +171,7 @@ interface NavigationInterceptor { /** * Return true if the navigation has been intercepted */ - fun navToRoom(roomId: String?, eventId: String? = null): Boolean { + fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri? = null): Boolean { return false } diff --git a/vector/src/main/java/im/vector/app/features/roomdirectory/PublicRoomsFragment.kt b/vector/src/main/java/im/vector/app/features/roomdirectory/PublicRoomsFragment.kt index d200129ed9c..65aab1e448f 100644 --- a/vector/src/main/java/im/vector/app/features/roomdirectory/PublicRoomsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomdirectory/PublicRoomsFragment.kt @@ -16,6 +16,7 @@ package im.vector.app.features.roomdirectory +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem @@ -128,7 +129,7 @@ class PublicRoomsFragment @Inject constructor( val permalink = session.permalinkService().createPermalink(roomIdOrAlias) permalinkHandler .launch(requireContext(), permalink, object : NavigationInterceptor { - override fun navToRoom(roomId: String?, eventId: String?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { requireActivity().finish() return false } diff --git a/vector/src/main/java/im/vector/app/features/spaces/ShareSpaceBottomSheet.kt b/vector/src/main/java/im/vector/app/features/spaces/ShareSpaceBottomSheet.kt new file mode 100644 index 00000000000..d3fb2250835 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/spaces/ShareSpaceBottomSheet.kt @@ -0,0 +1,107 @@ +/* + * 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.spaces + +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentManager +import im.vector.app.R +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.di.ScreenComponent +import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment +import im.vector.app.core.utils.startSharePlainTextIntent +import im.vector.app.databinding.BottomSheetSpaceInviteBinding +import im.vector.app.features.invite.InviteUsersToRoomActivity +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +class ShareSpaceBottomSheet : VectorBaseBottomSheetDialogFragment() { + + @Parcelize + data class Args( + val spaceId: String + ) : Parcelable + + override val showExpanded = true + + @Inject + lateinit var activeSessionHolder: ActiveSessionHolder + + override fun injectWith(injector: ScreenComponent) { + injector.inject(this) + } + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetSpaceInviteBinding { + return BottomSheetSpaceInviteBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Not going for full view model for now, as it may change + + val args: Args = arguments?.getParcelable(EXTRA_ARGS) + ?: return Unit.also { dismiss() } + val summary = activeSessionHolder.getSafeActiveSession()?.spaceService()?.getSpace(args.spaceId)?.spaceSummary() + + val spaceName = summary?.roomSummary?.name + views.descriptionText.text = getString(R.string.invite_people_to_your_space_desc, spaceName) + + views.inviteByMailButton.debouncedClicks { + } + + views.inviteByMxidButton.debouncedClicks { + val intent = InviteUsersToRoomActivity.getIntent(requireContext(), args.spaceId) + startActivity(intent) + } + + views.inviteByLinkButton.debouncedClicks { + activeSessionHolder.getSafeActiveSession()?.permalinkService()?.createRoomPermalink(args.spaceId)?.let { permalink -> + startSharePlainTextIntent( + fragment = this, + activityResultLauncher = null, + chooserTitle = getString(R.string.share_by_text), + text = getString(R.string.share_space_link_message, spaceName, permalink), + extraTitle = getString(R.string.share_space_link_message, spaceName, permalink) + ) + } + } + + views.skipButton.debouncedClicks { + dismiss() + } + } + + companion object { + + const val EXTRA_ARGS = "EXTRA_ARGS" + + fun show(fragmentManager: FragmentManager, spaceId: String): ShareSpaceBottomSheet { + return ShareSpaceBottomSheet().apply { + isCancelable = true + arguments = Bundle().apply { + this.putParcelable(EXTRA_ARGS, ShareSpaceBottomSheet.Args(spaceId = spaceId)) + } + }.also { + it.show(fragmentManager, ShareSpaceBottomSheet::class.java.name) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpaceCreationActivity.kt b/vector/src/main/java/im/vector/app/features/spaces/SpaceCreationActivity.kt new file mode 100644 index 00000000000..ed8b85f5874 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/spaces/SpaceCreationActivity.kt @@ -0,0 +1,162 @@ +/* + * 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.spaces + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.viewModel +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.di.ScreenComponent +import im.vector.app.core.extensions.toMvRxBundle +import im.vector.app.core.platform.SimpleFragmentActivity +import im.vector.app.features.spaces.create.ChooseSpaceTypeFragment +import im.vector.app.features.spaces.create.CreateSpaceAction +import im.vector.app.features.spaces.create.CreateSpaceDefaultRoomsFragment +import im.vector.app.features.spaces.create.CreateSpaceDetailsFragment +import im.vector.app.features.spaces.create.CreateSpaceEvents +import im.vector.app.features.spaces.create.CreateSpaceState +import im.vector.app.features.spaces.create.CreateSpaceViewModel +import javax.inject.Inject + +class SpaceCreationActivity : SimpleFragmentActivity(), CreateSpaceViewModel.Factory { + + @Inject lateinit var viewModelFactory: CreateSpaceViewModel.Factory + + override fun injectWith(injector: ScreenComponent) { + super.injectWith(injector) + injector.inject(this) + } + + val viewModel: CreateSpaceViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (isFirstCreation()) { + when (withState(viewModel) { it.step }) { + CreateSpaceState.Step.ChooseType -> { + navigateToFragment(ChooseSpaceTypeFragment::class.java) + } + CreateSpaceState.Step.SetDetails -> { + navigateToFragment(ChooseSpaceTypeFragment::class.java) + } + CreateSpaceState.Step.AddRooms -> TODO() + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressed() + return true + } + return super.onOptionsItemSelected(item) + } + + override fun initUiAndData() { + super.initUiAndData() + viewModel.subscribe(this) { + renderState(it) + } + + viewModel.observeViewEvents { + when (it) { + CreateSpaceEvents.NavigateToDetails -> { + navigateToFragment(CreateSpaceDetailsFragment::class.java) + } + CreateSpaceEvents.NavigateToChooseType -> { + navigateToFragment(ChooseSpaceTypeFragment::class.java) + } + CreateSpaceEvents.Dismiss -> { + finish() + } + CreateSpaceEvents.NavigateToAddRooms -> { + navigateToFragment(CreateSpaceDefaultRoomsFragment::class.java) + } + is CreateSpaceEvents.ShowModalError -> { + hideWaitingView() + AlertDialog.Builder(this) + .setMessage(it.errorMessage) + .setPositiveButton(getString(R.string.ok), null) + .show() + } + is CreateSpaceEvents.FinishSuccess -> { + setResult(RESULT_OK, Intent().apply { + putExtra(RESULT_DATA_CREATED_SPACE_ID, it.spaceId) + putExtra(RESULT_DATA_DEFAULT_ROOM_ID, it.defaultRoomId) + }) + finish() + } + CreateSpaceEvents.HideModalLoading -> { + hideWaitingView() + } + } + } + } + + private fun navigateToFragment(fragmentClass: Class) { + val frag = supportFragmentManager.findFragmentByTag(fragmentClass.name) ?: createFragment(fragmentClass, Bundle().toMvRxBundle()) + supportFragmentManager.beginTransaction() + .setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) + .replace(R.id.container, + frag, + fragmentClass.name + ) + .commit() + } + + override fun onBackPressed() { + viewModel.handle(CreateSpaceAction.OnBackPressed) + } + + private fun renderState(state: CreateSpaceState) { + val titleRes = when (state.step) { + CreateSpaceState.Step.ChooseType -> R.string.activity_create_space_title + CreateSpaceState.Step.SetDetails -> R.string.your_public_space + CreateSpaceState.Step.AddRooms -> R.string.your_public_space + } + supportActionBar?.let { + it.title = getString(titleRes) + } ?: run { + setTitle(getString(titleRes)) + } + + if (state.creationResult is Loading) { + showWaitingView(getString(R.string.create_spaces_loading_message)) + } + } + + companion object { + + const val RESULT_DATA_CREATED_SPACE_ID = "RESULT_DATA_CREATED_SPACE_ID" + const val RESULT_DATA_DEFAULT_ROOM_ID = "RESULT_DATA_DEFAULT_ROOM_ID" + + fun newIntent(context: Context): Intent { + return Intent(context, SpaceCreationActivity::class.java).apply { + // putExtra(MvRx.KEY_ARG, SpaceDirectoryArgs(spaceId)) + } + } + } + + override fun create(initialState: CreateSpaceState): CreateSpaceViewModel = viewModelFactory.create(initialState) +} diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpaceListFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/SpaceListFragment.kt index e14b920b2cc..f2ef3b84a03 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpaceListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpaceListFragment.kt @@ -57,6 +57,7 @@ class SpaceListFragment @Inject constructor( when (it) { is SpaceListViewEvents.OpenSpaceSummary -> sharedActionViewModel.post(HomeActivitySharedAction.OpenSpacePreview(it.id)) is SpaceListViewEvents.OpenSpace -> sharedActionViewModel.post(HomeActivitySharedAction.OpenGroup) + is SpaceListViewEvents.AddSpace -> sharedActionViewModel.post(HomeActivitySharedAction.AddSpace) }.exhaustive } } @@ -78,4 +79,12 @@ class SpaceListFragment @Inject constructor( override fun onSpaceSelected(spaceSummary: SpaceSummary) { viewModel.handle(SpaceListAction.SelectSpace(spaceSummary)) } + + override fun onLeaveSpace(spaceSummary: SpaceSummary) { + viewModel.handle(SpaceListAction.LeaveSpace(spaceSummary)) + } + + override fun onAddSpaceSelected() { + viewModel.handle(SpaceListAction.AddSpace) + } } diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpaceSummaryController.kt b/vector/src/main/java/im/vector/app/features/spaces/SpaceSummaryController.kt index 29d48b4cd17..0402beb428f 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpaceSummaryController.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpaceSummaryController.kt @@ -19,8 +19,10 @@ package im.vector.app.features.spaces import com.airbnb.epoxy.EpoxyController import im.vector.app.R import im.vector.app.core.resources.StringProvider +import im.vector.app.core.ui.list.genericButtonItem import im.vector.app.core.ui.list.genericFooterItem import im.vector.app.core.ui.list.genericItemHeader +import im.vector.app.core.utils.DebouncedClickListener import im.vector.app.features.grouplist.homeSpaceSummaryItem import im.vector.app.features.home.AvatarRenderer import org.matrix.android.sdk.api.session.room.model.Membership @@ -69,6 +71,7 @@ class SpaceSummaryController @Inject constructor( matrixItem(it.toMatrixItem()) selected(false) listener { callback?.onSpaceSelected(it) } +// lea { callback?.onSpaceSelected(it) } } } genericFooterItem { @@ -99,13 +102,25 @@ class SpaceSummaryController @Inject constructor( id(groupSummary.spaceId) matrixItem(groupSummary.toMatrixItem()) selected(isSelected) + onLeave { callback?.onLeaveSpace(groupSummary) } listener { callback?.onSpaceSelected(groupSummary) } } } } + + // Temporary item to create a new Space (will move with final design) + + genericButtonItem { + id("create") + text(stringProvider.getString(R.string.add_space)) + iconRes(R.drawable.ic_add_black) + buttonClickAction(DebouncedClickListener({ callback?.onAddSpaceSelected() })) + } } interface Callback { fun onSpaceSelected(spaceSummary: SpaceSummary) + fun onLeaveSpace(spaceSummary: SpaceSummary) + fun onAddSpaceSelected() } } diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpaceSummaryItem.kt b/vector/src/main/java/im/vector/app/features/spaces/SpaceSummaryItem.kt index bf3a47461f6..3f1971d9dc9 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpaceSummaryItem.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpaceSummaryItem.kt @@ -18,12 +18,14 @@ package im.vector.app.features.spaces import android.widget.ImageView import android.widget.TextView +import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.platform.CheckableConstraintLayout +import im.vector.app.core.utils.DebouncedClickListener import im.vector.app.features.home.AvatarRenderer import org.matrix.android.sdk.api.util.MatrixItem @@ -34,12 +36,22 @@ abstract class SpaceSummaryItem : VectorEpoxyModel() { @EpoxyAttribute lateinit var matrixItem: MatrixItem @EpoxyAttribute var selected: Boolean = false @EpoxyAttribute var listener: (() -> Unit)? = null + @EpoxyAttribute var onLeave: (() -> Unit)? = null override fun bind(holder: Holder) { super.bind(holder) holder.rootView.setOnClickListener { listener?.invoke() } holder.groupNameView.text = matrixItem.displayName holder.rootView.isChecked = selected + if (onLeave != null) { + holder.leaveView.setOnClickListener( + DebouncedClickListener({ _ -> + onLeave?.invoke() + }) + ) + } else { + holder.leaveView.isVisible = false + } avatarRenderer.renderSpace(matrixItem, holder.avatarImageView) } @@ -52,5 +64,6 @@ abstract class SpaceSummaryItem : VectorEpoxyModel() { val avatarImageView by bind(R.id.groupAvatarImageView) val groupNameView by bind(R.id.groupNameView) val rootView by bind(R.id.itemGroupLayout) + val leaveView by bind(R.id.groupTmpLeave) } } diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpacesListViewModel.kt b/vector/src/main/java/im/vector/app/features/spaces/SpacesListViewModel.kt index cbfa760f563..28bc358e361 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpacesListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpacesListViewModel.kt @@ -16,6 +16,7 @@ package im.vector.app.features.spaces +import androidx.lifecycle.viewModelScope import arrow.core.Option import com.airbnb.mvrx.Async import com.airbnb.mvrx.FragmentViewModelContext @@ -34,18 +35,23 @@ import im.vector.app.core.resources.StringProvider import im.vector.app.features.grouplist.SelectedSpaceDataSource import io.reactivex.Observable import io.reactivex.functions.BiFunction +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams import org.matrix.android.sdk.api.session.space.SpaceSummary +import org.matrix.android.sdk.internal.util.awaitCallback import org.matrix.android.sdk.rx.rx const val ALL_COMMUNITIES_GROUP_ID = "+ALL_COMMUNITIES_GROUP_ID" sealed class SpaceListAction : VectorViewModelAction { data class SelectSpace(val spaceSummary: SpaceSummary) : SpaceListAction() + data class LeaveSpace(val spaceSummary: SpaceSummary) : SpaceListAction() + object AddSpace : SpaceListAction() } /** @@ -54,6 +60,7 @@ sealed class SpaceListAction : VectorViewModelAction { sealed class SpaceListViewEvents : VectorViewEvents { object OpenSpace : SpaceListViewEvents() data class OpenSpaceSummary(val id: String) : SpaceListViewEvents() + object AddSpace : SpaceListViewEvents() } data class SpaceListViewState( @@ -84,8 +91,20 @@ class SpacesListViewModel @AssistedInject constructor(@Assisted initialState: Sp private var currentGroupId = "" init { - observeGroupSummaries() + observeSpaceSummaries() observeSelectionState() + selectedSpaceDataSource + .observe() + .subscribe { + if (currentGroupId != it.orNull()?.spaceId) { + setState { + copy( + selectedSpace = it.orNull() + ) + } + } + } + .disposeOnClear() } private fun observeSelectionState() { @@ -110,10 +129,12 @@ class SpacesListViewModel @AssistedInject constructor(@Assisted initialState: Sp override fun handle(action: SpaceListAction) { when (action) { is SpaceListAction.SelectSpace -> handleSelectSpace(action) + is SpaceListAction.LeaveSpace -> handleLeaveSpace(action) + SpaceListAction.AddSpace -> handleAddSpace() } } - // PRIVATE METHODS ***************************************************************************** +// PRIVATE METHODS ***************************************************************************** private fun handleSelectSpace(action: SpaceListAction.SelectSpace) = withState { state -> // get uptodate version of the space @@ -136,8 +157,22 @@ class SpacesListViewModel @AssistedInject constructor(@Assisted initialState: Sp } } - private fun observeGroupSummaries() { - val roomSummaryQueryParams = roomSummaryQueryParams() { + private fun handleLeaveSpace(action: SpaceListAction.LeaveSpace) { + viewModelScope.launch { + awaitCallback { + tryOrNull("Failed to leave space ${action.spaceSummary.spaceId}") { + session.spaceService().getSpace(action.spaceSummary.spaceId)?.asRoom()?.leave(null, it) + } + } + } + } + + private fun handleAddSpace() { + _viewEvents.post(SpaceListViewEvents.AddSpace) + } + + private fun observeSpaceSummaries() { + val spaceSummaryQueryParams = roomSummaryQueryParams() { memberships = listOf(Membership.JOIN, Membership.INVITE) displayName = QueryStringValue.IsNotEmpty excludeType = listOf(/**RoomType.MESSAGING,$*/ @@ -164,7 +199,7 @@ class SpacesListViewModel @AssistedInject constructor(@Assisted initialState: Sp }, session .rx() - .liveSpaceSummaries(roomSummaryQueryParams), + .liveSpaceSummaries(spaceSummaryQueryParams), BiFunction { allCommunityGroup, communityGroups -> listOf(allCommunityGroup) + communityGroups } diff --git a/vector/src/main/java/im/vector/app/features/spaces/create/ChooseSpaceTypeFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/create/ChooseSpaceTypeFragment.kt new file mode 100644 index 00000000000..b174952ec66 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/spaces/create/ChooseSpaceTypeFragment.kt @@ -0,0 +1,49 @@ +/* + * 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.spaces.create + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.activityViewModel +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.utils.DebouncedClickListener +import im.vector.app.databinding.FragmentSpaceCreateChooseTypeBinding +import javax.inject.Inject + +class ChooseSpaceTypeFragment @Inject constructor( + // private val viewModelFactory: CreateSpaceViewModel.Factory, +) : VectorBaseFragment() { + + private val sharedViewModel: CreateSpaceViewModel by activityViewModel() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?) = + FragmentSpaceCreateChooseTypeBinding.inflate(layoutInflater, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + views.publicButton.setOnClickListener(DebouncedClickListener({ + sharedViewModel.handle(CreateSpaceAction.SetRoomType(SpaceType.Public)) + })) + + views.privateButton.setOnClickListener(DebouncedClickListener({ + // sharedViewModel.handle(CreateSpaceAction.SetRoomType(SpaceType.Private)) + })) + } +} diff --git a/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceDefaultRoomsFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceDefaultRoomsFragment.kt new file mode 100644 index 00000000000..fb1ed8e5f86 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceDefaultRoomsFragment.kt @@ -0,0 +1,60 @@ +/* + * 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.spaces.create + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.activityViewModel +import im.vector.app.core.extensions.configureWith +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.databinding.FragmentSpaceCreateGenericEpoxyFormBinding +import javax.inject.Inject + +class CreateSpaceDefaultRoomsFragment @Inject constructor( + private val epoxyController: SpaceDefaultRoomEpoxyController +) : VectorBaseFragment(), SpaceDefaultRoomEpoxyController.Listener { + + private val sharedViewModel: CreateSpaceViewModel by activityViewModel() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?) = + FragmentSpaceCreateGenericEpoxyFormBinding.inflate(layoutInflater, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + views.recyclerView.configureWith(epoxyController) + epoxyController.listener = this + + sharedViewModel.subscribe(this) { + epoxyController.setData(it) + } + + views.nextButton.debouncedClicks { + sharedViewModel.handle(CreateSpaceAction.NextFromDefaultRooms) + } + } + + override fun onNameChange(index: Int, newName: String) { + sharedViewModel.handle(CreateSpaceAction.DefaultRoomNameChanged(index, newName)) + } + + // ----------------------------- + // Epoxy controller listener methods + // ----------------------------- +} diff --git a/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceDetailsFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceDetailsFragment.kt new file mode 100644 index 00000000000..670876fdf19 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceDetailsFragment.kt @@ -0,0 +1,82 @@ +/* + * 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.spaces.create + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.activityViewModel +import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper +import im.vector.app.core.extensions.configureWith +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.resources.ColorProvider +import im.vector.app.databinding.FragmentSpaceCreateGenericEpoxyFormBinding +import javax.inject.Inject + +class CreateSpaceDetailsFragment @Inject constructor( + private val epoxyController: SpaceDetailEpoxyController, + private val colorProvider: ColorProvider +) : VectorBaseFragment(), SpaceDetailEpoxyController.Listener, + GalleryOrCameraDialogHelper.Listener { + + private val sharedViewModel: CreateSpaceViewModel by activityViewModel() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?) = + FragmentSpaceCreateGenericEpoxyFormBinding.inflate(layoutInflater, container, false) + + private val galleryOrCameraDialogHelper = GalleryOrCameraDialogHelper(this, colorProvider) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + views.recyclerView.configureWith(epoxyController) + epoxyController.listener = this + + sharedViewModel.subscribe(this) { + epoxyController.setData(it) + } + + views.nextButton.debouncedClicks { + sharedViewModel.handle(CreateSpaceAction.NextFromDetails) + } + } + + override fun onImageReady(uri: Uri?) { + sharedViewModel.handle(CreateSpaceAction.SetAvatar(uri)) + } + // ----------------------------- + // Epoxy controller listener methods + // ----------------------------- + + override fun onAvatarDelete() { + sharedViewModel.handle(CreateSpaceAction.SetAvatar(null)) + } + + override fun onAvatarChange() { + galleryOrCameraDialogHelper.show() + } + + override fun onNameChange(newName: String) { + sharedViewModel.handle(CreateSpaceAction.NameChanged(newName)) + } + + override fun onTopicChange(newTopic: String) { + sharedViewModel.handle(CreateSpaceAction.TopicChanged(newTopic)) + } +} diff --git a/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceViewModel.kt b/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceViewModel.kt new file mode 100644 index 00000000000..9988bbe0031 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceViewModel.kt @@ -0,0 +1,261 @@ +/* + * 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.spaces.create + +import android.net.Uri +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.ViewModelContext +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.R +import im.vector.app.core.error.ErrorFormatter +import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.platform.VectorViewEvents +import im.vector.app.core.platform.VectorViewModel +import im.vector.app.core.platform.VectorViewModelAction +import im.vector.app.core.resources.StringProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.session.Session + +data class CreateSpaceState( + val name: String? = null, + val avatarUri: Uri? = null, + val topic: String = "", + val step: Step = Step.ChooseType, + val spaceType: SpaceType? = null, + val nameInlineError: String? = null, + val defaultRooms: Map? = null, + val creationResult: Async = Uninitialized +) : MvRxState { + + enum class Step { + ChooseType, + SetDetails, + AddRooms + } +} + +enum class SpaceType { + Public, + Private +} + +sealed class CreateSpaceAction : VectorViewModelAction { + data class SetRoomType(val type: SpaceType) : CreateSpaceAction() + data class NameChanged(val name: String) : CreateSpaceAction() + data class TopicChanged(val topic: String) : CreateSpaceAction() + data class SetAvatar(val uri: Uri?) : CreateSpaceAction() + object OnBackPressed : CreateSpaceAction() + object NextFromDetails : CreateSpaceAction() + object NextFromDefaultRooms : CreateSpaceAction() + data class DefaultRoomNameChanged(val index: Int, val name: String) : CreateSpaceAction() +} + +sealed class CreateSpaceEvents : VectorViewEvents { + object NavigateToDetails : CreateSpaceEvents() + object NavigateToChooseType : CreateSpaceEvents() + object NavigateToAddRooms : CreateSpaceEvents() + object Dismiss : CreateSpaceEvents() + data class FinishSuccess(val spaceId: String, val defaultRoomId: String?) : CreateSpaceEvents() + data class ShowModalError(val errorMessage: String) : CreateSpaceEvents() + object HideModalLoading : CreateSpaceEvents() +} + +class CreateSpaceViewModel @AssistedInject constructor( + @Assisted initialState: CreateSpaceState, + private val session: Session, + private val stringProvider: StringProvider, + private val createSpaceViewModelTask: CreateSpaceViewModelTask, + private val errorFormatter: ErrorFormatter +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory { + fun create(initialState: CreateSpaceState): CreateSpaceViewModel + } + + companion object : MvRxViewModelFactory { + + override fun create(viewModelContext: ViewModelContext, state: CreateSpaceState): CreateSpaceViewModel? { + val factory = when (viewModelContext) { + is FragmentViewModelContext -> viewModelContext.fragment as? Factory + is ActivityViewModelContext -> viewModelContext.activity as? Factory + } + return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface") + } + + override fun initialState(viewModelContext: ViewModelContext): CreateSpaceState? { + return CreateSpaceState( + defaultRooms = mapOf( + 0 to viewModelContext.activity.getString(R.string.create_spaces_default_public_room_name), + 1 to viewModelContext.activity.getString(R.string.create_spaces_default_public_random_room_name) + ) + ) + } + } + + override fun handle(action: CreateSpaceAction) { + when (action) { + is CreateSpaceAction.SetRoomType -> { + setState { + copy( + step = CreateSpaceState.Step.SetDetails, + spaceType = action.type + ) + } + _viewEvents.post(CreateSpaceEvents.NavigateToDetails) + } + is CreateSpaceAction.NameChanged -> { + setState { + copy( + nameInlineError = null, + name = action.name + ) + } + } + is CreateSpaceAction.TopicChanged -> { + setState { + copy( + topic = action.topic + ) + } + } + CreateSpaceAction.OnBackPressed -> { + handleBackNavigation() + } + CreateSpaceAction.NextFromDetails -> { + handleNextFromDetails() + } + CreateSpaceAction.NextFromDefaultRooms -> { + handleNextFromDefaultRooms() + } + is CreateSpaceAction.DefaultRoomNameChanged -> { + setState { + copy( + defaultRooms = (defaultRooms ?: emptyMap()).toMutableMap().apply { + this[action.index] = action.name + } + ) + } + } + is CreateSpaceAction.SetAvatar -> { + setState { copy(avatarUri = action.uri) } + } + }.exhaustive + } + + private fun handleBackNavigation() = withState { state -> + when (state.step) { + CreateSpaceState.Step.ChooseType -> { + _viewEvents.post(CreateSpaceEvents.Dismiss) + } + CreateSpaceState.Step.SetDetails -> { + setState { + copy( + step = CreateSpaceState.Step.ChooseType + ) + } + _viewEvents.post(CreateSpaceEvents.NavigateToChooseType) + } + CreateSpaceState.Step.AddRooms -> { + setState { + copy( + step = CreateSpaceState.Step.SetDetails + ) + } + _viewEvents.post(CreateSpaceEvents.NavigateToDetails) + } + } + } + + private fun handleNextFromDetails() = withState { state -> + if (state.name.isNullOrBlank()) { + setState { + copy( + nameInlineError = stringProvider.getString(R.string.create_space_error_empty_field_space_name) + ) + } + } else { + setState { + copy( + step = CreateSpaceState.Step.AddRooms + ) + } + _viewEvents.post(CreateSpaceEvents.NavigateToAddRooms) + } + } + + private fun handleNextFromDefaultRooms() = withState { state -> + val spaceName = state.name ?: return@withState + setState { + copy(creationResult = Loading()) + } + viewModelScope.launch(Dispatchers.IO) { + try { + val result = createSpaceViewModelTask.execute( + CreateSpaceTaskParams( + spaceName = spaceName, + spaceTopic = state.topic, + spaceAvatar = state.avatarUri, + isPublic = state.spaceType == SpaceType.Public, + defaultRooms = state.defaultRooms + ?.entries + ?.sortedBy { it.key } + ?.mapNotNull { it.value } ?: emptyList() + ) + ) + when (result) { + is CreateSpaceTaskResult.Success -> { + setState { + copy(creationResult = Success(result.spaceId)) + } + _viewEvents.post(CreateSpaceEvents.FinishSuccess(result.spaceId, result.childIds.firstOrNull())) + } + is CreateSpaceTaskResult.PartialSuccess -> { + // XXX what can we do here? + setState { + copy(creationResult = Success(result.spaceId)) + } + _viewEvents.post(CreateSpaceEvents.FinishSuccess(result.spaceId, result.childIds.firstOrNull())) + } + is CreateSpaceTaskResult.FailedToCreateSpace -> { + setState { + copy(creationResult = Fail(result.failure)) + } + _viewEvents.post(CreateSpaceEvents.ShowModalError(errorFormatter.toHumanReadable(result.failure))) + } + } + } catch (failure: Throwable) { + setState { + copy(creationResult = Fail(failure)) + } + _viewEvents.post(CreateSpaceEvents.ShowModalError(errorFormatter.toHumanReadable(failure))) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceViewModelTask.kt b/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceViewModelTask.kt new file mode 100644 index 00000000000..a565e290f65 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceViewModelTask.kt @@ -0,0 +1,95 @@ +/* + * 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.spaces.create + +import android.net.Uri +import im.vector.app.core.platform.ViewModelTask +import im.vector.app.core.resources.StringProvider +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomPreset +import org.matrix.android.sdk.internal.util.awaitCallback +import timber.log.Timber +import javax.inject.Inject + +sealed class CreateSpaceTaskResult { + + data class Success(val spaceId: String, val childIds: List) : CreateSpaceTaskResult() + + data class PartialSuccess(val spaceId: String, val childIds: List, val failedRooms: Map) : CreateSpaceTaskResult() + + class FailedToCreateSpace(val failure: Throwable) : CreateSpaceTaskResult() +} + +data class CreateSpaceTaskParams( + val spaceName: String, + val spaceTopic: String?, + val spaceAvatar: Uri? = null, + val isPublic: Boolean, + val defaultRooms: List = emptyList() +) + +class CreateSpaceViewModelTask @Inject constructor( + private val session: Session, + private val stringProvider: StringProvider +) : ViewModelTask { + + override suspend fun execute(params: CreateSpaceTaskParams): CreateSpaceTaskResult { + val spaceID = try { + session.spaceService().createSpace(params.spaceName, params.spaceTopic, params.spaceAvatar, params.isPublic) + } catch (failure: Throwable) { + return CreateSpaceTaskResult.FailedToCreateSpace(failure) + } + + val createdSpace = session.spaceService().getSpace(spaceID) + + val childErrors = mutableMapOf() + val childIds = mutableListOf() + if (params.isPublic) { + params.defaultRooms + .filter { it.isNotBlank() } + .forEach { roomName -> + try { + val roomId = try { + awaitCallback { + session.createRoom(CreateRoomParams().apply { + this.name = roomName + this.preset = CreateRoomPreset.PRESET_PUBLIC_CHAT + }, it) + } + } catch (timeout: CreateRoomFailure.CreatedWithTimeout) { + // we ignore that? + timeout.roomID + } + val via = session.sessionParams.homeServerHost?.let { listOf(it) } ?: emptyList() + createdSpace!!.addChildren(roomId, via, null, true) + childIds.add(roomId) + } catch (failure: Throwable) { + Timber.d("Failed to create child room in $spaceID") + childErrors[roomName] = failure + } + } + } + + return if (childErrors.isEmpty()) { + CreateSpaceTaskResult.Success(spaceID, childIds) + } else { + CreateSpaceTaskResult.PartialSuccess(spaceID, childIds, childErrors) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/spaces/create/SpaceDefaultRoomEpoxyController.kt b/vector/src/main/java/im/vector/app/features/spaces/create/SpaceDefaultRoomEpoxyController.kt new file mode 100644 index 00000000000..a1e46457e3b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/spaces/create/SpaceDefaultRoomEpoxyController.kt @@ -0,0 +1,93 @@ +/* + * 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.spaces.create + +import com.airbnb.epoxy.TypedEpoxyController +import com.google.android.material.textfield.TextInputLayout +import im.vector.app.R +import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.resources.StringProvider +import im.vector.app.core.ui.list.GenericItem +import im.vector.app.core.ui.list.genericFooterItem +import im.vector.app.features.form.formEditTextItem +import javax.inject.Inject + +class SpaceDefaultRoomEpoxyController @Inject constructor( + private val stringProvider: StringProvider, + private val colorProvider: ColorProvider +) : TypedEpoxyController() { + + var listener: Listener? = null + + override fun buildModels(data: CreateSpaceState?) { + genericFooterItem { + id("info_help_header") + style(GenericItem.STYLE.BIG_TEXT) + text(stringProvider.getString(R.string.create_spaces_room_public_header, data?.name)) + textColor(colorProvider.getColorFromAttribute(R.attr.riot_primary_text_color)) + } + + genericFooterItem { + id("info_help") + text(stringProvider.getString(R.string.create_spaces_room_public_header_desc)) + textColor(colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary)) + } + + formEditTextItem { + id("roomName1") + enabled(true) + value(data?.defaultRooms?.get(0)) + hint(stringProvider.getString(R.string.create_room_name_section)) + endIconMode(TextInputLayout.END_ICON_CLEAR_TEXT) + showBottomSeparator(false) + onTextChange { text -> + listener?.onNameChange(0, text) + } + } + + formEditTextItem { + id("roomName2") + enabled(true) + value(data?.defaultRooms?.get(1)) + hint(stringProvider.getString(R.string.create_room_name_section)) + endIconMode(TextInputLayout.END_ICON_CLEAR_TEXT) + showBottomSeparator(false) + onTextChange { text -> + listener?.onNameChange(1, text) + } + } + + formEditTextItem { + id("roomName3") + enabled(true) + value(data?.defaultRooms?.get(2)) + hint(stringProvider.getString(R.string.create_room_name_section)) + endIconMode(TextInputLayout.END_ICON_CLEAR_TEXT) + showBottomSeparator(false) + onTextChange { text -> + listener?.onNameChange(2, text) + } + } + } + + interface Listener { + // fun onAvatarDelete() +// fun onAvatarChange() + fun onNameChange(index: Int, newName: String) +// fun onTopicChange(newTopic: String) + } +} diff --git a/vector/src/main/java/im/vector/app/features/spaces/create/SpaceDetailEpoxyController.kt b/vector/src/main/java/im/vector/app/features/spaces/create/SpaceDetailEpoxyController.kt new file mode 100644 index 00000000000..357b741ff42 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/spaces/create/SpaceDetailEpoxyController.kt @@ -0,0 +1,90 @@ +/* + * 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.spaces.create + +import com.airbnb.epoxy.TypedEpoxyController +import im.vector.app.R +import im.vector.app.core.resources.StringProvider +import im.vector.app.core.ui.list.genericFooterItem +import im.vector.app.features.form.formEditTextItem +import im.vector.app.features.form.formEditableSquareAvatarItem +import im.vector.app.features.form.formMultiLineEditTextItem +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.util.MatrixItem +import javax.inject.Inject + +class SpaceDetailEpoxyController @Inject constructor( + private val stringProvider: StringProvider, + private val avatarRenderer: AvatarRenderer +) : TypedEpoxyController() { + + var listener: Listener? = null + + override fun buildModels(data: CreateSpaceState?) { + genericFooterItem { + id("info_help") + text( + if (data?.spaceType == SpaceType.Public) { + stringProvider.getString(R.string.create_spaces_details_public_header) + } else { + stringProvider.getString(R.string.create_spaces_details_private_header) + } + ) + } + + formEditableSquareAvatarItem { + id("avatar") + enabled(true) + imageUri(data?.avatarUri) + avatarRenderer(avatarRenderer) + matrixItem(data?.name?.let { MatrixItem.RoomItem("", it, null).takeIf { !it.displayName.isNullOrBlank() } }) + clickListener { listener?.onAvatarChange() } + deleteListener { listener?.onAvatarDelete() } + } + + formEditTextItem { + id("name") + enabled(true) + value(data?.name) + hint(stringProvider.getString(R.string.create_room_name_hint)) + showBottomSeparator(false) + errorMessage(data?.nameInlineError) + onTextChange { text -> + listener?.onNameChange(text) + } + } + + formMultiLineEditTextItem { + id("topic") + enabled(true) + value(data?.topic) + hint(stringProvider.getString(R.string.create_room_topic_hint)) + showBottomSeparator(false) + textSizeSp(15) + onTextChange { text -> + listener?.onTopicChange(text) + } + } + } + + interface Listener { + fun onAvatarDelete() + fun onAvatarChange() + fun onNameChange(newName: String) + fun onTopicChange(newTopic: String) + } +} diff --git a/vector/src/main/java/im/vector/app/features/spaces/create/WizardButtonView.kt b/vector/src/main/java/im/vector/app/features/spaces/create/WizardButtonView.kt new file mode 100644 index 00000000000..a6b9b1978ac --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/spaces/create/WizardButtonView.kt @@ -0,0 +1,102 @@ +/* + * 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.spaces.create + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import android.os.Build +import android.util.AttributeSet +import android.util.TypedValue +import androidx.appcompat.content.res.AppCompatResources.getDrawable +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.withStyledAttributes +import im.vector.app.R +import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.databinding.ViewSpaceTypeButtonBinding + +class WizardButtonView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) + : ConstraintLayout(context, attrs, defStyle) { + + private val views: ViewSpaceTypeButtonBinding + + var title: String? = null + set(value) { + if (value != title) { + field = value + views.title.setTextOrHide(value) + } + } + + var subTitle: String? = null + set(value) { + if (value != subTitle) { + field = value + views.subTitle.setTextOrHide(value) + } + } + + var icon: Drawable? = null + set(value) { + if (value != icon) { + field = value + views.buttonImageView.setImageDrawable(value) + } + } + + private var tint: Int? = null + set(value) { + field = value + if (value != null) { + views.buttonImageView.imageTintList = ColorStateList.valueOf(value) + } + } + +// var action: (() -> Unit)? = null + + init { + inflate(context, R.layout.view_space_type_button, this) + views = ViewSpaceTypeButtonBinding.bind(this) + + views.subTitle.setTextOrHide(null) + + if (isInEditMode) { + title = "Title" + subTitle = "This is doing something" + } + + context.withStyledAttributes(attrs, R.styleable.WizardButtonView) { + title = getString(R.styleable.WizardButtonView_title) + subTitle = getString(R.styleable.WizardButtonView_subTitle) + icon = getDrawable(R.styleable.WizardButtonView_icon) + tint = getColor(R.styleable.WizardButtonView_iconTint, -1) + .takeIf { it != -1 } + } + + val outValue = TypedValue() + context.theme.resolveAttribute(android.R.attr.selectableItemBackground, outValue, true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + this.foreground = getDrawable(context, outValue.resourceId) + } + +// views.content.isClickable = true +// views.content.isFocusable = true +// views.content.setOnClickListener { +// action?.invoke() +// } + } +} diff --git a/vector/src/main/res/drawable/ic_camera_plain.xml b/vector/src/main/res/drawable/ic_camera_plain.xml new file mode 100644 index 00000000000..56d55c9da19 --- /dev/null +++ b/vector/src/main/res/drawable/ic_camera_plain.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/drawable/ic_mail.xml b/vector/src/main/res/drawable/ic_mail.xml new file mode 100644 index 00000000000..80d25166b08 --- /dev/null +++ b/vector/src/main/res/drawable/ic_mail.xml @@ -0,0 +1,21 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_public_room.xml b/vector/src/main/res/drawable/ic_public_room.xml new file mode 100644 index 00000000000..15208988312 --- /dev/null +++ b/vector/src/main/res/drawable/ic_public_room.xml @@ -0,0 +1,13 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_room_private.xml b/vector/src/main/res/drawable/ic_room_private.xml new file mode 100644 index 00000000000..cacdf15a3b8 --- /dev/null +++ b/vector/src/main/res/drawable/ic_room_private.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/drawable/ic_share_link.xml b/vector/src/main/res/drawable/ic_share_link.xml new file mode 100644 index 00000000000..d4eceb3b9a1 --- /dev/null +++ b/vector/src/main/res/drawable/ic_share_link.xml @@ -0,0 +1,12 @@ + + + diff --git a/vector/src/main/res/layout/bottom_sheet_matrix_to_card.xml b/vector/src/main/res/layout/bottom_sheet_matrix_to_card.xml index 37f96337286..652ec8421d2 100644 --- a/vector/src/main/res/layout/bottom_sheet_matrix_to_card.xml +++ b/vector/src/main/res/layout/bottom_sheet_matrix_to_card.xml @@ -1,6 +1,5 @@ - - - - - - - - - - - - + android:layout_height="wrap_content" /> - + diff --git a/vector/src/main/res/layout/bottom_sheet_space_invite.xml b/vector/src/main/res/layout/bottom_sheet_space_invite.xml new file mode 100644 index 00000000000..c8d29b2f7d2 --- /dev/null +++ b/vector/src/main/res/layout/bottom_sheet_space_invite.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_matrix_to_room_space_card.xml b/vector/src/main/res/layout/fragment_matrix_to_room_space_card.xml new file mode 100644 index 00000000000..6f49f158428 --- /dev/null +++ b/vector/src/main/res/layout/fragment_matrix_to_room_space_card.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_matrix_to_user_card.xml b/vector/src/main/res/layout/fragment_matrix_to_user_card.xml new file mode 100644 index 00000000000..03599779f1d --- /dev/null +++ b/vector/src/main/res/layout/fragment_matrix_to_user_card.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_space_create_choose_type.xml b/vector/src/main/res/layout/fragment_space_create_choose_type.xml new file mode 100644 index 00000000000..ddf61aabf8d --- /dev/null +++ b/vector/src/main/res/layout/fragment_space_create_choose_type.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_space_create_generic_epoxy_form.xml b/vector/src/main/res/layout/fragment_space_create_generic_epoxy_form.xml new file mode 100644 index 00000000000..3097664e026 --- /dev/null +++ b/vector/src/main/res/layout/fragment_space_create_generic_epoxy_form.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_editable_square_avatar.xml b/vector/src/main/res/layout/item_editable_square_avatar.xml new file mode 100644 index 00000000000..3112b36a65b --- /dev/null +++ b/vector/src/main/res/layout/item_editable_square_avatar.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/item_space.xml b/vector/src/main/res/layout/item_space.xml index 9cd07f32155..25b685b9990 100644 --- a/vector/src/main/res/layout/item_space.xml +++ b/vector/src/main/res/layout/item_space.xml @@ -35,11 +35,26 @@ android:textSize="15sp" android:textStyle="bold" app:layout_constraintBottom_toTopOf="@+id/groupBottomSeparator" - app:layout_constraintEnd_toStartOf="@+id/groupAvatarChevron" + app:layout_constraintEnd_toStartOf="@+id/groupTmpLeave" app:layout_constraintStart_toEndOf="@+id/groupAvatarImageView" app:layout_constraintTop_toTopOf="parent" tools:text="@tools:sample/lorem/random" /> + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/values/attrs.xml b/vector/src/main/res/values/attrs.xml index 0b9852634d6..506e8187d8f 100644 --- a/vector/src/main/res/values/attrs.xml +++ b/vector/src/main/res/values/attrs.xml @@ -65,8 +65,10 @@ + + - + @@ -79,4 +81,12 @@ + + + + + + + + diff --git a/vector/src/main/res/values/ids.xml b/vector/src/main/res/values/ids.xml new file mode 100644 index 00000000000..099d6f4279a --- /dev/null +++ b/vector/src/main/res/values/ids.xml @@ -0,0 +1,3 @@ + + + diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 741eba373c0..5fb0366359e 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -2888,4 +2888,36 @@ Create a Space Join the Space with the given id Leave room with given id (or current room if null) + + Add Space + Your public space + Spaces are a new way to group rooms and people + What type of space do you want to create? + To join an existing space, you need an invite. + Public + Open to anyone, best for communities + Private + Invite only, best for yourself or teams + Create a space + Add some details to help it stand out. You can change these at any point. + Add some details to help people identify it. You can change these at any point. + Give it a name to continue. + What are some discussions you want to have in %s? + We’ll create rooms for them, and auto-join everyone. You can add more later too. + General + Random + Creating Space… + Invite people to your space + It’s just you at the moment. %s will be even better with others. + Invite by email + Invite by username + Share link + + Join my space %1$s %2$s + Skip for now + Join Space + Join Anyway + This alias is not accessible at this time.\nTry again later, or ask a room admin to check if you have access. + +