Merge pull request #6051 from vector-im/feature/fre/start_dm_on_first_msg

Start DM on first message (UI)
This commit is contained in:
Florian Renaud 2022-07-12 15:12:17 +02:00 committed by GitHub
commit 909ce290c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 751 additions and 176 deletions

1
changelog.d/5525.wip Normal file
View File

@ -0,0 +1 @@
Create DM room only on first message - Design implementation & debug feature flag

View File

@ -40,6 +40,18 @@ interface RoomService {
*/ */
suspend fun createRoom(createRoomParams: CreateRoomParams): String 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. * Create a direct room asynchronously. This is a facility method to create a direct room with the necessary parameters.
*/ */

View File

@ -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()}"
}

View File

@ -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.CurrentStateEventEntity
import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields
internal fun CurrentStateEventEntity.Companion.whereRoomId(
realm: Realm,
roomId: String
): RealmQuery<CurrentStateEventEntity> {
return realm.where(CurrentStateEventEntity::class.java)
.equalTo(CurrentStateEventEntityFields.ROOM_ID, roomId)
}
internal fun CurrentStateEventEntity.Companion.whereType( internal fun CurrentStateEventEntity.Companion.whereType(
realm: Realm, realm: Realm,
roomId: String, roomId: String,
type: String type: String
): RealmQuery<CurrentStateEventEntity> { ): RealmQuery<CurrentStateEventEntity> {
return realm.where(CurrentStateEventEntity::class.java) return whereRoomId(realm = realm, roomId = roomId)
.equalTo(CurrentStateEventEntityFields.ROOM_ID, roomId)
.equalTo(CurrentStateEventEntityFields.TYPE, type) .equalTo(CurrentStateEventEntityFields.TYPE, type)
} }

View File

@ -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.di.SessionDatabase
import org.matrix.android.sdk.internal.session.room.alias.DeleteRoomAliasTask 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.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.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.RoomChangeMembershipStateDataSource
import org.matrix.android.sdk.internal.session.room.membership.RoomMemberHelper import org.matrix.android.sdk.internal.session.room.membership.RoomMemberHelper
import org.matrix.android.sdk.internal.session.room.membership.joining.JoinRoomTask import org.matrix.android.sdk.internal.session.room.membership.joining.JoinRoomTask
@ -60,6 +62,8 @@ import javax.inject.Inject
internal class DefaultRoomService @Inject constructor( internal class DefaultRoomService @Inject constructor(
@SessionDatabase private val monarchy: Monarchy, @SessionDatabase private val monarchy: Monarchy,
private val createRoomTask: CreateRoomTask, private val createRoomTask: CreateRoomTask,
private val createLocalRoomTask: CreateLocalRoomTask,
private val deleteLocalRoomTask: DeleteLocalRoomTask,
private val joinRoomTask: JoinRoomTask, private val joinRoomTask: JoinRoomTask,
private val markAllRoomsReadTask: MarkAllRoomsReadTask, private val markAllRoomsReadTask: MarkAllRoomsReadTask,
private val updateBreadcrumbsTask: UpdateBreadcrumbsTask, private val updateBreadcrumbsTask: UpdateBreadcrumbsTask,
@ -78,6 +82,14 @@ internal class DefaultRoomService @Inject constructor(
return createRoomTask.executeRetry(createRoomParams, 3) 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? { override fun getRoom(roomId: String): Room? {
return roomGetter.getRoom(roomId) return roomGetter.getRoom(roomId)
} }

View File

@ -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.DeleteRoomAliasTask
import org.matrix.android.sdk.internal.session.room.alias.GetRoomIdByAliasTask 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.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.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.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.DefaultGetPublicRoomTask
import org.matrix.android.sdk.internal.session.room.directory.DefaultGetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.DefaultGetRoomDirectoryVisibilityTask
import org.matrix.android.sdk.internal.session.room.directory.DefaultSetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.DefaultSetRoomDirectoryVisibilityTask
@ -204,6 +208,12 @@ internal abstract class RoomModule {
@Binds @Binds
abstract fun bindCreateRoomTask(task: DefaultCreateRoomTask): CreateRoomTask abstract fun bindCreateRoomTask(task: DefaultCreateRoomTask): CreateRoomTask
@Binds
abstract fun bindCreateLocalRoomTask(task: DefaultCreateLocalRoomTask): CreateLocalRoomTask
@Binds
abstract fun bindDeleteLocalRoomTask(task: DefaultDeleteLocalRoomTask): DeleteLocalRoomTask
@Binds @Binds
abstract fun bindGetPublicRoomTask(task: DefaultGetPublicRoomTask): GetPublicRoomTask abstract fun bindGetPublicRoomTask(task: DefaultGetPublicRoomTask): GetPublicRoomTask

View File

@ -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<CreateRoomParams, String>
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<ChunkEntity>().apply {
isLastBackward = true
isLastForward = true
}
val eventList = createLocalRoomEvents(createRoomBody)
val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>()
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<Event> {
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
}
}

View File

@ -120,3 +120,20 @@ internal data class CreateRoomBody(
@Json(name = "room_version") @Json(name = "room_version")
val roomVersion: String? 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
}

View File

@ -62,11 +62,6 @@ internal class DefaultCreateRoomTask @Inject constructor(
) : CreateRoomTask { ) : CreateRoomTask {
override suspend fun execute(params: CreateRoomParams): String { 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) { if (params.preset == CreateRoomPreset.PRESET_PUBLIC_CHAT) {
try { try {
aliasAvailabilityChecker.check(params.roomAliasName) aliasAvailabilityChecker.check(params.roomAliasName)
@ -111,14 +106,13 @@ internal class DefaultCreateRoomTask @Inject constructor(
RoomSummaryEntity.where(it, roomId).findFirst()?.lastActivityTime = clock.epochMillis() RoomSummaryEntity.where(it, roomId).findFirst()?.lastActivityTime = clock.epochMillis()
} }
if (otherUserId != null) { handleDirectChatCreation(roomId, createRoomBody.getDirectUserId())
handleDirectChatCreation(roomId, otherUserId)
}
setReadMarkers(roomId) setReadMarkers(roomId)
return 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 -> monarchy.awaitTransaction { realm ->
RoomSummaryEntity.where(realm, roomId).findFirst()?.apply { RoomSummaryEntity.where(realm, roomId).findFirst()?.apply {
this.directUserId = otherUserId this.directUserId = otherUserId
@ -133,21 +127,4 @@ internal class DefaultCreateRoomTask @Inject constructor(
val setReadMarkerParams = SetReadMarkersTask.Params(roomId, forceReadReceipt = true, forceReadMarker = true) val setReadMarkerParams = SetReadMarkersTask.Params(roomId, forceReadReceipt = true, forceReadMarker = true)
return readMarkersTask.execute(setReadMarkerParams) 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
}
} }

View File

@ -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<Params, Unit> {
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")
}
}
}

View File

@ -75,6 +75,11 @@ class DebugFeaturesStateFactory @Inject constructor(
key = DebugFeatureKeys.forceUsageOfOpusEncoder, key = DebugFeatureKeys.forceUsageOfOpusEncoder,
factory = VectorFeatures::forceUsageOfOpusEncoder factory = VectorFeatures::forceUsageOfOpusEncoder
), ),
createBooleanFeature(
label = "Start DM on first message",
key = DebugFeatureKeys.startDmOnFirstMsg,
factory = VectorFeatures::shouldStartDmOnFirstMessage
),
) )
) )
} }

