Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Start DM on first message (UI) #6051

Merged
merged 31 commits into from
Jul 12, 2022
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c9ab092
Start DM - Add feature flag
May 11, 2022
ba3d350
Fix VectorFeature pref key
May 11, 2022
26d1a12
Start DM - Rename action button to "go"
Mar 16, 2022
10d683a
Start DM - display a local room before creating the real one
Mar 17, 2022
7ea2d0a
Delete the local rooms when the room list is shown
May 12, 2022
e86f919
Display timeline from the top of the screen for local rooms
May 13, 2022
b144bac
Update wordings for local room timeline
May 13, 2022
a46d7ed
Hide unwanted events from local room timeline
May 13, 2022
554d35f
Add changelog
May 20, 2022
398e98a
Remove useless variable
May 23, 2022
d42a3da
Reduce code smell
Jun 14, 2022
3f087eb
Merge branch 'develop' into feature/fre/start_dm_on_first_msg
Jun 30, 2022
33a19c1
Merge branch 'develop' into feature/fre/start_dm_on_first_msg
Jul 1, 2022
71320e4
Show date separator in local room timeline
Jul 1, 2022
c7db896
Split code in MergedRoomCreationItem
Jun 30, 2022
7415623
Update room description style
Jun 30, 2022
0dad4cb
Set current ts for local events age
Jul 1, 2022
c25edfa
Remove unused imports
Jul 1, 2022
8a5a47c
Ensure that Realm is up to date before returning the roomId
Jul 4, 2022
f4b50f1
Fix logs when deleting local room
Jul 4, 2022
0d9cd2b
Delete local room related entities with cascade
Jul 4, 2022
e89bb0e
Set local room members as loaded
Jul 4, 2022
2b6bfc1
Create local events using local echo
Jul 4, 2022
414dc52
Fix copyright date
Jul 4, 2022
68bd55f
Merge branch 'develop' into feature/fre/start_dm_on_first_msg
Jul 11, 2022
fb87d31
Update the title of the local room timeline
Jul 11, 2022
a10a8ce
Add margin after the action button of the user list toolbar
Jul 11, 2022
fdb9ed8
Change method visibility
Jul 11, 2022
fca4df3
Revert "Ensure that Realm is up to date before returning the roomId"
Jul 11, 2022
53eb852
Merge branch 'develop' into feature/fre/start_dm_on_first_msg
Jul 12, 2022
b8cdf9d
Merge branch 'develop' into feature/fre/start_dm_on_first_msg
Jul 12, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/5525.wip
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Create DM room only on first message - Design implementation & debug feature flag
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

@ouchadam ouchadam May 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thinking out loud about about the api design~ not a blocker!

from a consumer api perspective, are local rooms semantically the same as normal rooms? would we handle interacting with rooms differently if they're normal vs local?

I'm wondering if CreateRoomParams could have a isLocal option and reusing createRoom and leaveRoom 🤔


/**
* 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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright 2020 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."

fun isLocalEchoId(roomId: String) = roomId.startsWith(PREFIX)

fun createLocalEchoId() = "${PREFIX}${UUID.randomUUID()}"
}
Florian14 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
/*
* 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.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.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.UUID
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think we need to wait in this case. We are not waiting for a sync response including the new room.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bmarty I agree with you but during my tests, I had a frequent crash because the roomId was returned before having the object in DB. Adam did the same comment, did I miss something?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just replacing with this line can help (I let you do some test):

        // Ensure that Realm is up to date before returning the roomId.
        monarchy.doWithRealm { it.refresh() }

try {
awaitNotEmptyResult(realmConfiguration, TimeUnit.MINUTES.toMillis(1L)) { realm ->
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for my understanding, are we relying on another task to asynchronously update the room entity? if possible it would be safer to sequentially run the tasks rather than rely on a timeout (is this a common pattern in the sdk?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ouchadam I agree with you, I'd like to remove it but I have a non-systematic but recurrent crash when I try to navigate to the local room (in the stacktrace, it seems that we try to navigate to un unknown room). It's a bit confusing because the awaitTransaction seems ok, I can log the entity before returning the roomId from the task. Did I miss something else?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not too familiar with this area, would need to delegate to @bmarty / @ganfra to answer that

it looks like other tasks are using the same pattern of awaitNotEmptyResult, not a blocker for me, was curious 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second task is waiting to get the room from the sync but in the current case, the room is created locally and put in DB, so we should not have to wait for it 😕

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the cross conversations, I did not see @ouchadam 's comments at first sight.
Please see my suggestion at #6051 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just replacing with this line can help (I let you do some test):

        // Ensure that Realm is up to date before returning the roomId.
        monarchy.doWithRealm { it.refresh() }

It continues crashing...

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

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 = createFakeEvent(
type = EventType.STATE_ROOM_CREATE,
content = RoomCreateContent(
creator = userId
).toContent()
)
val myRoomMemberEvent = createFakeEvent(
type = EventType.STATE_ROOM_MEMBER,
content = RoomMemberContent(
membership = Membership.JOIN,
displayName = myUser.displayName,
avatarUrl = myUser.avatarUrl
).toContent(),
stateKey = userId
)
val roomMemberEvents = invitedUsers.map {
createFakeEvent(
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 { createFakeEvent(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 createFakeEvent(type: String?, content: Content?, stateKey: String? = ""): Event {
Florian14 marked this conversation as resolved.
Show resolved Hide resolved
return Event(
type = type,
senderId = userId,
stateKey = stateKey,
content = content,
originServerTs = clock.epochMillis(),
eventId = UUID.randomUUID().toString()
Florian14 marked this conversation as resolved.
Show resolved Hide resolved
)
}

/**
* 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
}
}
Loading