diff --git a/changelog.d/5525.wip b/changelog.d/5525.wip new file mode 100644 index 0000000000..c4ac0ff008 --- /dev/null +++ b/changelog.d/5525.wip @@ -0,0 +1 @@ +Create DM room only on first message - Design implementation & debug feature flag diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt index 5dfb8961e3..90f3f323b2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt @@ -40,6 +40,18 @@ interface RoomService { */ suspend fun createRoom(createRoomParams: CreateRoomParams): String + /** + * Create a room locally. + * This room will not be synchronized with the server and will not come back from the sync, so all the events related to this room will be generated + * locally. + */ + suspend fun createLocalRoom(createRoomParams: CreateRoomParams): String + + /** + * Delete a local room with all its related events. + */ + suspend fun deleteLocalRoom(roomId: String) + /** * Create a direct room asynchronously. This is a facility method to create a direct room with the necessary parameters. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/localecho/RoomLocalEcho.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/localecho/RoomLocalEcho.kt new file mode 100644 index 0000000000..7ef0d63924 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/localecho/RoomLocalEcho.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2022 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.api.session.room.model.localecho + +import java.util.UUID + +object RoomLocalEcho { + + private const val PREFIX = "!local." + + /** + * Tell whether the provider room id is a local id. + */ + fun isLocalEchoId(roomId: String) = roomId.startsWith(PREFIX) + + internal fun createLocalEchoId() = "${PREFIX}${UUID.randomUUID()}" +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/CurrentStateEventEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/CurrentStateEventEntityQueries.kt index e0dbf2eee8..e17d07c584 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/CurrentStateEventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/CurrentStateEventEntityQueries.kt @@ -23,13 +23,20 @@ import io.realm.kotlin.createObject import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields +internal fun CurrentStateEventEntity.Companion.whereRoomId( + realm: Realm, + roomId: String +): RealmQuery { + return realm.where(CurrentStateEventEntity::class.java) + .equalTo(CurrentStateEventEntityFields.ROOM_ID, roomId) +} + internal fun CurrentStateEventEntity.Companion.whereType( realm: Realm, roomId: String, type: String ): RealmQuery { - return realm.where(CurrentStateEventEntity::class.java) - .equalTo(CurrentStateEventEntityFields.ROOM_ID, roomId) + return whereRoomId(realm = realm, roomId = roomId) .equalTo(CurrentStateEventEntityFields.TYPE, type) } 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 5e6d052443..6fd4f752a8 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 @@ -43,7 +43,9 @@ import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntityFie import org.matrix.android.sdk.internal.di.SessionDatabase 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.CreateLocalRoomTask import org.matrix.android.sdk.internal.session.room.create.CreateRoomTask +import org.matrix.android.sdk.internal.session.room.delete.DeleteLocalRoomTask import org.matrix.android.sdk.internal.session.room.membership.RoomChangeMembershipStateDataSource import org.matrix.android.sdk.internal.session.room.membership.RoomMemberHelper import org.matrix.android.sdk.internal.session.room.membership.joining.JoinRoomTask @@ -60,6 +62,8 @@ import javax.inject.Inject internal class DefaultRoomService @Inject constructor( @SessionDatabase private val monarchy: Monarchy, private val createRoomTask: CreateRoomTask, + private val createLocalRoomTask: CreateLocalRoomTask, + private val deleteLocalRoomTask: DeleteLocalRoomTask, private val joinRoomTask: JoinRoomTask, private val markAllRoomsReadTask: MarkAllRoomsReadTask, private val updateBreadcrumbsTask: UpdateBreadcrumbsTask, @@ -78,6 +82,14 @@ internal class DefaultRoomService @Inject constructor( return createRoomTask.executeRetry(createRoomParams, 3) } + override suspend fun createLocalRoom(createRoomParams: CreateRoomParams): String { + return createLocalRoomTask.execute(createRoomParams) + } + + override suspend fun deleteLocalRoom(roomId: String) { + deleteLocalRoomTask.execute(DeleteLocalRoomTask.Params(roomId)) + } + override fun getRoom(roomId: String): Room? { return roomGetter.getRoom(roomId) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt index c4d37d124b..e1dd22a211 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt @@ -43,8 +43,12 @@ import org.matrix.android.sdk.internal.session.room.alias.DefaultGetRoomLocalAli 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.alias.GetRoomLocalAliasesTask +import org.matrix.android.sdk.internal.session.room.create.CreateLocalRoomTask import org.matrix.android.sdk.internal.session.room.create.CreateRoomTask +import org.matrix.android.sdk.internal.session.room.create.DefaultCreateLocalRoomTask import org.matrix.android.sdk.internal.session.room.create.DefaultCreateRoomTask +import org.matrix.android.sdk.internal.session.room.delete.DefaultDeleteLocalRoomTask +import org.matrix.android.sdk.internal.session.room.delete.DeleteLocalRoomTask import org.matrix.android.sdk.internal.session.room.directory.DefaultGetPublicRoomTask import org.matrix.android.sdk.internal.session.room.directory.DefaultGetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.DefaultSetRoomDirectoryVisibilityTask @@ -204,6 +208,12 @@ internal abstract class RoomModule { @Binds abstract fun bindCreateRoomTask(task: DefaultCreateRoomTask): CreateRoomTask + @Binds + abstract fun bindCreateLocalRoomTask(task: DefaultCreateLocalRoomTask): CreateLocalRoomTask + + @Binds + abstract fun bindDeleteLocalRoomTask(task: DefaultDeleteLocalRoomTask): DeleteLocalRoomTask + @Binds abstract fun bindGetPublicRoomTask(task: DefaultGetPublicRoomTask): GetPublicRoomTask diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt new file mode 100644 index 0000000000..d57491a4c8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt @@ -0,0 +1,267 @@ +/* + * Copyright 2022 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.create + +import com.zhuinden.monarchy.Monarchy +import io.realm.Realm +import io.realm.RealmConfiguration +import io.realm.kotlin.createObject +import kotlinx.coroutines.TimeoutCancellationException +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.events.model.Content +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.LocalEcho +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure +import org.matrix.android.sdk.api.session.room.model.GuestAccess +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent +import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho +import org.matrix.android.sdk.api.session.room.send.SendState +import org.matrix.android.sdk.api.session.sync.model.RoomSyncSummary +import org.matrix.android.sdk.api.session.user.UserService +import org.matrix.android.sdk.api.session.user.model.User +import org.matrix.android.sdk.internal.database.awaitNotEmptyResult +import org.matrix.android.sdk.internal.database.helper.addTimelineEvent +import org.matrix.android.sdk.internal.database.mapper.asDomain +import org.matrix.android.sdk.internal.database.mapper.toEntity +import org.matrix.android.sdk.internal.database.model.ChunkEntity +import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity +import org.matrix.android.sdk.internal.database.model.EventInsertType +import org.matrix.android.sdk.internal.database.model.RoomEntity +import org.matrix.android.sdk.internal.database.model.RoomMembersLoadStatusType +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore +import org.matrix.android.sdk.internal.database.query.getOrCreate +import org.matrix.android.sdk.internal.database.query.getOrNull +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent +import org.matrix.android.sdk.internal.session.room.membership.RoomMemberEventHandler +import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryUpdater +import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection +import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.awaitTransaction +import org.matrix.android.sdk.internal.util.time.Clock +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +internal interface CreateLocalRoomTask : Task + +internal class DefaultCreateLocalRoomTask @Inject constructor( + @UserId private val userId: String, + @SessionDatabase private val monarchy: Monarchy, + private val roomMemberEventHandler: RoomMemberEventHandler, + private val roomSummaryUpdater: RoomSummaryUpdater, + @SessionDatabase private val realmConfiguration: RealmConfiguration, + private val createRoomBodyBuilder: CreateRoomBodyBuilder, + private val userService: UserService, + private val clock: Clock, +) : CreateLocalRoomTask { + + override suspend fun execute(params: CreateRoomParams): String { + val createRoomBody = createRoomBodyBuilder.build(params.withDefault()) + val roomId = RoomLocalEcho.createLocalEchoId() + monarchy.awaitTransaction { realm -> + createLocalRoomEntity(realm, roomId, createRoomBody) + createLocalRoomSummaryEntity(realm, roomId, createRoomBody) + } + + // Wait for room to be created in DB + try { + awaitNotEmptyResult(realmConfiguration, TimeUnit.MINUTES.toMillis(1L)) { realm -> + realm.where(RoomSummaryEntity::class.java) + .equalTo(RoomSummaryEntityFields.ROOM_ID, roomId) + .equalTo(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.JOIN.name) + } + } catch (exception: TimeoutCancellationException) { + throw CreateRoomFailure.CreatedWithTimeout(roomId) + } + + return roomId + } + + /** + * Create a local room entity from the given room creation params. + * This will also generate and store in database the chunk and the events related to the room params in order to retrieve and display the local room. + */ + private suspend fun createLocalRoomEntity(realm: Realm, roomId: String, createRoomBody: CreateRoomBody) { + RoomEntity.getOrCreate(realm, roomId).apply { + membership = Membership.JOIN + chunks.add(createLocalRoomChunk(realm, roomId, createRoomBody)) + membersLoadStatus = RoomMembersLoadStatusType.LOADED + } + } + + private fun createLocalRoomSummaryEntity(realm: Realm, roomId: String, createRoomBody: CreateRoomBody) { + val otherUserId = createRoomBody.getDirectUserId() + if (otherUserId != null) { + RoomSummaryEntity.getOrCreate(realm, roomId).apply { + isDirect = true + directUserId = otherUserId + } + } + roomSummaryUpdater.update( + realm = realm, + roomId = roomId, + membership = Membership.JOIN, + roomSummary = RoomSyncSummary( + heroes = createRoomBody.invitedUserIds.orEmpty().take(5), + joinedMembersCount = 1, + invitedMembersCount = createRoomBody.invitedUserIds?.size ?: 0 + ), + updateMembers = !createRoomBody.invitedUserIds.isNullOrEmpty() + ) + } + + /** + * Create a single chunk containing the necessary events to display the local room. + * + * @param realm the current instance of realm + * @param roomId the id of the local room + * @param createRoomBody the room creation params + * + * @return a chunk entity + */ + private suspend fun createLocalRoomChunk(realm: Realm, roomId: String, createRoomBody: CreateRoomBody): ChunkEntity { + val chunkEntity = realm.createObject().apply { + isLastBackward = true + isLastForward = true + } + + val eventList = createLocalRoomEvents(createRoomBody) + val roomMemberContentsByUser = HashMap() + + for (event in eventList) { + if (event.eventId == null || event.senderId == null || event.type == null) { + continue + } + + val now = clock.epochMillis() + val eventEntity = event.toEntity(roomId, SendState.SYNCED, now).copyToRealmOrIgnore(realm, EventInsertType.INCREMENTAL_SYNC) + if (event.stateKey != null) { + CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { + eventId = event.eventId + root = eventEntity + } + if (event.type == EventType.STATE_ROOM_MEMBER) { + roomMemberContentsByUser[event.stateKey] = event.getFixedRoomMemberContent() + roomMemberEventHandler.handle(realm, roomId, event, false) + } + } + + roomMemberContentsByUser.getOrPut(event.senderId) { + // If we don't have any new state on this user, get it from db + val rootStateEvent = CurrentStateEventEntity.getOrNull(realm, roomId, event.senderId, EventType.STATE_ROOM_MEMBER)?.root + rootStateEvent?.asDomain()?.getFixedRoomMemberContent() + } + + chunkEntity.addTimelineEvent( + roomId = roomId, + eventEntity = eventEntity, + direction = PaginationDirection.FORWARDS, + roomMemberContentsByUser = roomMemberContentsByUser + ) + } + + return chunkEntity + } + + /** + * Build the list of the events related to the room creation params. + * + * @param createRoomBody the room creation params + * + * @return the list of events + */ + private suspend fun createLocalRoomEvents(createRoomBody: CreateRoomBody): List { + val myUser = userService.getUser(userId) ?: User(userId) + val invitedUsers = createRoomBody.invitedUserIds.orEmpty() + .mapNotNull { tryOrNull { userService.resolveUser(it) } } + + val createRoomEvent = createLocalEvent( + type = EventType.STATE_ROOM_CREATE, + content = RoomCreateContent( + creator = userId + ).toContent() + ) + val myRoomMemberEvent = createLocalEvent( + type = EventType.STATE_ROOM_MEMBER, + content = RoomMemberContent( + membership = Membership.JOIN, + displayName = myUser.displayName, + avatarUrl = myUser.avatarUrl + ).toContent(), + stateKey = userId + ) + val roomMemberEvents = invitedUsers.map { + createLocalEvent( + type = EventType.STATE_ROOM_MEMBER, + content = RoomMemberContent( + isDirect = createRoomBody.isDirect.orFalse(), + membership = Membership.INVITE, + displayName = it.displayName, + avatarUrl = it.avatarUrl + ).toContent(), + stateKey = it.userId + ) + } + + return buildList { + add(createRoomEvent) + add(myRoomMemberEvent) + addAll(createRoomBody.initialStates.orEmpty().map { createLocalEvent(it.type, it.content, it.stateKey) }) + addAll(roomMemberEvents) + } + } + + /** + * Generate a local event from the given parameters. + * + * @param type the event type, see [EventType] + * @param content the content of the Event + * @param stateKey the stateKey, if any + * + * @return a fake event + */ + private fun createLocalEvent(type: String?, content: Content?, stateKey: String? = ""): Event { + return Event( + type = type, + senderId = userId, + stateKey = stateKey, + content = content, + originServerTs = clock.epochMillis(), + eventId = LocalEcho.createLocalEchoId() + ) + } + + /** + * Setup default values to the CreateRoomParams as the room is created locally (the default values will not be defined by the server). + */ + private fun CreateRoomParams.withDefault() = this.apply { + if (visibility == null) visibility = RoomDirectoryVisibility.PRIVATE + if (historyVisibility == null) historyVisibility = RoomHistoryVisibility.SHARED + if (guestAccess == null) guestAccess = GuestAccess.Forbidden + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt index cffa632768..b326c3618c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt @@ -120,3 +120,20 @@ internal data class CreateRoomBody( @Json(name = "room_version") val roomVersion: String? ) + +/** + * Tells if the created room can be a direct chat one. + * + * @return true if it is a direct chat + */ +private fun CreateRoomBody.isDirect(): Boolean { + return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT && isDirect == true +} + +internal fun CreateRoomBody.getDirectUserId(): String? { + return if (isDirect()) { + invitedUserIds?.firstOrNull() + ?: invite3pids?.firstOrNull()?.address + ?: throw IllegalStateException("You can't create a direct room without an invitedUser") + } else null +} 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 6dd2c91048..d76640573f 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 @@ -62,11 +62,6 @@ internal class DefaultCreateRoomTask @Inject constructor( ) : CreateRoomTask { override suspend fun execute(params: CreateRoomParams): String { - val otherUserId = if (params.isDirect()) { - params.getFirstInvitedUserId() - ?: throw IllegalStateException("You can't create a direct room without an invitedUser") - } else null - if (params.preset == CreateRoomPreset.PRESET_PUBLIC_CHAT) { try { aliasAvailabilityChecker.check(params.roomAliasName) @@ -111,14 +106,13 @@ internal class DefaultCreateRoomTask @Inject constructor( RoomSummaryEntity.where(it, roomId).findFirst()?.lastActivityTime = clock.epochMillis() } - if (otherUserId != null) { - handleDirectChatCreation(roomId, otherUserId) - } + handleDirectChatCreation(roomId, createRoomBody.getDirectUserId()) setReadMarkers(roomId) return roomId } - private suspend fun handleDirectChatCreation(roomId: String, otherUserId: String) { + private suspend fun handleDirectChatCreation(roomId: String, otherUserId: String?) { + otherUserId ?: return // This is not a direct room monarchy.awaitTransaction { realm -> RoomSummaryEntity.where(realm, roomId).findFirst()?.apply { this.directUserId = otherUserId @@ -133,21 +127,4 @@ internal class DefaultCreateRoomTask @Inject constructor( val setReadMarkerParams = SetReadMarkersTask.Params(roomId, forceReadReceipt = true, forceReadMarker = true) return readMarkersTask.execute(setReadMarkerParams) } - - /** - * Tells if the created room can be a direct chat one. - * - * @return true if it is a direct chat - */ - private fun CreateRoomParams.isDirect(): Boolean { - return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT && - isDirect == true - } - - /** - * @return the first invited user id - */ - private fun CreateRoomParams.getFirstInvitedUserId(): String? { - return invitedUserIds.firstOrNull() ?: invite3pids.firstOrNull()?.value - } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/delete/DeleteLocalRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/delete/DeleteLocalRoomTask.kt new file mode 100644 index 0000000000..936c94e520 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/delete/DeleteLocalRoomTask.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2022 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.delete + +import com.zhuinden.monarchy.Monarchy +import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho +import org.matrix.android.sdk.internal.database.model.ChunkEntity +import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.RoomEntity +import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntity +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.model.deleteOnCascade +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.database.query.whereRoomId +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.session.room.delete.DeleteLocalRoomTask.Params +import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.awaitTransaction +import timber.log.Timber +import javax.inject.Inject + +internal interface DeleteLocalRoomTask : Task { + data class Params(val roomId: String) +} + +internal class DefaultDeleteLocalRoomTask @Inject constructor( + @SessionDatabase private val monarchy: Monarchy, +) : DeleteLocalRoomTask { + + override suspend fun execute(params: Params) { + val roomId = params.roomId + + if (RoomLocalEcho.isLocalEchoId(roomId)) { + monarchy.awaitTransaction { realm -> + Timber.i("## DeleteLocalRoomTask - delete local room id $roomId") + RoomMemberSummaryEntity.where(realm, roomId = roomId).findAll() + ?.also { Timber.i("## DeleteLocalRoomTask - RoomMemberSummaryEntity - delete ${it.size} entries") } + ?.deleteAllFromRealm() + CurrentStateEventEntity.whereRoomId(realm, roomId = roomId).findAll() + ?.also { Timber.i("## DeleteLocalRoomTask - CurrentStateEventEntity - delete ${it.size} entries") } + ?.deleteAllFromRealm() + EventEntity.whereRoomId(realm, roomId = roomId).findAll() + ?.also { Timber.i("## DeleteLocalRoomTask - EventEntity - delete ${it.size} entries") } + ?.deleteAllFromRealm() + TimelineEventEntity.whereRoomId(realm, roomId = roomId).findAll() + ?.also { Timber.i("## DeleteLocalRoomTask - TimelineEventEntity - delete ${it.size} entries") } + ?.forEach { it.deleteOnCascade(true) } + ChunkEntity.where(realm, roomId = roomId).findAll() + ?.also { Timber.i("## DeleteLocalRoomTask - ChunkEntity - delete ${it.size} entries") } + ?.forEach { it.deleteOnCascade(deleteStateEvents = true, canDeleteRoot = true) } + RoomSummaryEntity.where(realm, roomId = roomId).findAll() + ?.also { Timber.i("## DeleteLocalRoomTask - RoomSummaryEntity - delete ${it.size} entries") } + ?.deleteAllFromRealm() + RoomEntity.where(realm, roomId = roomId).findAll() + ?.also { Timber.i("## DeleteLocalRoomTask - RoomEntity - delete ${it.size} entries") } + ?.deleteAllFromRealm() + } + } else { + Timber.i("## DeleteLocalRoomTask - Failed to remove room with id $roomId: not a local room") + } + } +} diff --git a/vector/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt b/vector/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt index 32dfd432d9..9533e93ed1 100644 --- a/vector/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt +++ b/vector/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt @@ -75,6 +75,11 @@ class DebugFeaturesStateFactory @Inject constructor( key = DebugFeatureKeys.forceUsageOfOpusEncoder, factory = VectorFeatures::forceUsageOfOpusEncoder ), + createBooleanFeature( + label = "Start DM on first message", + key = DebugFeatureKeys.startDmOnFirstMsg, + factory = VectorFeatures::shouldStartDmOnFirstMessage + ), ) ) } diff --git a/vector/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt b/vector/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt index cc581fe5ab..1b178b5f48 100644 --- a/vector/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt +++ b/vector/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt @@ -69,6 +69,9 @@ class DebugVectorFeatures( override fun forceUsageOfOpusEncoder(): Boolean = read(DebugFeatureKeys.forceUsageOfOpusEncoder) ?: vectorFeatures.forceUsageOfOpusEncoder() + override fun shouldStartDmOnFirstMessage(): Boolean = read(DebugFeatureKeys.startDmOnFirstMsg) + ?: vectorFeatures.shouldStartDmOnFirstMessage() + fun override(value: T?, key: Preferences.Key) = updatePreferences { if (value == null) { it.remove(key) @@ -127,4 +130,5 @@ object DebugFeatureKeys { val liveLocationSharing = booleanPreferencesKey("live-location-sharing") val screenSharing = booleanPreferencesKey("screen-sharing") val forceUsageOfOpusEncoder = booleanPreferencesKey("force-usage-of-opus-encoder") + val startDmOnFirstMsg = booleanPreferencesKey("start-dm-on-first-msg") } diff --git a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt index fd3bbf4a89..eaacb0498e 100644 --- a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt +++ b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt @@ -31,6 +31,7 @@ interface VectorFeatures { fun allowExternalUnifiedPushDistributors(): Boolean fun isScreenSharingEnabled(): Boolean fun forceUsageOfOpusEncoder(): Boolean + fun shouldStartDmOnFirstMessage(): Boolean enum class OnboardingVariant { LEGACY, @@ -50,4 +51,5 @@ class DefaultVectorFeatures : VectorFeatures { override fun allowExternalUnifiedPushDistributors(): Boolean = Config.ALLOW_EXTERNAL_UNIFIED_PUSH_DISTRIBUTORS override fun isScreenSharingEnabled(): Boolean = true override fun forceUsageOfOpusEncoder(): Boolean = false + override fun shouldStartDmOnFirstMessage(): Boolean = false } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt index 83c7f0a13b..b5657598ee 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt @@ -20,10 +20,12 @@ import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.features.userdirectory.PendingSelection sealed class CreateDirectRoomAction : VectorViewModelAction { - data class CreateRoomAndInviteSelectedUsers( + data class PrepareRoomWithSelectedUsers( val selections: Set ) : CreateDirectRoomAction() + object CreateRoomAndInviteSelectedUsers : CreateDirectRoomAction() + data class QrScannedAction( val result: String ) : CreateDirectRoomAction() diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt index 12cd4ddab8..707b78d328 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt @@ -161,7 +161,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { } private fun handleOnMenuItemSubmitClick(action: UserListSharedAction.OnMenuItemSubmitClick) { - viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(action.selections)) + viewModel.handle(CreateDirectRoomAction.PrepareRoomWithSelectedUsers(action.selections)) } private fun renderCreateAndInviteState(state: Async) { diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt index 8374f9d513..b306cb6e03 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt @@ -26,6 +26,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.mvrx.runCatchingToAsync import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.VectorFeatures import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.plan.CreatedRoom import im.vector.app.features.raw.wellknown.getElementWellknown @@ -46,7 +47,8 @@ class CreateDirectRoomViewModel @AssistedInject constructor( @Assisted initialState: CreateDirectRoomViewState, private val rawService: RawService, val session: Session, - val analyticsTracker: AnalyticsTracker + val analyticsTracker: AnalyticsTracker, + val vectorFeatures: VectorFeatures ) : VectorViewModel(initialState) { @@ -59,7 +61,8 @@ class CreateDirectRoomViewModel @AssistedInject constructor( override fun handle(action: CreateDirectRoomAction) { when (action) { - is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onSubmitInvitees(action.selections) + is CreateDirectRoomAction.PrepareRoomWithSelectedUsers -> onSubmitInvitees(action.selections) + is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onCreateRoomWithInvitees() is CreateDirectRoomAction.QrScannedAction -> onCodeParsed(action) } } @@ -94,16 +97,18 @@ class CreateDirectRoomViewModel @AssistedInject constructor( } if (existingRoomId != null) { // Do not create a new DM, just tell that the creation is successful by passing the existing roomId - setState { - copy(createAndInviteState = Success(existingRoomId)) - } + setState { copy(createAndInviteState = Success(existingRoomId)) } } else { - // Create the DM - createRoomAndInviteSelectedUsers(selections) + createLocalRoomWithSelectedUsers(selections) } } - private fun createRoomAndInviteSelectedUsers(selections: Set) { + private fun onCreateRoomWithInvitees() { + // Create the DM + withState { createLocalRoomWithSelectedUsers(it.pendingSelections) } + } + + private fun createLocalRoomWithSelectedUsers(selections: Set) { setState { copy(createAndInviteState = Loading()) } viewModelScope.launch(Dispatchers.IO) { @@ -124,7 +129,11 @@ class CreateDirectRoomViewModel @AssistedInject constructor( } val result = runCatchingToAsync { - session.roomService().createRoom(roomParams) + if (vectorFeatures.shouldStartDmOnFirstMessage()) { + session.roomService().createLocalRoom(roomParams) + } else { + session.roomService().createRoom(roomParams) + } } analyticsTracker.capture(CreatedRoom(isDM = roomParams.isDirect.orFalse())) diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewState.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewState.kt index 41366a7110..33360ac20c 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewState.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewState.kt @@ -19,7 +19,9 @@ package im.vector.app.features.createdirect import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized +import im.vector.app.features.userdirectory.PendingSelection data class CreateDirectRoomViewState( + val pendingSelections: Set = emptySet(), val createAndInviteState: Async = Uninitialized ) : MavericksState diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index 47db50d0d4..3ec7166739 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho import org.matrix.android.sdk.api.session.room.sender.SenderInfo import org.matrix.android.sdk.api.session.sync.SyncRequestState import org.matrix.android.sdk.api.session.sync.SyncState @@ -103,4 +104,6 @@ data class RoomDetailViewState( fun isDm() = asyncRoomSummary()?.isDirect == true fun isThreadTimeline() = rootThreadEventId != null + + fun isLocalRoom() = RoomLocalEcho.isLocalEchoId(roomId) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index 9468af7726..25947cc22b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -1430,6 +1430,9 @@ class TimelineFragment @Inject constructor( updateJumpToReadMarkerViewVisibility() jumpToBottomViewVisibilityManager.maybeShowJumpToBottomViewVisibilityWithDelay() } + }.apply { + // For local rooms, pin the view's content to the top edge (the layout is reversed) + stackFromEnd = isLocalRoom() } val stateRestorer = LayoutManagerStateRestorer(layoutManager).register() scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager, timelineEventController) @@ -1701,31 +1704,41 @@ class TimelineFragment @Inject constructor( } private fun renderToolbar(roomSummary: RoomSummary?) { - if (!isThreadTimeLine()) { - views.includeRoomToolbar.roomToolbarContentView.isVisible = true - views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = false - if (roomSummary == null) { - views.includeRoomToolbar.roomToolbarContentView.isClickable = false - } else { - views.includeRoomToolbar.roomToolbarContentView.isClickable = roomSummary.membership == Membership.JOIN - views.includeRoomToolbar.roomToolbarTitleView.text = roomSummary.displayName - avatarRenderer.render(roomSummary.toMatrixItem(), views.includeRoomToolbar.roomToolbarAvatarImageView) - val showPresence = roomSummary.isDirect - views.includeRoomToolbar.roomToolbarPresenceImageView.render(showPresence, roomSummary.directUserPresence) - val shieldView = if (showPresence) views.includeRoomToolbar.roomToolbarTitleShield else views.includeRoomToolbar.roomToolbarAvatarShield - shieldView.render(roomSummary.roomEncryptionTrustLevel) - views.includeRoomToolbar.roomToolbarPublicImageView.isVisible = roomSummary.isPublic && !roomSummary.isDirect + when { + isLocalRoom() -> { + views.includeRoomToolbar.roomToolbarContentView.isVisible = false + views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = false + setupToolbar(views.roomToolbar) + .setTitle(R.string.room_member_open_or_create_dm) + .allowBack(useCross = true) } - } else { - views.includeRoomToolbar.roomToolbarContentView.isVisible = false - views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = true - timelineArgs.threadTimelineArgs?.let { - val matrixItem = MatrixItem.RoomItem(it.roomId, it.displayName, it.avatarUrl) - avatarRenderer.render(matrixItem, views.includeThreadToolbar.roomToolbarThreadImageView) - views.includeThreadToolbar.roomToolbarThreadShieldImageView.render(it.roomEncryptionTrustLevel) - views.includeThreadToolbar.roomToolbarThreadSubtitleTextView.text = it.displayName + isThreadTimeLine() -> { + views.includeRoomToolbar.roomToolbarContentView.isVisible = false + views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = true + timelineArgs.threadTimelineArgs?.let { + val matrixItem = MatrixItem.RoomItem(it.roomId, it.displayName, it.avatarUrl) + avatarRenderer.render(matrixItem, views.includeThreadToolbar.roomToolbarThreadImageView) + views.includeThreadToolbar.roomToolbarThreadShieldImageView.render(it.roomEncryptionTrustLevel) + views.includeThreadToolbar.roomToolbarThreadSubtitleTextView.text = it.displayName + } + views.includeThreadToolbar.roomToolbarThreadTitleTextView.text = resources.getText(R.string.thread_timeline_title) + } + else -> { + views.includeRoomToolbar.roomToolbarContentView.isVisible = true + views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = false + if (roomSummary == null) { + views.includeRoomToolbar.roomToolbarContentView.isClickable = false + } else { + views.includeRoomToolbar.roomToolbarContentView.isClickable = roomSummary.membership == Membership.JOIN + views.includeRoomToolbar.roomToolbarTitleView.text = roomSummary.displayName + avatarRenderer.render(roomSummary.toMatrixItem(), views.includeRoomToolbar.roomToolbarAvatarImageView) + val showPresence = roomSummary.isDirect + views.includeRoomToolbar.roomToolbarPresenceImageView.render(showPresence, roomSummary.directUserPresence) + val shieldView = if (showPresence) views.includeRoomToolbar.roomToolbarTitleShield else views.includeRoomToolbar.roomToolbarAvatarShield + shieldView.render(roomSummary.roomEncryptionTrustLevel) + views.includeRoomToolbar.roomToolbarPublicImageView.isVisible = roomSummary.isPublic && !roomSummary.isDirect + } } - views.includeThreadToolbar.roomToolbarThreadTitleTextView.text = resources.getText(R.string.thread_timeline_title) } } @@ -2660,6 +2673,11 @@ class TimelineFragment @Inject constructor( */ private fun isThreadTimeLine(): Boolean = timelineArgs.threadTimelineArgs?.rootThreadEventId != null + /** + * Returns true if the current room is a local room, false otherwise. + */ + private fun isLocalRoom(): Boolean = withState(timelineViewModel) { it.isLocalRoom() } + /** * Returns the root thread event if we are in a thread room, otherwise returns null. */ diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 48f8aef421..194db8bce7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -735,26 +735,30 @@ class TimelineViewModel @AssistedInject constructor( return@withState false } - if (initialState.isThreadTimeline()) { - when (itemId) { - R.id.menu_thread_timeline_view_in_room, - R.id.menu_thread_timeline_copy_link, - R.id.menu_thread_timeline_share -> true - else -> false + when { + initialState.isLocalRoom() -> false + initialState.isThreadTimeline() -> { + when (itemId) { + R.id.menu_thread_timeline_view_in_room, + R.id.menu_thread_timeline_copy_link, + R.id.menu_thread_timeline_share -> true + else -> false + } } - } else { - when (itemId) { - R.id.timeline_setting -> true - R.id.invite -> state.canInvite - R.id.open_matrix_apps -> true - R.id.voice_call -> state.isCallOptionAvailable() - R.id.video_call -> state.isCallOptionAvailable() || state.jitsiState.confId == null || state.jitsiState.hasJoined - // Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^ - R.id.join_conference -> !state.isCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined - R.id.search -> state.isSearchAvailable() - R.id.menu_timeline_thread_list -> vectorPreferences.areThreadMessagesEnabled() - R.id.dev_tools -> vectorPreferences.developerMode() - else -> false + else -> { + when (itemId) { + R.id.timeline_setting -> true + R.id.invite -> state.canInvite + R.id.open_matrix_apps -> true + R.id.voice_call -> state.isCallOptionAvailable() + R.id.video_call -> state.isCallOptionAvailable() || state.jitsiState.confId == null || state.jitsiState.hasJoined + // Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^ + R.id.join_conference -> !state.isCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined + R.id.search -> state.isSearchAvailable() + R.id.menu_timeline_thread_list -> vectorPreferences.areThreadMessagesEnabled() + R.id.dev_tools -> vectorPreferences.developerMode() + else -> false + } } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt index a02eaa3202..18c626bda8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt @@ -603,12 +603,13 @@ class TimelineEventController @Inject constructor( } private fun wantsDateSeparator(event: TimelineEvent, nextEvent: TimelineEvent?): Boolean { - return if (hasReachedInvite && hasUTD) { - true - } else { - val date = event.root.localDateTime() - val nextDate = nextEvent?.root?.localDateTime() - date.toLocalDate() != nextDate?.toLocalDate() + return when { + hasReachedInvite && hasUTD -> true + else -> { + val date = event.root.localDateTime() + val nextDate = nextEvent?.root?.localDateTime() + date.toLocalDate() != nextDate?.toLocalDate() + } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt index dd058197b4..5f32696334 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt @@ -30,6 +30,7 @@ import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.content.EncryptionEventContent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.getRoomSummary +import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho import javax.inject.Inject class EncryptionItemFactory @Inject constructor( @@ -55,12 +56,19 @@ class EncryptionItemFactory @Inject constructor( val description: String val shield: StatusTileTimelineItem.ShieldUIState if (isSafeAlgorithm) { + val isDirect = session.getRoomSummary(event.root.roomId.orEmpty())?.isDirect.orFalse() title = stringProvider.getString(R.string.encryption_enabled) description = stringProvider.getString( - if (session.getRoomSummary(event.root.roomId ?: "")?.isDirect.orFalse()) { - R.string.direct_room_encryption_enabled_tile_description - } else { - R.string.encryption_enabled_tile_description + when { + isDirect && RoomLocalEcho.isLocalEchoId(event.root.roomId.orEmpty()) -> { + R.string.direct_room_encryption_enabled_tile_description_future + } + isDirect -> { + R.string.direct_room_encryption_enabled_tile_description + } + else -> { + R.string.encryption_enabled_tile_description + } } ) shield = StatusTileTimelineItem.ShieldUIState.BLACK diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index 771e42b63c..800fc27e77 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -117,6 +117,7 @@ class MergedHeaderItemFactory @Inject constructor( highlighted = true } val data = BasedMergedItem.Data( + roomId = mergedEvent.root.roomId, userId = mergedEvent.root.senderId ?: "", avatarUrl = mergedEvent.senderInfo.avatarUrl, memberName = mergedEvent.senderInfo.disambiguatedDisplayName, @@ -199,6 +200,7 @@ class MergedHeaderItemFactory @Inject constructor( highlighted = true } val data = BasedMergedItem.Data( + roomId = mergedEvent.root.roomId, userId = mergedEvent.root.senderId ?: "", avatarUrl = mergedEvent.senderInfo.avatarUrl, memberName = mergedEvent.senderInfo.disambiguatedDisplayName, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt index e4e0ea8352..488aebde13 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt @@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.events.model.isThread import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import javax.inject.Inject @@ -176,6 +177,13 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen return true } + // Hide fake events for local rooms + if (RoomLocalEcho.isLocalEchoId(roomId) && + root.getClearType() == EventType.STATE_ROOM_MEMBER || + root.getClearType() == EventType.STATE_ROOM_HISTORY_VISIBILITY) { + return true + } + // Allow only the the threads within the rootThreadEventId along with the root event if (userPreferencesProvider.areThreadMessagesEnabled() && isFromThreadTimeline) { return if (root.getRootThreadEventId() == rootThreadEventId) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BasedMergedItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BasedMergedItem.kt index 43f81b4d05..48d5c29879 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BasedMergedItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BasedMergedItem.kt @@ -55,6 +55,7 @@ abstract class BasedMergedItem(@LayoutRes layoutId: } data class Data( + val roomId: String?, val localId: Long, val eventId: String, val userId: String, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt index 0483c3914b..47401c34e7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt @@ -25,9 +25,9 @@ import android.widget.ImageView import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat -import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams +import androidx.core.widget.TextViewCompat import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R @@ -38,8 +38,10 @@ import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.RoomDetailAction import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.tools.linkify +import im.vector.app.features.themes.ThemeUtils import me.gujun.android.span.span import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho import org.matrix.android.sdk.api.util.toMatrixItem @EpoxyModelClass @@ -51,126 +53,123 @@ abstract class MergedRoomCreationItem : BasedMergedItem { this.marginEnd = leftGuideline } if (attributes.isEncryptionAlgorithmSecure) { - holder.e2eTitleTextView.text = holder.expandView.resources.getString(R.string.encryption_enabled) - holder.e2eTitleDescriptionView.text = if (data?.isDirectRoom == true) { - holder.expandView.resources.getString(R.string.direct_room_encryption_enabled_tile_description) - } else { - holder.expandView.resources.getString(R.string.encryption_enabled_tile_description) - } - holder.e2eTitleDescriptionView.textAlignment = View.TEXT_ALIGNMENT_CENTER - holder.e2eTitleTextView.setCompoundDrawablesWithIntrinsicBounds( - ContextCompat.getDrawable(holder.view.context, R.drawable.ic_shield_black), - null, null, null - ) + renderE2ESecureTile(holder) } else { - holder.e2eTitleTextView.text = holder.expandView.resources.getString(R.string.encryption_not_enabled) - holder.e2eTitleDescriptionView.text = holder.expandView.resources.getString(R.string.encryption_unknown_algorithm_tile_description) - holder.e2eTitleTextView.setCompoundDrawablesWithIntrinsicBounds( - ContextCompat.getDrawable(holder.view.context, R.drawable.ic_shield_warning), - null, null, null - ) + renderE2EUnsecureTile(holder) } } else { holder.encryptionTile.isVisible = false } } + private fun renderE2ESecureTile(holder: Holder) { + val resources = holder.expandView.resources + val description = when { + isDirectRoom -> { + if (attributes.isLocalRoom) { + resources.getString(R.string.direct_room_encryption_enabled_tile_description_future) + } else { + resources.getString(R.string.direct_room_encryption_enabled_tile_description) + } + } + else -> { + resources.getString(R.string.encryption_enabled_tile_description) + } + } + + holder.e2eTitleTextView.text = holder.expandView.resources.getString(R.string.encryption_enabled) + holder.e2eTitleTextView.setCompoundDrawablesWithIntrinsicBounds( + ContextCompat.getDrawable(holder.view.context, R.drawable.ic_shield_black), + null, null, null + ) + holder.e2eTitleDescriptionView.text = description + holder.e2eTitleDescriptionView.textAlignment = View.TEXT_ALIGNMENT_CENTER + } + + private fun renderE2EUnsecureTile(holder: Holder) { + holder.e2eTitleTextView.text = holder.expandView.resources.getString(R.string.encryption_not_enabled) + holder.e2eTitleDescriptionView.text = holder.expandView.resources.getString(R.string.encryption_unknown_algorithm_tile_description) + holder.e2eTitleTextView.setCompoundDrawablesWithIntrinsicBounds( + ContextCompat.getDrawable(holder.view.context, R.drawable.ic_shield_warning), + null, null, null + ) + } + private fun bindCreationSummaryTile(holder: Holder) { - val roomSummary = attributes.roomSummary val roomDisplayName = roomSummary?.displayName - holder.roomNameText.setTextOrHide(roomDisplayName) - val isDirect = roomSummary?.isDirect == true val membersCount = roomSummary?.otherMemberIds?.size ?: 0 - if (isDirect) { - holder.roomDescriptionText.text = holder.view.resources.getString( - R.string.this_is_the_beginning_of_dm, - roomSummary?.displayName ?: "" - ) - } else if (roomDisplayName.isNullOrBlank() || roomSummary.name.isBlank()) { - holder.roomDescriptionText.text = holder.view.resources.getString(R.string.this_is_the_beginning_of_room_no_name) - } else { - holder.roomDescriptionText.text = holder.view.resources.getString(R.string.this_is_the_beginning_of_room, roomDisplayName) - } - - val topic = roomSummary?.topic - if (topic.isNullOrBlank()) { - // do not show hint for DMs or group DMs - val canSetTopic = attributes.canChangeTopic && !isDirect - if (canSetTopic) { - val addTopicLink = holder.view.resources.getString(R.string.add_a_topic_link_text) - val styledText = SpannableString(holder.view.resources.getString(R.string.room_created_summary_no_topic_creation_text, addTopicLink)) - holder.roomTopicText.setTextOrHide(styledText.tappableMatchingText(addTopicLink, object : ClickableSpan() { - override fun onClick(widget: View) { - attributes.callback?.onTimelineItemAction(RoomDetailAction.QuickActionSetTopic) - } - })) - } - } else { - holder.roomTopicText.setTextOrHide( - span { - span(holder.view.resources.getString(R.string.topic_prefix)) { - textStyle = "bold" - } - +topic.linkify(attributes.callback) - } - ) - } - holder.roomTopicText.movementMethod = movementMethod + holder.roomNameText.setTextOrHide(roomDisplayName) + renderRoomDescription(holder) + renderRoomTopic(holder) val roomItem = roomSummary?.toMatrixItem() val shouldSetAvatar = attributes.canChangeAvatar && - (roomSummary?.isDirect == false || (isDirect && membersCount >= 2)) && + (roomSummary?.isDirect == false || (isDirectRoom && membersCount >= 2)) && roomItem?.avatarUrl.isNullOrBlank() holder.roomAvatarImageView.isVisible = roomItem != null @@ -193,7 +192,7 @@ abstract class MergedRoomCreationItem : BasedMergedItem { + if (attributes.isLocalRoom) { + resources.getString(R.string.send_your_first_msg_to_invite, roomSummary?.displayName.orEmpty()) + } else { + resources.getString(R.string.this_is_the_beginning_of_dm, roomSummary?.displayName.orEmpty()) + } + } + roomDisplayName.isNullOrBlank() || roomSummary?.name.isNullOrBlank() -> { + holder.view.resources.getString(R.string.this_is_the_beginning_of_room_no_name) + } + else -> { + holder.view.resources.getString(R.string.this_is_the_beginning_of_room, roomDisplayName) + } + } + holder.roomDescriptionText.text = description + if (isDirectRoom && attributes.isLocalRoom) { + TextViewCompat.setTextAppearance(holder.roomDescriptionText, R.style.TextAppearance_Vector_Subtitle) + holder.roomDescriptionText.setTextColor(ThemeUtils.getColor(holder.roomDescriptionText.context, R.attr.vctr_content_primary)) + } + } + + private fun renderRoomTopic(holder: Holder) { + val topic = roomSummary?.topic + if (topic.isNullOrBlank()) { + // do not show hint for DMs or group DMs + val canSetTopic = attributes.canChangeTopic && !isDirectRoom + if (canSetTopic) { + val addTopicLink = holder.view.resources.getString(R.string.add_a_topic_link_text) + val styledText = SpannableString(holder.view.resources.getString(R.string.room_created_summary_no_topic_creation_text, addTopicLink)) + holder.roomTopicText.setTextOrHide(styledText.tappableMatchingText(addTopicLink, object : ClickableSpan() { + override fun onClick(widget: View) { + attributes.callback?.onTimelineItemAction(RoomDetailAction.QuickActionSetTopic) + } + })) + } + } else { + holder.roomTopicText.setTextOrHide( + span { + span(holder.view.resources.getString(R.string.topic_prefix)) { + textStyle = "bold" + } + +topic.linkify(attributes.callback) + } + ) + } + holder.roomTopicText.movementMethod = movementMethod + } + class Holder : BasedMergedItem.Holder(STUB_ID) { + val mergedView by bind(R.id.mergedSumContainer) val summaryView by bind(R.id.itemNoticeTextView) val avatarView by bind(R.id.itemNoticeAvatarView) val encryptionTile by bind(R.id.creationEncryptionTile) @@ -236,5 +288,8 @@ abstract class MergedRoomCreationItem : BasedMergedItem + roomSummaries.mapNotNull { summary -> + summary.roomId.takeIf { RoomLocalEcho.isLocalEchoId(it) } + }.toSet() + } + .setOnEach { roomIds -> + copy(localRoomIds = roomIds) + } + } + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() private val roomListSectionBuilder = if (appStateHandler.getCurrentRoomGroupingMethod() is RoomGroupingMethod.BySpace) { @@ -173,6 +194,14 @@ class RoomListViewModel @AssistedInject constructor( return session.getRoom(roomId)?.stateService()?.isPublic().orFalse() } + fun deleteLocalRooms(roomsIds: Set) { + viewModelScope.launch { + roomsIds.forEach { + session.roomService().deleteLocalRoom(it) + } + } + } + // PRIVATE METHODS ***************************************************************************** private fun handleSelectRoom(action: RoomListAction.SelectRoom) = withState { diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewState.kt index 46ff6c242b..9d386b679a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewState.kt @@ -30,7 +30,8 @@ data class RoomListViewState( val roomMembershipChanges: Map = emptyMap(), val asyncSuggestedRooms: Async> = Uninitialized, val currentUserName: String? = null, - val currentRoomGrouping: Async = Uninitialized + val currentRoomGrouping: Async = Uninitialized, + val localRoomIds: Set = emptySet() ) : MavericksState { constructor(args: RoomListParams) : this(displayMode = args.displayMode) diff --git a/vector/src/main/res/layout/fragment_user_list.xml b/vector/src/main/res/layout/fragment_user_list.xml index b3149f05c5..989cb2feef 100644 --- a/vector/src/main/res/layout/fragment_user_list.xml +++ b/vector/src/main/res/layout/fragment_user_list.xml @@ -15,7 +15,9 @@ android:id="@+id/userListToolbar" android:layout_width="match_parent" android:layout_height="?actionBarSize" - app:title="@string/fab_menu_create_chat"/> + android:paddingEnd="@dimen/layout_horizontal_margin" + app:title="@string/fab_menu_create_chat" + tools:ignore="RtlSymmetry" /> @@ -94,4 +96,4 @@ app:layout_constraintTop_toBottomOf="@id/userListE2EbyDefaultDisabled" tools:listitem="@layout/item_known_user" /> - \ No newline at end of file + diff --git a/vector/src/main/res/menu/vector_create_direct_room.xml b/vector/src/main/res/menu/vector_create_direct_room.xml index 8c6eab1c52..10b2adf23d 100755 --- a/vector/src/main/res/menu/vector_create_direct_room.xml +++ b/vector/src/main/res/menu/vector_create_direct_room.xml @@ -4,7 +4,7 @@ diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 5c7371ea2f..778acf3096 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -1624,6 +1624,7 @@ "CREATE" + Go "Room name" "Name" "Room topic (optional)" @@ -2402,7 +2403,8 @@ Encryption enabled Messages in this room are end-to-end encrypted. Learn more & verify users in their profile. - Messages in this room are end-to-end encrypted. + Messages in this chat are end-to-end encrypted. + Messages in this chat will be end-to-end encrypted. Encryption not enabled Encryption is misconfigured The encryption used by this room is not supported @@ -2414,6 +2416,7 @@ This is the beginning of %s. This is the beginning of this conversation. This is the beginning of your direct message history with %s. + Send your first message to invite %s to chat %s to let people know what this room is about. Add a topic