View File

@ -69,6 +69,9 @@ class DebugVectorFeatures(
override fun forceUsageOfOpusEncoder(): Boolean = read(DebugFeatureKeys.forceUsageOfOpusEncoder) override fun forceUsageOfOpusEncoder(): Boolean = read(DebugFeatureKeys.forceUsageOfOpusEncoder)
?: vectorFeatures.forceUsageOfOpusEncoder() ?: vectorFeatures.forceUsageOfOpusEncoder()
override fun shouldStartDmOnFirstMessage(): Boolean = read(DebugFeatureKeys.startDmOnFirstMsg)
?: vectorFeatures.shouldStartDmOnFirstMessage()
fun <T> override(value: T?, key: Preferences.Key<T>) = updatePreferences { fun <T> override(value: T?, key: Preferences.Key<T>) = updatePreferences {
if (value == null) { if (value == null) {
it.remove(key) it.remove(key)
@ -127,4 +130,5 @@ object DebugFeatureKeys {
val liveLocationSharing = booleanPreferencesKey("live-location-sharing") val liveLocationSharing = booleanPreferencesKey("live-location-sharing")
val screenSharing = booleanPreferencesKey("screen-sharing") val screenSharing = booleanPreferencesKey("screen-sharing")
val forceUsageOfOpusEncoder = booleanPreferencesKey("force-usage-of-opus-encoder") val forceUsageOfOpusEncoder = booleanPreferencesKey("force-usage-of-opus-encoder")
val startDmOnFirstMsg = booleanPreferencesKey("start-dm-on-first-msg")
} }

View File

@ -31,6 +31,7 @@ interface VectorFeatures {
fun allowExternalUnifiedPushDistributors(): Boolean fun allowExternalUnifiedPushDistributors(): Boolean
fun isScreenSharingEnabled(): Boolean fun isScreenSharingEnabled(): Boolean
fun forceUsageOfOpusEncoder(): Boolean fun forceUsageOfOpusEncoder(): Boolean
fun shouldStartDmOnFirstMessage(): Boolean
enum class OnboardingVariant { enum class OnboardingVariant {
LEGACY, LEGACY,
@ -50,4 +51,5 @@ class DefaultVectorFeatures : VectorFeatures {
override fun allowExternalUnifiedPushDistributors(): Boolean = Config.ALLOW_EXTERNAL_UNIFIED_PUSH_DISTRIBUTORS override fun allowExternalUnifiedPushDistributors(): Boolean = Config.ALLOW_EXTERNAL_UNIFIED_PUSH_DISTRIBUTORS
override fun isScreenSharingEnabled(): Boolean = true override fun isScreenSharingEnabled(): Boolean = true
override fun forceUsageOfOpusEncoder(): Boolean = false override fun forceUsageOfOpusEncoder(): Boolean = false
override fun shouldStartDmOnFirstMessage(): Boolean = false
} }

View File

@ -20,10 +20,12 @@ import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.userdirectory.PendingSelection import im.vector.app.features.userdirectory.PendingSelection
sealed class CreateDirectRoomAction : VectorViewModelAction { sealed class CreateDirectRoomAction : VectorViewModelAction {
data class CreateRoomAndInviteSelectedUsers( data class PrepareRoomWithSelectedUsers(
val selections: Set<PendingSelection> val selections: Set<PendingSelection>
) : CreateDirectRoomAction() ) : CreateDirectRoomAction()
object CreateRoomAndInviteSelectedUsers : CreateDirectRoomAction()
data class QrScannedAction( data class QrScannedAction(
val result: String val result: String
) : CreateDirectRoomAction() ) : CreateDirectRoomAction()

View File

@ -161,7 +161,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
} }
private fun handleOnMenuItemSubmitClick(action: UserListSharedAction.OnMenuItemSubmitClick) { private fun handleOnMenuItemSubmitClick(action: UserListSharedAction.OnMenuItemSubmitClick) {
viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(action.selections)) viewModel.handle(CreateDirectRoomAction.PrepareRoomWithSelectedUsers(action.selections))
} }
private fun renderCreateAndInviteState(state: Async<String>) { private fun renderCreateAndInviteState(state: Async<String>) {

View File

@ -26,6 +26,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.mvrx.runCatchingToAsync import im.vector.app.core.mvrx.runCatchingToAsync
import im.vector.app.core.platform.VectorViewModel 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.AnalyticsTracker
import im.vector.app.features.analytics.plan.CreatedRoom import im.vector.app.features.analytics.plan.CreatedRoom
import im.vector.app.features.raw.wellknown.getElementWellknown import im.vector.app.features.raw.wellknown.getElementWellknown
@ -46,7 +47,8 @@ class CreateDirectRoomViewModel @AssistedInject constructor(
@Assisted initialState: CreateDirectRoomViewState, @Assisted initialState: CreateDirectRoomViewState,
private val rawService: RawService, private val rawService: RawService,
val session: Session, val session: Session,
val analyticsTracker: AnalyticsTracker val analyticsTracker: AnalyticsTracker,
val vectorFeatures: VectorFeatures
) : ) :
VectorViewModel<CreateDirectRoomViewState, CreateDirectRoomAction, CreateDirectRoomViewEvents>(initialState) { VectorViewModel<CreateDirectRoomViewState, CreateDirectRoomAction, CreateDirectRoomViewEvents>(initialState) {
@ -59,7 +61,8 @@ class CreateDirectRoomViewModel @AssistedInject constructor(
override fun handle(action: CreateDirectRoomAction) { override fun handle(action: CreateDirectRoomAction) {
when (action) { when (action) {
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onSubmitInvitees(action.selections) is CreateDirectRoomAction.PrepareRoomWithSelectedUsers -> onSubmitInvitees(action.selections)
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onCreateRoomWithInvitees()
is CreateDirectRoomAction.QrScannedAction -> onCodeParsed(action) is CreateDirectRoomAction.QrScannedAction -> onCodeParsed(action)
} }
} }
@ -94,16 +97,18 @@ class CreateDirectRoomViewModel @AssistedInject constructor(
} }
if (existingRoomId != null) { if (existingRoomId != null) {
// Do not create a new DM, just tell that the creation is successful by passing the existing roomId // Do not create a new DM, just tell that the creation is successful by passing the existing roomId
setState { setState { copy(createAndInviteState = Success(existingRoomId)) }
copy(createAndInviteState = Success(existingRoomId))
}
} else { } else {
// Create the DM createLocalRoomWithSelectedUsers(selections)
createRoomAndInviteSelectedUsers(selections)
} }
} }
private fun createRoomAndInviteSelectedUsers(selections: Set<PendingSelection>) { private fun onCreateRoomWithInvitees() {
// Create the DM
withState { createLocalRoomWithSelectedUsers(it.pendingSelections) }
}
private fun createLocalRoomWithSelectedUsers(selections: Set<PendingSelection>) {
setState { copy(createAndInviteState = Loading()) } setState { copy(createAndInviteState = Loading()) }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@ -124,7 +129,11 @@ class CreateDirectRoomViewModel @AssistedInject constructor(
} }
val result = runCatchingToAsync { 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())) analyticsTracker.capture(CreatedRoom(isDM = roomParams.isDirect.orFalse()))

View File

@ -19,7 +19,9 @@ package im.vector.app.features.createdirect
import com.airbnb.mvrx.Async import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.userdirectory.PendingSelection
data class CreateDirectRoomViewState( data class CreateDirectRoomViewState(
val pendingSelections: Set<PendingSelection> = emptySet(),
val createAndInviteState: Async<String> = Uninitialized val createAndInviteState: Async<String> = Uninitialized
) : MavericksState ) : MavericksState

View File

@ -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.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary 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.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.room.sender.SenderInfo
import org.matrix.android.sdk.api.session.sync.SyncRequestState import org.matrix.android.sdk.api.session.sync.SyncRequestState
import org.matrix.android.sdk.api.session.sync.SyncState import org.matrix.android.sdk.api.session.sync.SyncState
@ -103,4 +104,6 @@ data class RoomDetailViewState(
fun isDm() = asyncRoomSummary()?.isDirect == true fun isDm() = asyncRoomSummary()?.isDirect == true
fun isThreadTimeline() = rootThreadEventId != null fun isThreadTimeline() = rootThreadEventId != null
fun isLocalRoom() = RoomLocalEcho.isLocalEchoId(roomId)
} }

View File

@ -1430,6 +1430,9 @@ class TimelineFragment @Inject constructor(
updateJumpToReadMarkerViewVisibility() updateJumpToReadMarkerViewVisibility()
jumpToBottomViewVisibilityManager.maybeShowJumpToBottomViewVisibilityWithDelay() 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() val stateRestorer = LayoutManagerStateRestorer(layoutManager).register()
scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager, timelineEventController) scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager, timelineEventController)
@ -1701,31 +1704,41 @@ class TimelineFragment @Inject constructor(
} }
private fun renderToolbar(roomSummary: RoomSummary?) { private fun renderToolbar(roomSummary: RoomSummary?) {
if (!isThreadTimeLine()) { when {
views.includeRoomToolbar.roomToolbarContentView.isVisible = true isLocalRoom() -> {
views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = false views.includeRoomToolbar.roomToolbarContentView.isVisible = false
if (roomSummary == null) { views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = false
views.includeRoomToolbar.roomToolbarContentView.isClickable = false setupToolbar(views.roomToolbar)
} else { .setTitle(R.string.room_member_open_or_create_dm)
views.includeRoomToolbar.roomToolbarContentView.isClickable = roomSummary.membership == Membership.JOIN .allowBack(useCross = true)
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
} }
} else { isThreadTimeLine() -> {
views.includeRoomToolbar.roomToolbarContentView.isVisible = false views.includeRoomToolbar.roomToolbarContentView.isVisible = false
views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = true views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = true
timelineArgs.threadTimelineArgs?.let { timelineArgs.threadTimelineArgs?.let {
val matrixItem = MatrixItem.RoomItem(it.roomId, it.displayName, it.avatarUrl) val matrixItem = MatrixItem.RoomItem(it.roomId, it.displayName, it.avatarUrl)
avatarRenderer.render(matrixItem, views.includeThreadToolbar.roomToolbarThreadImageView) avatarRenderer.render(matrixItem, views.includeThreadToolbar.roomToolbarThreadImageView)
views.includeThreadToolbar.roomToolbarThreadShieldImageView.render(it.roomEncryptionTrustLevel) views.includeThreadToolbar.roomToolbarThreadShieldImageView.render(it.roomEncryptionTrustLevel)
views.includeThreadToolbar.roomToolbarThreadSubtitleTextView.text = it.displayName 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 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. * Returns the root thread event if we are in a thread room, otherwise returns null.
*/ */

View File

@ -735,26 +735,30 @@ class TimelineViewModel @AssistedInject constructor(
return@withState false return@withState false
} }
if (initialState.isThreadTimeline()) { when {
when (itemId) { initialState.isLocalRoom() -> false
R.id.menu_thread_timeline_view_in_room, initialState.isThreadTimeline() -> {
R.id.menu_thread_timeline_copy_link, when (itemId) {
R.id.menu_thread_timeline_share -> true R.id.menu_thread_timeline_view_in_room,
else -> false R.id.menu_thread_timeline_copy_link,
R.id.menu_thread_timeline_share -> true
else -> false
}
} }
} else { else -> {
when (itemId) { when (itemId) {
R.id.timeline_setting -> true R.id.timeline_setting -> true
R.id.invite -> state.canInvite R.id.invite -> state.canInvite
R.id.open_matrix_apps -> true R.id.open_matrix_apps -> true
R.id.voice_call -> state.isCallOptionAvailable() R.id.voice_call -> state.isCallOptionAvailable()
R.id.video_call -> state.isCallOptionAvailable() || state.jitsiState.confId == null || state.jitsiState.hasJoined 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. ^ // 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.join_conference -> !state.isCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined
R.id.search -> state.isSearchAvailable() R.id.search -> state.isSearchAvailable()
R.id.menu_timeline_thread_list -> vectorPreferences.areThreadMessagesEnabled() R.id.menu_timeline_thread_list -> vectorPreferences.areThreadMessagesEnabled()
R.id.dev_tools -> vectorPreferences.developerMode() R.id.dev_tools -> vectorPreferences.developerMode()
else -> false else -> false
}
} }
} }
} }

View File

@ -603,12 +603,13 @@ class TimelineEventController @Inject constructor(
} }
private fun wantsDateSeparator(event: TimelineEvent, nextEvent: TimelineEvent?): Boolean { private fun wantsDateSeparator(event: TimelineEvent, nextEvent: TimelineEvent?): Boolean {
return if (hasReachedInvite && hasUTD) { return when {
true hasReachedInvite && hasUTD -> true
} else { else -> {
val date = event.root.localDateTime() val date = event.root.localDateTime()
val nextDate = nextEvent?.root?.localDateTime() val nextDate = nextEvent?.root?.localDateTime()
date.toLocalDate() != nextDate?.toLocalDate() date.toLocalDate() != nextDate?.toLocalDate()
}
} }
} }

View File

@ -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.content.EncryptionEventContent
import org.matrix.android.sdk.api.session.events.model.toModel 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.getRoomSummary
import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho
import javax.inject.Inject import javax.inject.Inject
class EncryptionItemFactory @Inject constructor( class EncryptionItemFactory @Inject constructor(
@ -55,12 +56,19 @@ class EncryptionItemFactory @Inject constructor(
val description: String val description: String
val shield: StatusTileTimelineItem.ShieldUIState val shield: StatusTileTimelineItem.ShieldUIState
if (isSafeAlgorithm) { if (isSafeAlgorithm) {
val isDirect = session.getRoomSummary(event.root.roomId.orEmpty())?.isDirect.orFalse()
title = stringProvider.getString(R.string.encryption_enabled) title = stringProvider.getString(R.string.encryption_enabled)
description = stringProvider.getString( description = stringProvider.getString(
if (session.getRoomSummary(event.root.roomId ?: "")?.isDirect.orFalse()) { when {
R.string.direct_room_encryption_enabled_tile_description isDirect && RoomLocalEcho.isLocalEchoId(event.root.roomId.orEmpty()) -> {
} else { R.string.direct_room_encryption_enabled_tile_description_future
R.string.encryption_enabled_tile_description }
isDirect -> {
R.string.direct_room_encryption_enabled_tile_description
}
else -> {
R.string.encryption_enabled_tile_description
}
} }
) )
shield = StatusTileTimelineItem.ShieldUIState.BLACK shield = StatusTileTimelineItem.ShieldUIState.BLACK

View File

@ -117,6 +117,7 @@ class MergedHeaderItemFactory @Inject constructor(
highlighted = true highlighted = true
} }
val data = BasedMergedItem.Data( val data = BasedMergedItem.Data(
roomId = mergedEvent.root.roomId,
userId = mergedEvent.root.senderId ?: "", userId = mergedEvent.root.senderId ?: "",
avatarUrl = mergedEvent.senderInfo.avatarUrl, avatarUrl = mergedEvent.senderInfo.avatarUrl,
memberName = mergedEvent.senderInfo.disambiguatedDisplayName, memberName = mergedEvent.senderInfo.disambiguatedDisplayName,
@ -199,6 +200,7 @@ class MergedHeaderItemFactory @Inject constructor(
highlighted = true highlighted = true
} }
val data = BasedMergedItem.Data( val data = BasedMergedItem.Data(
roomId = mergedEvent.root.roomId,
userId = mergedEvent.root.senderId ?: "", userId = mergedEvent.root.senderId ?: "",
avatarUrl = mergedEvent.senderInfo.avatarUrl, avatarUrl = mergedEvent.senderInfo.avatarUrl,
memberName = mergedEvent.senderInfo.disambiguatedDisplayName, memberName = mergedEvent.senderInfo.disambiguatedDisplayName,

View File

@ -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.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.Membership 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.RoomMemberContent
import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject import javax.inject.Inject
@ -176,6 +177,13 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
return true 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 // Allow only the the threads within the rootThreadEventId along with the root event
if (userPreferencesProvider.areThreadMessagesEnabled() && isFromThreadTimeline) { if (userPreferencesProvider.areThreadMessagesEnabled() && isFromThreadTimeline) {
return if (root.getRootThreadEventId() == rootThreadEventId) { return if (root.getRootThreadEventId() == rootThreadEventId) {

View File

@ -55,6 +55,7 @@ abstract class BasedMergedItem<H : BasedMergedItem.Holder>(@LayoutRes layoutId:
} }
data class Data( data class Data(
val roomId: String?,
val localId: Long, val localId: Long,
val eventId: String, val eventId: String,
val userId: String, val userId: String,

View File

@ -25,9 +25,9 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.widget.TextViewCompat
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R 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.RoomDetailAction
import im.vector.app.features.home.room.detail.timeline.TimelineEventController 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.home.room.detail.timeline.tools.linkify
import im.vector.app.features.themes.ThemeUtils
import me.gujun.android.span.span 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.RoomSummary
import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
@EpoxyModelClass @EpoxyModelClass
@ -51,126 +53,123 @@ abstract class MergedRoomCreationItem : BasedMergedItem<MergedRoomCreationItem.H
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var movementMethod: MovementMethod? = null var movementMethod: MovementMethod? = null
private val roomSummary
get() = attributes.roomSummary
private val isDirectRoom
get() = distinctMergeData.lastOrNull()?.isDirectRoom
?: roomSummary?.isDirect
?: false
override fun getViewStubId() = STUB_ID override fun getViewStubId() = STUB_ID
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
bindCreationSummaryTile(holder) bindCreationSummaryTile(holder)
bindMergedViews(holder)
}
private fun bindMergedViews(holder: Holder) {
holder.mergedView.isVisible = !attributes.isLocalRoom
if (attributes.isCollapsed) { if (attributes.isCollapsed) {
// Take the oldest data // Take the oldest data
val data = distinctMergeData.lastOrNull() val data = distinctMergeData.lastOrNull()
renderSummaryText(holder, data)
val createdFromCurrentUser = data?.userId == attributes.currentUserId
val summary = if (createdFromCurrentUser) {
if (data?.isDirectRoom == true) {
holder.expandView.resources.getString(R.string.direct_room_created_summary_item_by_you)
} else {
holder.expandView.resources.getString(R.string.room_created_summary_item_by_you)
}
} else {
if (data?.isDirectRoom == true) {
holder.expandView.resources.getString(R.string.direct_room_created_summary_item, data.memberName)
} else {
holder.expandView.resources.getString(R.string.room_created_summary_item, data?.memberName ?: data?.userId ?: "")
}
}
holder.summaryView.text = summary
holder.summaryView.visibility = View.VISIBLE holder.summaryView.visibility = View.VISIBLE
holder.avatarView.visibility = View.VISIBLE
if (data != null) { if (data != null) {
holder.avatarView.visibility = View.VISIBLE holder.avatarView.visibility = View.VISIBLE
attributes.avatarRenderer.render(data.toMatrixItem(), holder.avatarView) attributes.avatarRenderer.render(data.toMatrixItem(), holder.avatarView)
} else { } else {
holder.avatarView.visibility = View.GONE holder.avatarView.visibility = View.GONE
} }
bindEncryptionTile(holder)
bindEncryptionTile(holder, data)
} else { } else {
holder.avatarView.visibility = View.INVISIBLE holder.avatarView.visibility = View.INVISIBLE
holder.summaryView.visibility = View.GONE holder.summaryView.visibility = View.GONE
holder.encryptionTile.isGone = true holder.encryptionTile.visibility = View.GONE
} }
} }
private fun bindEncryptionTile(holder: Holder, data: Data?) { private fun renderSummaryText(holder: Holder, data: Data?) {
val resources = holder.expandView.resources
val createdFromCurrentUser = data?.userId == attributes.currentUserId
val summary = if (createdFromCurrentUser) {
if (isDirectRoom) {
resources.getString(R.string.direct_room_created_summary_item_by_you)
} else {
resources.getString(R.string.room_created_summary_item_by_you)
}
} else {
if (isDirectRoom) {
resources.getString(R.string.direct_room_created_summary_item, data?.memberName.orEmpty())
} else {
resources.getString(R.string.room_created_summary_item, data?.memberName.orEmpty())
}
}
holder.summaryView.text = summary
}
private fun bindEncryptionTile(holder: Holder) {
if (attributes.hasEncryptionEvent) { if (attributes.hasEncryptionEvent) {
holder.encryptionTile.isVisible = true holder.encryptionTile.isVisible = true
holder.encryptionTile.updateLayoutParams<ConstraintLayout.LayoutParams> { holder.encryptionTile.updateLayoutParams<ConstraintLayout.LayoutParams> {
this.marginEnd = leftGuideline this.marginEnd = leftGuideline
} }
if (attributes.isEncryptionAlgorithmSecure) { if (attributes.isEncryptionAlgorithmSecure) {
holder.e2eTitleTextView.text = holder.expandView.resources.getString(R.string.encryption_enabled) renderE2ESecureTile(holder)
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
)
} else { } else {
holder.e2eTitleTextView.text = holder.expandView.resources.getString(R.string.encryption_not_enabled) renderE2EUnsecureTile(holder)
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
)
} }
} else { } else {
holder.encryptionTile.isVisible = false 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) { private fun bindCreationSummaryTile(holder: Holder) {
val roomSummary = attributes.roomSummary
val roomDisplayName = roomSummary?.displayName val roomDisplayName = roomSummary?.displayName
holder.roomNameText.setTextOrHide(roomDisplayName)
val isDirect = roomSummary?.isDirect == true
val membersCount = roomSummary?.otherMemberIds?.size ?: 0 val membersCount = roomSummary?.otherMemberIds?.size ?: 0
if (isDirect) { holder.roomNameText.setTextOrHide(roomDisplayName)
holder.roomDescriptionText.text = holder.view.resources.getString( renderRoomDescription(holder)
R.string.this_is_the_beginning_of_dm, renderRoomTopic(holder)
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
val roomItem = roomSummary?.toMatrixItem() val roomItem = roomSummary?.toMatrixItem()
val shouldSetAvatar = attributes.canChangeAvatar && val shouldSetAvatar = attributes.canChangeAvatar &&
(roomSummary?.isDirect == false || (isDirect && membersCount >= 2)) && (roomSummary?.isDirect == false || (isDirectRoom && membersCount >= 2)) &&
roomItem?.avatarUrl.isNullOrBlank() roomItem?.avatarUrl.isNullOrBlank()
holder.roomAvatarImageView.isVisible = roomItem != null holder.roomAvatarImageView.isVisible = roomItem != null
@ -193,7 +192,7 @@ abstract class MergedRoomCreationItem : BasedMergedItem<MergedRoomCreationItem.H
} }
} }
val canInvite = attributes.canInvite && !isDirect val canInvite = attributes.canInvite && !isDirectRoom
holder.addPeopleButton.isVisible = canInvite holder.addPeopleButton.isVisible = canInvite
if (canInvite) { if (canInvite) {
holder.addPeopleButton.onClick { holder.addPeopleButton.onClick {
@ -202,7 +201,60 @@ abstract class MergedRoomCreationItem : BasedMergedItem<MergedRoomCreationItem.H
} }
} }
private fun renderRoomDescription(holder: Holder) {
val roomDisplayName = roomSummary?.displayName
val resources = holder.roomDescriptionText.resources
val description = when {
isDirectRoom -> {
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) { class Holder : BasedMergedItem.Holder(STUB_ID) {
val mergedView by bind<View>(R.id.mergedSumContainer)
val summaryView by bind<TextView>(R.id.itemNoticeTextView) val summaryView by bind<TextView>(R.id.itemNoticeTextView)
val avatarView by bind<ImageView>(R.id.itemNoticeAvatarView) val avatarView by bind<ImageView>(R.id.itemNoticeAvatarView)
val encryptionTile by bind<ViewGroup>(R.id.creationEncryptionTile) val encryptionTile by bind<ViewGroup>(R.id.creationEncryptionTile)
@ -236,5 +288,8 @@ abstract class MergedRoomCreationItem : BasedMergedItem<MergedRoomCreationItem.H
val canChangeAvatar: Boolean = false, val canChangeAvatar: Boolean = false,
val canChangeName: Boolean = false, val canChangeName: Boolean = false,
val canChangeTopic: Boolean = false val canChangeTopic: Boolean = false
) : BasedMergedItem.Attributes ) : BasedMergedItem.Attributes {
val isLocalRoom = RoomLocalEcho.isLocalEchoId(roomSummary?.roomId.orEmpty())
}
} }

View File

@ -146,6 +146,10 @@ class RoomListFragment @Inject constructor(
(it.contentEpoxyController as? RoomSummaryPagedController)?.roomChangeMembershipStates = ms (it.contentEpoxyController as? RoomSummaryPagedController)?.roomChangeMembershipStates = ms
} }
} }
roomListViewModel.onEach(RoomListViewState::localRoomIds) {
// Local rooms should not exist anymore when the room list is shown
roomListViewModel.deleteLocalRooms(it)
}
} }
private fun refreshCollapseStates() { private fun refreshCollapseStates() {

View File

@ -48,7 +48,10 @@ import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.getRoomSummary import org.matrix.android.sdk.api.session.getRoomSummary
import org.matrix.android.sdk.api.session.room.UpdatableLivePageResult import org.matrix.android.sdk.api.session.room.UpdatableLivePageResult
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho
import org.matrix.android.sdk.api.session.room.model.tag.RoomTag import org.matrix.android.sdk.api.session.room.model.tag.RoomTag
import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams
import org.matrix.android.sdk.api.session.room.state.isPublic import org.matrix.android.sdk.api.session.room.state.isPublic
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.flow.flow import org.matrix.android.sdk.flow.flow
@ -96,6 +99,7 @@ class RoomListViewModel @AssistedInject constructor(
init { init {
observeMembershipChanges() observeMembershipChanges()
observeLocalRooms()
appStateHandler.selectedRoomGroupingFlow appStateHandler.selectedRoomGroupingFlow
.distinctUntilChanged() .distinctUntilChanged()
@ -123,6 +127,23 @@ class RoomListViewModel @AssistedInject constructor(
} }
} }
private fun observeLocalRooms() {
val queryParams = roomSummaryQueryParams {
memberships = listOf(Membership.JOIN)
}
session
.flow()
.liveRoomSummaries(queryParams)
.map { roomSummaries ->
roomSummaries.mapNotNull { summary ->
summary.roomId.takeIf { RoomLocalEcho.isLocalEchoId(it) }
}.toSet()
}
.setOnEach { roomIds ->
copy(localRoomIds = roomIds)
}
}
companion object : MavericksViewModelFactory<RoomListViewModel, RoomListViewState> by hiltMavericksViewModelFactory() companion object : MavericksViewModelFactory<RoomListViewModel, RoomListViewState> by hiltMavericksViewModelFactory()
private val roomListSectionBuilder = if (appStateHandler.getCurrentRoomGroupingMethod() is RoomGroupingMethod.BySpace) { private val roomListSectionBuilder = if (appStateHandler.getCurrentRoomGroupingMethod() is RoomGroupingMethod.BySpace) {
@ -173,6 +194,14 @@ class RoomListViewModel @AssistedInject constructor(
return session.getRoom(roomId)?.stateService()?.isPublic().orFalse() return session.getRoom(roomId)?.stateService()?.isPublic().orFalse()
} }
fun deleteLocalRooms(roomsIds: Set<String>) {
viewModelScope.launch {
roomsIds.forEach {
session.roomService().deleteLocalRoom(it)
}
}
}
// PRIVATE METHODS ***************************************************************************** // PRIVATE METHODS *****************************************************************************
private fun handleSelectRoom(action: RoomListAction.SelectRoom) = withState { private fun handleSelectRoom(action: RoomListAction.SelectRoom) = withState {

View File

@ -30,7 +30,8 @@ data class RoomListViewState(
val roomMembershipChanges: Map<String, ChangeMembershipState> = emptyMap(), val roomMembershipChanges: Map<String, ChangeMembershipState> = emptyMap(),
val asyncSuggestedRooms: Async<List<SpaceChildInfo>> = Uninitialized, val asyncSuggestedRooms: Async<List<SpaceChildInfo>> = Uninitialized,
val currentUserName: String? = null, val currentUserName: String? = null,
val currentRoomGrouping: Async<RoomGroupingMethod> = Uninitialized val currentRoomGrouping: Async<RoomGroupingMethod> = Uninitialized,
val localRoomIds: Set<String> = emptySet()
) : MavericksState { ) : MavericksState {
constructor(args: RoomListParams) : this(displayMode = args.displayMode) constructor(args: RoomListParams) : this(displayMode = args.displayMode)

View File

@ -15,7 +15,9 @@
android:id="@+id/userListToolbar" android:id="@+id/userListToolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?actionBarSize" 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" />
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
@ -94,4 +96,4 @@
app:layout_constraintTop_toBottomOf="@id/userListE2EbyDefaultDisabled" app:layout_constraintTop_toBottomOf="@id/userListE2EbyDefaultDisabled"
tools:listitem="@layout/item_known_user" /> tools:listitem="@layout/item_known_user" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -4,7 +4,7 @@
<item <item
android:id="@+id/action_create_direct_room" android:id="@+id/action_create_direct_room"
android:title="@string/create_room_action_create" android:title="@string/create_room_action_go"
app:showAsAction="always" /> app:showAsAction="always" />
</menu> </menu>

View File

@ -1624,6 +1624,7 @@
<!-- Create room screen --> <!-- Create room screen -->
<string name="create_room_action_create">"CREATE"</string> <string name="create_room_action_create">"CREATE"</string>
<string name="create_room_action_go">Go</string>
<string name="create_room_name_section">"Room name"</string> <string name="create_room_name_section">"Room name"</string>
<string name="create_room_name_hint">"Name"</string> <string name="create_room_name_hint">"Name"</string>
<string name="create_room_topic_section">"Room topic (optional)"</string> <string name="create_room_topic_section">"Room topic (optional)"</string>
@ -2402,7 +2403,8 @@
<string name="encryption_enabled">Encryption enabled</string> <string name="encryption_enabled">Encryption enabled</string>
<string name="encryption_enabled_tile_description">Messages in this room are end-to-end encrypted. Learn more &amp; verify users in their profile.</string> <string name="encryption_enabled_tile_description">Messages in this room are end-to-end encrypted. Learn more &amp; verify users in their profile.</string>
<string name="direct_room_encryption_enabled_tile_description">Messages in this room are end-to-end encrypted.</string> <string name="direct_room_encryption_enabled_tile_description">Messages in this chat are end-to-end encrypted.</string>
<string name="direct_room_encryption_enabled_tile_description_future">Messages in this chat will be end-to-end encrypted.</string>
<string name="encryption_not_enabled">Encryption not enabled</string> <string name="encryption_not_enabled">Encryption not enabled</string>
<string name="encryption_misconfigured">Encryption is misconfigured</string> <string name="encryption_misconfigured">Encryption is misconfigured</string>
<string name="encryption_unknown_algorithm_tile_description">The encryption used by this room is not supported</string> <string name="encryption_unknown_algorithm_tile_description">The encryption used by this room is not supported</string>
@ -2414,6 +2416,7 @@
<string name="this_is_the_beginning_of_room">This is the beginning of %s.</string> <string name="this_is_the_beginning_of_room">This is the beginning of %s.</string>
<string name="this_is_the_beginning_of_room_no_name">This is the beginning of this conversation.</string> <string name="this_is_the_beginning_of_room_no_name">This is the beginning of this conversation.</string>
<string name="this_is_the_beginning_of_dm">This is the beginning of your direct message history with %s.</string> <string name="this_is_the_beginning_of_dm">This is the beginning of your direct message history with %s.</string>
<string name="send_your_first_msg_to_invite">Send your first message to invite %s to chat</string>
<!-- First param will be replaced by the value of add_a_topic_link_text, that will be clickable--> <!-- First param will be replaced by the value of add_a_topic_link_text, that will be clickable-->
<string name="room_created_summary_no_topic_creation_text">%s to let people know what this room is about.</string> <string name="room_created_summary_no_topic_creation_text">%s to let people know what this room is about.</string>
<string name="add_a_topic_link_text">Add a topic</string> <string name="add_a_topic_link_text">Add a topic</string>