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
/**
* 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.
*/

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.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(
realm: Realm,
roomId: String,
type: String
): RealmQuery<CurrentStateEventEntity> {
return realm.where(CurrentStateEventEntity::class.java)
.equalTo(CurrentStateEventEntityFields.ROOM_ID, roomId)
return whereRoomId(realm = realm, roomId = roomId)
.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.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)
}

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.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

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")
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 {
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
}
}

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,
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)
?: vectorFeatures.forceUsageOfOpusEncoder()
override fun shouldStartDmOnFirstMessage(): Boolean = read(DebugFeatureKeys.startDmOnFirstMsg)
?: vectorFeatures.shouldStartDmOnFirstMessage()
fun <T> override(value: T?, key: Preferences.Key<T>) = 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")
}

View file

@ -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
}

View file

@ -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<PendingSelection>
) : CreateDirectRoomAction()
object CreateRoomAndInviteSelectedUsers : CreateDirectRoomAction()
data class QrScannedAction(
val result: String
) : CreateDirectRoomAction()

View file

@ -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<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.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<CreateDirectRoomViewState, CreateDirectRoomAction, CreateDirectRoomViewEvents>(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<PendingSelection>) {
private fun onCreateRoomWithInvitees() {
// Create the DM
withState { createLocalRoomWithSelectedUsers(it.pendingSelections) }
}
private fun createLocalRoomWithSelectedUsers(selections: Set<PendingSelection>) {
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()))

View file

@ -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<PendingSelection> = emptySet(),
val createAndInviteState: Async<String> = Uninitialized
) : 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.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)
}

View file

@ -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.
*/

View file

@ -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
}
}
}
}

View file

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

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.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

View file

@ -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,

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.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) {

View file

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

View file

@ -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<MergedRoomCreationItem.H
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
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 bind(holder: Holder) {
super.bind(holder)
bindCreationSummaryTile(holder)
bindMergedViews(holder)
}
private fun bindMergedViews(holder: Holder) {
holder.mergedView.isVisible = !attributes.isLocalRoom
if (attributes.isCollapsed) {
// Take the oldest data
val data = distinctMergeData.lastOrNull()
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
renderSummaryText(holder, data)
holder.summaryView.visibility = View.VISIBLE
holder.avatarView.visibility = View.VISIBLE
if (data != null) {
holder.avatarView.visibility = View.VISIBLE
attributes.avatarRenderer.render(data.toMatrixItem(), holder.avatarView)
} else {
holder.avatarView.visibility = View.GONE
}
bindEncryptionTile(holder, data)
bindEncryptionTile(holder)
} else {
holder.avatarView.visibility = View.INVISIBLE
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) {
holder.encryptionTile.isVisible = true
holder.encryptionTile.updateLayoutParams<ConstraintLayout.LayoutParams> {
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<MergedRoomCreationItem.H
}
}
val canInvite = attributes.canInvite && !isDirect
val canInvite = attributes.canInvite && !isDirectRoom
holder.addPeopleButton.isVisible = canInvite
if (canInvite) {
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) {
val mergedView by bind<View>(R.id.mergedSumContainer)
val summaryView by bind<TextView>(R.id.itemNoticeTextView)
val avatarView by bind<ImageView>(R.id.itemNoticeAvatarView)
val encryptionTile by bind<ViewGroup>(R.id.creationEncryptionTile)
@ -236,5 +288,8 @@ abstract class MergedRoomCreationItem : BasedMergedItem<MergedRoomCreationItem.H
val canChangeAvatar: Boolean = false,
val canChangeName: 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
}
}
roomListViewModel.onEach(RoomListViewState::localRoomIds) {
// Local rooms should not exist anymore when the room list is shown
roomListViewModel.deleteLocalRooms(it)
}
}
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.room.UpdatableLivePageResult
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.roomSummaryQueryParams
import org.matrix.android.sdk.api.session.room.state.isPublic
import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.flow.flow
@ -96,6 +99,7 @@ class RoomListViewModel @AssistedInject constructor(
init {
observeMembershipChanges()
observeLocalRooms()
appStateHandler.selectedRoomGroupingFlow
.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()
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<String>) {
viewModelScope.launch {
roomsIds.forEach {
session.roomService().deleteLocalRoom(it)
}
}
}
// PRIVATE METHODS *****************************************************************************
private fun handleSelectRoom(action: RoomListAction.SelectRoom) = withState {

View file

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

View file

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

View file

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

View file

@ -1624,6 +1624,7 @@
<!-- Create room screen -->
<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_hint">"Name"</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_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_misconfigured">Encryption is misconfigured</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_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="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-->
<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>