Merge pull request #3092 from vector-im/feature/bca/paged_room_list

Room List performance PR (use Live PagedList via Monarchy)
This commit is contained in:
Benoit Marty 2021-04-06 14:13:47 +02:00 committed by GitHub
commit 3109d111a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1233 additions and 608 deletions

View File

@ -14,6 +14,7 @@ Improvements 🙌:
- Update reactions to Unicode 13.1 (#2998)
- Be more robust when parsing some enums
- Improve timeline filtering (dissociate membership and profile events, display hidden events when highlighted, fix hidden item/read receipts behavior)
- Room list improvements (paging)
Bugfix 🐛:
- Fix bad theme change for the MainActivity

View File

@ -0,0 +1,24 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.query
enum class RoomCategoryFilter {
ONLY_DM,
ONLY_ROOMS,
ONLY_WITH_NOTIFICATIONS,
ALL
}

View File

@ -0,0 +1,23 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.query
data class RoomTagQueryFilter(
val isFavorite: Boolean?,
val isLowPriority: Boolean?,
val isServerNotice: Boolean?
)

View File

@ -17,6 +17,7 @@
package org.matrix.android.sdk.api.session.room
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
@ -24,6 +25,7 @@ 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.create.CreateRoomParams
import org.matrix.android.sdk.api.session.room.peeking.PeekResult
import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount
import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.session.room.alias.RoomAliasDescription
@ -178,4 +180,29 @@ interface RoomService {
* This call will try to gather some information on this room, but it could fail and get nothing more
*/
fun peekRoom(roomIdOrAlias: String, callback: MatrixCallback<PeekResult>)
/**
* TODO Doc
*/
fun getPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams,
pagedListConfig: PagedList.Config = defaultPagedListConfig): LiveData<PagedList<RoomSummary>>
/**
* TODO Doc
*/
fun getFilteredPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams,
pagedListConfig: PagedList.Config = defaultPagedListConfig): UpdatableFilterLivePageResult
/**
* TODO Doc
*/
fun getNotificationCountForRooms(queryParams: RoomSummaryQueryParams): RoomAggregateNotificationCount
private val defaultPagedListConfig
get() = PagedList.Config.Builder()
.setPageSize(10)
.setInitialLoadSizeHint(20)
.setEnablePlaceholders(false)
.setPrefetchDistance(10)
.build()
}

View File

@ -17,6 +17,8 @@
package org.matrix.android.sdk.api.session.room
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.query.RoomCategoryFilter
import org.matrix.android.sdk.api.query.RoomTagQueryFilter
import org.matrix.android.sdk.api.session.room.model.Membership
fun roomSummaryQueryParams(init: (RoomSummaryQueryParams.Builder.() -> Unit) = {}): RoomSummaryQueryParams {
@ -31,7 +33,9 @@ data class RoomSummaryQueryParams(
val roomId: QueryStringValue,
val displayName: QueryStringValue,
val canonicalAlias: QueryStringValue,
val memberships: List<Membership>
val memberships: List<Membership>,
val roomCategoryFilter: RoomCategoryFilter?,
val roomTagQueryFilter: RoomTagQueryFilter?
) {
class Builder {
@ -40,12 +44,16 @@ data class RoomSummaryQueryParams(
var displayName: QueryStringValue = QueryStringValue.IsNotEmpty
var canonicalAlias: QueryStringValue = QueryStringValue.NoCondition
var memberships: List<Membership> = Membership.all()
var roomCategoryFilter: RoomCategoryFilter? = RoomCategoryFilter.ALL
var roomTagQueryFilter: RoomTagQueryFilter? = null
fun build() = RoomSummaryQueryParams(
roomId = roomId,
displayName = displayName,
canonicalAlias = canonicalAlias,
memberships = memberships
memberships = memberships,
roomCategoryFilter = roomCategoryFilter,
roomTagQueryFilter = roomTagQueryFilter
)
}
}

View File

@ -1,11 +1,11 @@
/*
* Copyright 2019 New Vector Ltd
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* 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,
@ -14,12 +14,14 @@
* limitations under the License.
*/
package im.vector.app.features.home
package org.matrix.android.sdk.api.session.room
import im.vector.app.core.utils.BehaviorDataSource
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HomeRoomListDataSource @Inject constructor() : BehaviorDataSource<List<RoomSummary>>()
interface UpdatableFilterLivePageResult {
val livePagedList: LiveData<PagedList<RoomSummary>>
fun updateQuery(queryParams: RoomSummaryQueryParams)
}

View File

@ -0,0 +1,25 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.room.summary
data class RoomAggregateNotificationCount(
val notificationCount: Int,
val highlightCount: Int
) {
val totalCount = notificationCount + highlightCount
val isHighlight = highlightCount > 0
}

View File

@ -17,22 +17,27 @@
package org.matrix.android.sdk.internal.database
import io.realm.DynamicRealm
import io.realm.FieldAttribute
import io.realm.RealmMigration
import org.matrix.android.sdk.api.session.room.model.tag.RoomTag
import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntityFields
import org.matrix.android.sdk.internal.database.model.EditionOfEventFields
import org.matrix.android.sdk.internal.database.model.EventEntityFields
import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields
import org.matrix.android.sdk.internal.database.model.PendingThreePidEntityFields
import org.matrix.android.sdk.internal.database.model.PreviewUrlCacheEntityFields
import org.matrix.android.sdk.internal.database.model.RoomEntityFields
import org.matrix.android.sdk.internal.database.model.RoomMembersLoadStatusType
import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields
import org.matrix.android.sdk.internal.database.model.RoomTagEntityFields
import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import timber.log.Timber
import javax.inject.Inject
class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
companion object {
const val SESSION_STORE_SCHEMA_VERSION = 8L
const val SESSION_STORE_SCHEMA_VERSION = 9L
}
override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) {
@ -46,6 +51,7 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
if (oldVersion <= 5) migrateTo6(realm)
if (oldVersion <= 6) migrateTo7(realm)
if (oldVersion <= 7) migrateTo8(realm)
if (oldVersion <= 8) migrateTo9(realm)
}
private fun migrateTo1(realm: DynamicRealm) {
@ -149,4 +155,43 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
?.removeField("sourceLocalEchoEvents")
?.addRealmListField(EditAggregatedSummaryEntityFields.EDITIONS.`$`, editionOfEventSchema)
}
fun migrateTo9(realm: DynamicRealm) {
Timber.d("Step 8 -> 9")
realm.schema.get("RoomSummaryEntity")
?.addField(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Long::class.java, FieldAttribute.INDEXED)
?.setNullable(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, true)
?.addIndex(RoomSummaryEntityFields.MEMBERSHIP_STR)
?.addIndex(RoomSummaryEntityFields.IS_DIRECT)
?.addIndex(RoomSummaryEntityFields.VERSIONING_STATE_STR)
?.addField(RoomSummaryEntityFields.IS_FAVOURITE, Boolean::class.java)
?.addIndex(RoomSummaryEntityFields.IS_FAVOURITE)
?.addField(RoomSummaryEntityFields.IS_LOW_PRIORITY, Boolean::class.java)
?.addIndex(RoomSummaryEntityFields.IS_LOW_PRIORITY)
?.addField(RoomSummaryEntityFields.IS_SERVER_NOTICE, Boolean::class.java)
?.addIndex(RoomSummaryEntityFields.IS_SERVER_NOTICE)
?.transform { obj ->
val isFavorite = obj.getList(RoomSummaryEntityFields.TAGS.`$`).any {
it.getString(RoomTagEntityFields.TAG_NAME) == RoomTag.ROOM_TAG_FAVOURITE
}
obj.setBoolean(RoomSummaryEntityFields.IS_FAVOURITE, isFavorite)
val isLowPriority = obj.getList(RoomSummaryEntityFields.TAGS.`$`).any {
it.getString(RoomTagEntityFields.TAG_NAME) == RoomTag.ROOM_TAG_LOW_PRIORITY
}
obj.setBoolean(RoomSummaryEntityFields.IS_LOW_PRIORITY, isLowPriority)
// XXX migrate last message origin server ts
obj.getObject(RoomSummaryEntityFields.LATEST_PREVIEWABLE_EVENT.`$`)
?.getObject(TimelineEventEntityFields.ROOT.`$`)
?.getLong(EventEntityFields.ORIGIN_SERVER_TS)?.let {
obj.setLong(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, it)
}
}
}
}

View File

@ -26,7 +26,7 @@ internal class RoomSummaryMapper @Inject constructor(private val timelineEventMa
private val typingUsersTracker: DefaultTypingUsersTracker) {
fun map(roomSummaryEntity: RoomSummaryEntity): RoomSummary {
val tags = roomSummaryEntity.tags.map {
val tags = roomSummaryEntity.tags().map {
RoomTag(it.tagName, it.tagOrder)
}

View File

@ -16,61 +16,217 @@
package org.matrix.android.sdk.internal.database.model
import io.realm.RealmList
import io.realm.RealmObject
import io.realm.annotations.Index
import io.realm.annotations.PrimaryKey
import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.VersioningState
import io.realm.RealmList
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
import org.matrix.android.sdk.api.session.room.model.tag.RoomTag
internal open class RoomSummaryEntity(
@PrimaryKey var roomId: String = "",
var displayName: String? = "",
var avatarUrl: String? = "",
var name: String? = "",
var topic: String? = "",
var latestPreviewableEvent: TimelineEventEntity? = null,
var heroes: RealmList<String> = RealmList(),
var joinedMembersCount: Int? = 0,
var invitedMembersCount: Int? = 0,
var isDirect: Boolean = false,
var directUserId: String? = null,
var otherMemberIds: RealmList<String> = RealmList(),
var notificationCount: Int = 0,
var highlightCount: Int = 0,
var readMarkerId: String? = null,
var hasUnreadMessages: Boolean = false,
var tags: RealmList<RoomTagEntity> = RealmList(),
var userDrafts: UserDraftsEntity? = null,
var breadcrumbsIndex: Int = RoomSummary.NOT_IN_BREADCRUMBS,
var canonicalAlias: String? = null,
var aliases: RealmList<String> = RealmList(),
// this is required for querying
var flatAliases: String = "",
var isEncrypted: Boolean = false,
var encryptionEventTs: Long? = 0,
var roomEncryptionTrustLevelStr: String? = null,
var inviterId: String? = null,
var hasFailedSending: Boolean = false
@PrimaryKey var roomId: String = ""
) : RealmObject() {
var displayName: String? = ""
set(value) {
if (value != field) field = value
}
var avatarUrl: String? = ""
set(value) {
if (value != field) field = value
}
var name: String? = ""
set(value) {
if (value != field) field = value
}
var topic: String? = ""
set(value) {
if (value != field) field = value
}
var latestPreviewableEvent: TimelineEventEntity? = null
set(value) {
if (value != field) field = value
}
@Index
var lastActivityTime: Long? = null
set(value) {
if (value != field) field = value
}
var heroes: RealmList<String> = RealmList()
var joinedMembersCount: Int? = 0
set(value) {
if (value != field) field = value
}
var invitedMembersCount: Int? = 0
set(value) {
if (value != field) field = value
}
@Index
var isDirect: Boolean = false
set(value) {
if (value != field) field = value
}
var directUserId: String? = null
set(value) {
if (value != field) field = value
}
var otherMemberIds: RealmList<String> = RealmList()
var notificationCount: Int = 0
set(value) {
if (value != field) field = value
}
var highlightCount: Int = 0
set(value) {
if (value != field) field = value
}
var readMarkerId: String? = null
set(value) {
if (value != field) field = value
}
var hasUnreadMessages: Boolean = false
set(value) {
if (value != field) field = value
}
private var tags: RealmList<RoomTagEntity> = RealmList()
fun tags(): List<RoomTagEntity> = tags
fun updateTags(newTags: List<Pair<String, Double?>>) {
val toDelete = mutableListOf<RoomTagEntity>()
tags.forEach { existingTag ->
val updatedTag = newTags.firstOrNull { it.first == existingTag.tagName }
if (updatedTag == null) {
toDelete.add(existingTag)
} else {
existingTag.tagOrder = updatedTag.second
}
}
toDelete.forEach { it.deleteFromRealm() }
newTags.forEach { newTag ->
if (tags.all { it.tagName != newTag.first }) {
// we must add it
tags.add(
RoomTagEntity(newTag.first, newTag.second)
)
}
}
isFavourite = newTags.any { it.first == RoomTag.ROOM_TAG_FAVOURITE }
isLowPriority = newTags.any { it.first == RoomTag.ROOM_TAG_LOW_PRIORITY }
isServerNotice = newTags.any { it.first == RoomTag.ROOM_TAG_SERVER_NOTICE }
}
@Index
var isFavourite: Boolean = false
set(value) {
if (value != field) field = value
}
@Index
var isLowPriority: Boolean = false
set(value) {
if (value != field) field = value
}
@Index
var isServerNotice: Boolean = false
set(value) {
if (value != field) field = value
}
var userDrafts: UserDraftsEntity? = null
set(value) {
if (value != field) field = value
}
var breadcrumbsIndex: Int = RoomSummary.NOT_IN_BREADCRUMBS
set(value) {
if (value != field) field = value
}
var canonicalAlias: String? = null
set(value) {
if (value != field) field = value
}
var aliases: RealmList<String> = RealmList()
fun updateAliases(newAliases: List<String>) {
// only update underlying field if there is a diff
if (newAliases.distinct().sorted() != aliases.distinct().sorted()) {
aliases.clear()
aliases.addAll(newAliases)
flatAliases = newAliases.joinToString(separator = "|", prefix = "|")
}
}
// this is required for querying
var flatAliases: String = ""
var isEncrypted: Boolean = false
set(value) {
if (value != field) field = value
}
var encryptionEventTs: Long? = 0
set(value) {
if (value != field) field = value
}
var roomEncryptionTrustLevelStr: String? = null
set(value) {
if (value != field) field = value
}
var inviterId: String? = null
set(value) {
if (value != field) field = value
}
var hasFailedSending: Boolean = false
set(value) {
if (value != field) field = value
}
@Index
private var membershipStr: String = Membership.NONE.name
var membership: Membership
get() {
return Membership.valueOf(membershipStr)
}
set(value) {
membershipStr = value.name
if (value.name != membershipStr) {
membershipStr = value.name
}
}
@Index
private var versioningStateStr: String = VersioningState.NONE.name
var versioningState: VersioningState
get() {
return VersioningState.valueOf(versioningStateStr)
}
set(value) {
versioningStateStr = value.name
if (value.name != versioningStateStr) {
versioningStateStr = value.name
}
}
var roomEncryptionTrustLevel: RoomEncryptionTrustLevel?
@ -84,7 +240,9 @@ internal open class RoomSummaryEntity(
}
}
set(value) {
roomEncryptionTrustLevelStr = value?.name
if (value?.name != roomEncryptionTrustLevelStr) {
roomEncryptionTrustLevelStr = value?.name
}
}
companion object

View File

@ -18,17 +18,20 @@ package org.matrix.android.sdk.internal.session.room
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.paging.PagedList
import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.Room
import org.matrix.android.sdk.api.session.room.RoomService
import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams
import org.matrix.android.sdk.api.session.room.UpdatableFilterLivePageResult
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.create.CreateRoomParams
import org.matrix.android.sdk.api.session.room.peeking.PeekResult
import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount
import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
@ -96,6 +99,20 @@ internal class DefaultRoomService @Inject constructor(
return roomSummaryDataSource.getRoomSummariesLive(queryParams)
}
override fun getPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, pagedListConfig: PagedList.Config)
: LiveData<PagedList<RoomSummary>> {
return roomSummaryDataSource.getSortedPagedRoomSummariesLive(queryParams, pagedListConfig)
}
override fun getFilteredPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, pagedListConfig: PagedList.Config)
: UpdatableFilterLivePageResult {
return roomSummaryDataSource.getFilteredPagedRoomSummariesLive(queryParams, pagedListConfig)
}
override fun getNotificationCountForRooms(queryParams: RoomSummaryQueryParams): RoomAggregateNotificationCount {
return roomSummaryDataSource.getNotificationCountForRooms(queryParams)
}
override fun getBreadcrumbs(queryParams: RoomSummaryQueryParams): List<RoomSummary> {
return roomSummaryDataSource.getBreadcrumbs(queryParams)
}

View File

@ -17,18 +17,19 @@
package org.matrix.android.sdk.internal.session.room.create
import com.zhuinden.monarchy.Monarchy
import io.realm.Realm
import io.realm.RealmConfiguration
import kotlinx.coroutines.TimeoutCancellationException
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.MatrixError
import org.matrix.android.sdk.api.session.room.alias.RoomAliasError
import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams
import org.matrix.android.sdk.api.session.room.model.create.CreateRoomPreset
import org.matrix.android.sdk.internal.database.awaitNotEmptyResult
import org.matrix.android.sdk.internal.database.model.RoomEntity
import org.matrix.android.sdk.internal.database.model.RoomEntityFields
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.where
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
@ -96,12 +97,18 @@ internal class DefaultCreateRoomTask @Inject constructor(
// Wait for room to come back from the sync (but it can maybe be in the DB if the sync response is received before)
try {
awaitNotEmptyResult(realmConfiguration, TimeUnit.MINUTES.toMillis(1L)) { realm ->
realm.where(RoomEntity::class.java)
.equalTo(RoomEntityFields.ROOM_ID, roomId)
realm.where(RoomSummaryEntity::class.java)
.equalTo(RoomSummaryEntityFields.ROOM_ID, roomId)
.equalTo(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.JOIN.name)
}
} catch (exception: TimeoutCancellationException) {
throw CreateRoomFailure.CreatedWithTimeout
}
Realm.getInstance(realmConfiguration).executeTransactionAsync {
RoomSummaryEntity.where(it, roomId).findFirst()?.lastActivityTime = System.currentTimeMillis()
}
if (otherUserId != null) {
handleDirectChatCreation(roomId, otherUserId)
}

View File

@ -16,20 +16,23 @@
package org.matrix.android.sdk.internal.session.room.membership.joining
import io.realm.Realm
import io.realm.RealmConfiguration
import kotlinx.coroutines.TimeoutCancellationException
import org.matrix.android.sdk.api.session.room.failure.JoinRoomFailure
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.internal.database.awaitNotEmptyResult
import org.matrix.android.sdk.internal.database.model.RoomEntity
import org.matrix.android.sdk.internal.database.model.RoomEntityFields
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.where
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.session.room.RoomAPI
import org.matrix.android.sdk.internal.session.room.membership.RoomChangeMembershipStateDataSource
import org.matrix.android.sdk.internal.session.room.read.SetReadMarkersTask
import org.matrix.android.sdk.internal.task.Task
import io.realm.RealmConfiguration
import kotlinx.coroutines.TimeoutCancellationException
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@ -68,12 +71,18 @@ internal class DefaultJoinRoomTask @Inject constructor(
val roomId = joinRoomResponse.roomId
try {
awaitNotEmptyResult(realmConfiguration, TimeUnit.MINUTES.toMillis(1L)) { realm ->
realm.where(RoomEntity::class.java)
.equalTo(RoomEntityFields.ROOM_ID, roomId)
realm.where(RoomSummaryEntity::class.java)
.equalTo(RoomSummaryEntityFields.ROOM_ID, roomId)
.equalTo(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.JOIN.name)
}
} catch (exception: TimeoutCancellationException) {
throw JoinRoomFailure.JoinedWithTimeout
}
Realm.getInstance(realmConfiguration).executeTransactionAsync {
RoomSummaryEntity.where(it, roomId).findFirst()?.lastActivityTime = System.currentTimeMillis()
}
setReadMarkers(roomId)
}

View File

@ -18,10 +18,18 @@ package org.matrix.android.sdk.internal.session.room.summary
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import com.zhuinden.monarchy.Monarchy
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.Sort
import org.matrix.android.sdk.api.query.RoomCategoryFilter
import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams
import org.matrix.android.sdk.api.session.room.UpdatableFilterLivePageResult
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.VersioningState
import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
import org.matrix.android.sdk.internal.database.mapper.RoomSummaryMapper
@ -32,8 +40,6 @@ import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.query.process
import org.matrix.android.sdk.internal.util.fetchCopyMap
import io.realm.Realm
import io.realm.RealmQuery
import javax.inject.Inject
internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase private val monarchy: Monarchy,
@ -98,6 +104,62 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat
.sort(RoomSummaryEntityFields.BREADCRUMBS_INDEX)
}
fun getSortedPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams,
pagedListConfig: PagedList.Config): LiveData<PagedList<RoomSummary>> {
val realmDataSourceFactory = monarchy.createDataSourceFactory { realm ->
roomSummariesQuery(realm, queryParams)
.sort(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Sort.DESCENDING)
}
val dataSourceFactory = realmDataSourceFactory.map {
roomSummaryMapper.map(it)
}
return monarchy.findAllPagedWithChanges(
realmDataSourceFactory,
LivePagedListBuilder(dataSourceFactory, pagedListConfig)
)
}
fun getFilteredPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams,
pagedListConfig: PagedList.Config): UpdatableFilterLivePageResult {
val realmDataSourceFactory = monarchy.createDataSourceFactory { realm ->
roomSummariesQuery(realm, queryParams)
.sort(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Sort.DESCENDING)
}
val dataSourceFactory = realmDataSourceFactory.map {
roomSummaryMapper.map(it)
}
val mapped = monarchy.findAllPagedWithChanges(
realmDataSourceFactory,
LivePagedListBuilder(dataSourceFactory, pagedListConfig)
)
return object : UpdatableFilterLivePageResult {
override val livePagedList: LiveData<PagedList<RoomSummary>> = mapped
override fun updateQuery(queryParams: RoomSummaryQueryParams) {
realmDataSourceFactory.updateQuery {
roomSummariesQuery(it, queryParams)
.sort(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Sort.DESCENDING)
}
}
}
}
fun getNotificationCountForRooms(queryParams: RoomSummaryQueryParams): RoomAggregateNotificationCount {
var notificationCount: RoomAggregateNotificationCount? = null
monarchy.doWithRealm { realm ->
val roomSummariesQuery = roomSummariesQuery(realm, queryParams)
val notifCount = roomSummariesQuery.sum(RoomSummaryEntityFields.NOTIFICATION_COUNT).toInt()
val highlightCount = roomSummariesQuery.sum(RoomSummaryEntityFields.HIGHLIGHT_COUNT).toInt()
notificationCount = RoomAggregateNotificationCount(
notifCount,
highlightCount
)
}
return notificationCount!!
}
private fun roomSummariesQuery(realm: Realm, queryParams: RoomSummaryQueryParams): RealmQuery<RoomSummaryEntity> {
val query = RoomSummaryEntity.where(realm)
query.process(RoomSummaryEntityFields.ROOM_ID, queryParams.roomId)
@ -105,6 +167,28 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat
query.process(RoomSummaryEntityFields.CANONICAL_ALIAS, queryParams.canonicalAlias)
query.process(RoomSummaryEntityFields.MEMBERSHIP_STR, queryParams.memberships)
query.notEqualTo(RoomSummaryEntityFields.VERSIONING_STATE_STR, VersioningState.UPGRADED_ROOM_JOINED.name)
queryParams.roomCategoryFilter?.let {
when (it) {
RoomCategoryFilter.ONLY_DM -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, true)
RoomCategoryFilter.ONLY_ROOMS -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, false)
RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS -> query.greaterThan(RoomSummaryEntityFields.NOTIFICATION_COUNT, 0)
RoomCategoryFilter.ALL -> {
// nop
}
}
}
queryParams.roomTagQueryFilter?.let {
it.isFavorite?.let { fav ->
query.equalTo(RoomSummaryEntityFields.IS_FAVOURITE, fav)
}
it.isLowPriority?.let { lp ->
query.equalTo(RoomSummaryEntityFields.IS_LOW_PRIORITY, lp)
}
it.isServerNotice?.let { sn ->
query.equalTo(RoomSummaryEntityFields.IS_SERVER_NOTICE, sn)
}
}
return query
}
}

View File

@ -98,6 +98,11 @@ internal class RoomSummaryUpdater @Inject constructor(
val latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId)
val lastActivityFromEvent = latestPreviewableEvent?.root?.originServerTs
if (lastActivityFromEvent != null) {
roomSummaryEntity.lastActivityTime = lastActivityFromEvent
}
roomSummaryEntity.hasUnreadMessages = roomSummaryEntity.notificationCount > 0
// avoid this call if we are sure there are unread events
|| !isEventRead(realm.configuration, userId, roomId, latestPreviewableEvent?.eventId)
@ -112,9 +117,7 @@ internal class RoomSummaryUpdater @Inject constructor(
val roomAliases = ContentMapper.map(lastAliasesEvent?.content).toModel<RoomAliasesContent>()?.aliases
.orEmpty()
roomSummaryEntity.aliases.clear()
roomSummaryEntity.aliases.addAll(roomAliases)
roomSummaryEntity.flatAliases = roomAliases.joinToString(separator = "|", prefix = "|")
roomSummaryEntity.updateAliases(roomAliases)
roomSummaryEntity.isEncrypted = encryptionEvent != null
roomSummaryEntity.encryptionEventTs = encryptionEvent?.originServerTs

View File

@ -19,8 +19,8 @@ package org.matrix.android.sdk.internal.session.sync
import org.matrix.android.sdk.api.session.room.model.tag.RoomTagContent
import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity
import org.matrix.android.sdk.internal.database.model.RoomTagEntity
import org.matrix.android.sdk.internal.database.query.where
import io.realm.Realm
import org.matrix.android.sdk.internal.database.query.getOrCreate
import javax.inject.Inject
internal class RoomTagHandler @Inject constructor() {
@ -31,12 +31,8 @@ internal class RoomTagHandler @Inject constructor() {
}
val tags = content.tags.entries.map { (tagName, params) ->
RoomTagEntity(tagName, params["order"] as? Double)
Pair(tagName, params["order"] as? Double)
}
val roomSummaryEntity = RoomSummaryEntity.where(realm, roomId).findFirst()
?: RoomSummaryEntity(roomId)
roomSummaryEntity.tags.clear()
roomSummaryEntity.tags.addAll(tags)
realm.insertOrUpdate(roomSummaryEntity)
RoomSummaryEntity.getOrCreate(realm, roomId).updateTags(tags)
}
}

View File

@ -161,7 +161,7 @@ Formatter\.formatShortFileSize===1
# android\.text\.TextUtils
### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If the enum is not used as a Json class, change the value in file forbidden_strings_in_code.txt
enum class===93
enum class===94
### Do not import temporary legacy classes
import org.matrix.android.sdk.internal.legacy.riot===3

View File

@ -19,78 +19,26 @@ package im.vector.app
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import arrow.core.Option
import im.vector.app.features.grouplist.ALL_COMMUNITIES_GROUP_ID
import im.vector.app.features.grouplist.SelectedGroupDataSource
import im.vector.app.features.home.HomeRoomListDataSource
import im.vector.app.features.home.room.list.ChronologicalRoomComparator
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.functions.BiFunction
import io.reactivex.rxkotlin.addTo
import org.matrix.android.sdk.api.session.group.model.GroupSummary
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams
import org.matrix.android.sdk.rx.rx
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* This class handles the global app state. At the moment, it only manages room list.
* This class handles the global app state.
* It requires to be added to ProcessLifecycleOwner.get().lifecycle
*/
// TODO Keep this class for now, will maybe be used fro Space
@Singleton
class AppStateHandler @Inject constructor(
private val sessionDataSource: ActiveSessionDataSource,
private val homeRoomListDataSource: HomeRoomListDataSource,
private val selectedGroupDataSource: SelectedGroupDataSource,
private val chronologicalRoomComparator: ChronologicalRoomComparator) : LifecycleObserver {
class AppStateHandler @Inject constructor() : LifecycleObserver {
private val compositeDisposable = CompositeDisposable()
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun entersForeground() {
observeRoomsAndGroup()
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun entersBackground() {
compositeDisposable.clear()
}
private fun observeRoomsAndGroup() {
Observable
.combineLatest<List<RoomSummary>, Option<GroupSummary>, List<RoomSummary>>(
sessionDataSource.observe()
.observeOn(AndroidSchedulers.mainThread())
.switchMap {
val query = roomSummaryQueryParams {}
it.orNull()?.rx()?.liveRoomSummaries(query)
?: Observable.just(emptyList())
}
.throttleLast(300, TimeUnit.MILLISECONDS),
selectedGroupDataSource.observe(),
BiFunction { rooms, selectedGroupOption ->
val selectedGroup = selectedGroupOption.orNull()
val filteredRooms = rooms.filter {
if (selectedGroup == null || selectedGroup.groupId == ALL_COMMUNITIES_GROUP_ID) {
true
} else if (it.isDirect) {
it.otherMemberIds
.intersect(selectedGroup.userIds)
.isNotEmpty()
} else {
selectedGroup.roomIds.contains(it.roomId)
}
}
filteredRooms.sortedWith(chronologicalRoomComparator)
}
)
.subscribe {
homeRoomListDataSource.post(it)
}
.addTo(compositeDisposable)
}
}

View File

@ -35,7 +35,6 @@ import im.vector.app.features.crypto.keysrequest.KeyRequestHandler
import im.vector.app.features.crypto.verification.IncomingVerificationRequestHandler
import im.vector.app.features.grouplist.SelectedGroupDataSource
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.HomeRoomListDataSource
import im.vector.app.features.home.room.detail.RoomDetailPendingActionStore
import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
@ -113,8 +112,6 @@ interface VectorComponent {
fun errorFormatter(): ErrorFormatter
fun homeRoomListObservableStore(): HomeRoomListDataSource
fun selectedGroupStore(): SelectedGroupDataSource
fun roomDetailPendingActionStore(): RoomDetailPendingActionStore

View File

@ -127,6 +127,12 @@ abstract class VectorBaseFragment<VB: ViewBinding> : BaseMvRxFragment(), HasScre
Timber.i("onResume Fragment ${javaClass.simpleName}")
}
@CallSuper
override fun onPause() {
super.onPause()
Timber.i("onPause Fragment ${javaClass.simpleName}")
}
@CallSuper
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@ -149,7 +155,9 @@ abstract class VectorBaseFragment<VB: ViewBinding> : BaseMvRxFragment(), HasScre
super.onDestroyView()
}
@CallSuper
override fun onDestroy() {
Timber.i("onDestroy Fragment ${javaClass.simpleName}")
uiDisposables.dispose()
super.onDestroy()
}

View File

@ -20,4 +20,5 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class HomeDetailAction : VectorViewModelAction {
data class SwitchDisplayMode(val displayMode: RoomListDisplayMode) : HomeDetailAction()
object MarkAllRoomsRead : HomeDetailAction()
}

View File

@ -18,6 +18,8 @@ package im.vector.app.features.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
@ -33,8 +35,8 @@ import im.vector.app.core.platform.ToolbarConfigurable
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.ui.views.CurrentCallsView
import im.vector.app.core.ui.views.KnownCallsViewHolder
import im.vector.app.core.ui.views.KeysBackupBanner
import im.vector.app.core.ui.views.KnownCallsViewHolder
import im.vector.app.databinding.FragmentHomeDetailBinding
import im.vector.app.features.call.SharedKnownCallsViewModel
import im.vector.app.features.call.VectorCallActivity
@ -49,7 +51,6 @@ import im.vector.app.features.themes.ThemeUtils
import im.vector.app.features.workers.signout.BannerState
import im.vector.app.features.workers.signout.ServerBackupStatusViewModel
import im.vector.app.features.workers.signout.ServerBackupStatusViewState
import org.matrix.android.sdk.api.session.group.model.GroupSummary
import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.internal.crypto.model.rest.DeviceInfo
@ -79,6 +80,32 @@ class HomeDetailFragment @Inject constructor(
private lateinit var sharedActionViewModel: HomeSharedActionViewModel
private lateinit var sharedCallActionViewModel: SharedKnownCallsViewModel
private var hasUnreadRooms = false
set(value) {
if (value != field) {
field = value
invalidateOptionsMenu()
}
}
override fun getMenuRes() = R.menu.room_list
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_home_mark_all_as_read -> {
viewModel.handle(HomeDetailAction.MarkAllRoomsRead)
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onPrepareOptionsMenu(menu: Menu) {
menu.findItem(R.id.menu_home_mark_all_as_read).isVisible = hasUnreadRooms
super.onPrepareOptionsMenu(menu)
}
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeDetailBinding {
return FragmentHomeDetailBinding.inflate(inflater, container, false)
}
@ -314,6 +341,8 @@ class HomeDetailFragment @Inject constructor(
views.bottomNavigationView.getOrCreateBadge(R.id.bottom_action_rooms).render(it.notificationCountRooms, it.notificationHighlightRooms)
views.bottomNavigationView.getOrCreateBadge(R.id.bottom_action_notification).render(it.notificationCountCatchup, it.notificationHighlightCatchup)
views.syncStateView.render(it.syncState)
hasUnreadRooms = it.hasUnreadMessages
}
private fun BadgeDrawable.render(count: Int, highlight: Boolean) {

View File

@ -16,22 +16,30 @@
package im.vector.app.features.home
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.HasScreenInjector
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.grouplist.SelectedGroupDataSource
import im.vector.app.features.ui.UiStateRepository
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.query.RoomCategoryFilter
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams
import org.matrix.android.sdk.internal.util.awaitCallback
import org.matrix.android.sdk.rx.asObservable
import org.matrix.android.sdk.rx.rx
import timber.log.Timber
import java.util.concurrent.TimeUnit
/**
* View model used to update the home bottom bar notification counts, observe the sync state and
@ -41,7 +49,6 @@ class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: Ho
private val session: Session,
private val uiStateRepository: UiStateRepository,
private val selectedGroupStore: SelectedGroupDataSource,
private val homeRoomListStore: HomeRoomListDataSource,
private val stringProvider: StringProvider)
: VectorViewModel<HomeDetailViewState, HomeDetailAction, EmptyViewEvents>(initialState) {
@ -75,6 +82,7 @@ class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: Ho
override fun handle(action: HomeDetailAction) {
when (action) {
is HomeDetailAction.SwitchDisplayMode -> handleSwitchDisplayMode(action)
HomeDetailAction.MarkAllRoomsRead -> handleMarkAllRoomsRead()
}
}
@ -90,6 +98,26 @@ class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: Ho
// PRIVATE METHODS *****************************************************************************
private fun handleMarkAllRoomsRead() = withState { _ ->
// questionable to use viewmodelscope
viewModelScope.launch(Dispatchers.Default) {
val roomIds = session.getRoomSummaries(
roomSummaryQueryParams {
memberships = listOf(Membership.JOIN)
roomCategoryFilter = RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS
}
)
.map { it.roomId }
try {
awaitCallback<Unit> {
session.markAllAsRead(roomIds, it)
}
} catch (failure: Throwable) {
Timber.d(failure, "Failed to mark all as read")
}
}
}
private fun observeSyncState() {
session.rx()
.liveSyncState()
@ -113,43 +141,51 @@ class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: Ho
}
private fun observeRoomSummaries() {
homeRoomListStore
.observe()
.observeOn(Schedulers.computation())
.map { it.asSequence() }
.subscribe { summaries ->
val invitesDm = summaries
.filter { it.membership == Membership.INVITE && it.isDirect }
.count()
session.getPagedRoomSummariesLive(
roomSummaryQueryParams {
memberships = Membership.activeMemberships()
}
)
.asObservable()
.throttleFirst(300, TimeUnit.MILLISECONDS)
.subscribe {
val dmInvites = session.getRoomSummaries(
roomSummaryQueryParams {
memberships = listOf(Membership.INVITE)
roomCategoryFilter = RoomCategoryFilter.ONLY_DM
}
).size
val invitesRoom = summaries
.filter { it.membership == Membership.INVITE && it.isDirect.not() }
.count()
val roomsInvite = session.getRoomSummaries(
roomSummaryQueryParams {
memberships = listOf(Membership.INVITE)
roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS
}
).size
val peopleNotifications = summaries
.filter { it.isDirect }
.map { it.notificationCount }
.sum()
val peopleHasHighlight = summaries
.filter { it.isDirect }
.any { it.highlightCount > 0 }
val dmRooms = session.getNotificationCountForRooms(
roomSummaryQueryParams {
memberships = listOf(Membership.JOIN)
roomCategoryFilter = RoomCategoryFilter.ONLY_DM
}
)
val roomsNotifications = summaries
.filter { !it.isDirect }
.map { it.notificationCount }
.sum()
val roomsHasHighlight = summaries
.filter { !it.isDirect }
.any { it.highlightCount > 0 }
val otherRooms = session.getNotificationCountForRooms(
roomSummaryQueryParams {
memberships = listOf(Membership.JOIN)
roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS
}
)
setState {
copy(
notificationCountCatchup = peopleNotifications + roomsNotifications + invitesDm + invitesRoom,
notificationHighlightCatchup = peopleHasHighlight || roomsHasHighlight,
notificationCountPeople = peopleNotifications + invitesDm,
notificationHighlightPeople = peopleHasHighlight || invitesDm > 0,
notificationCountRooms = roomsNotifications + invitesRoom,
notificationHighlightRooms = roomsHasHighlight || invitesRoom > 0
notificationCountCatchup = dmRooms.totalCount + otherRooms.totalCount + roomsInvite + dmInvites,
notificationHighlightCatchup = dmRooms.isHighlight || otherRooms.isHighlight,
notificationCountPeople = dmRooms.totalCount + dmInvites,
notificationHighlightPeople = dmRooms.isHighlight || dmInvites > 0,
notificationCountRooms = otherRooms.totalCount + roomsInvite,
notificationHighlightRooms = otherRooms.isHighlight || roomsInvite > 0,
hasUnreadMessages = dmRooms.totalCount + otherRooms.totalCount > 0
)
}
}

View File

@ -34,5 +34,6 @@ data class HomeDetailViewState(
val notificationHighlightPeople: Boolean = false,
val notificationCountRooms: Int = 0,
val notificationHighlightRooms: Boolean = false,
val hasUnreadMessages: Boolean = false,
val syncState: SyncState = SyncState.Idle
) : MvRxState

View File

@ -21,36 +21,44 @@ import android.content.pm.ShortcutManager
import android.os.Build
import androidx.core.content.getSystemService
import androidx.core.content.pm.ShortcutManagerCompat
import io.reactivex.Observable
import im.vector.app.core.di.ActiveSessionHolder
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import io.reactivex.disposables.Disposables
import org.matrix.android.sdk.api.query.RoomTagQueryFilter
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams
import org.matrix.android.sdk.rx.asObservable
import javax.inject.Inject
class ShortcutsHandler @Inject constructor(
private val context: Context,
private val homeRoomListStore: HomeRoomListDataSource,
private val shortcutCreator: ShortcutCreator
private val shortcutCreator: ShortcutCreator,
private val activeSessionHolder: ActiveSessionHolder
) {
fun observeRoomsAndBuildShortcuts(): Disposable {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
// No op
return Observable.empty<Unit>().subscribe()
return Disposables.empty()
}
return homeRoomListStore
.observe()
.distinctUntilChanged()
.observeOn(Schedulers.computation())
.subscribe { rooms ->
return activeSessionHolder.getSafeActiveSession()
?.getPagedRoomSummariesLive(
roomSummaryQueryParams {
memberships = listOf(Membership.JOIN)
roomTagQueryFilter = RoomTagQueryFilter(isFavorite = true, null, null)
}
)
?.asObservable()
?.subscribe { rooms ->
val shortcuts = rooms
.filter { room -> room.isFavorite }
.take(n = 4) // Android only allows us to create 4 shortcuts
.map { shortcutCreator.create(it) }
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
ShortcutManagerCompat.addDynamicShortcuts(context, shortcuts)
}
?: Disposables.empty()
}
fun clearShortcuts() {

View File

@ -22,12 +22,11 @@ import org.matrix.android.sdk.api.session.room.notification.RoomNotificationStat
sealed class RoomListAction : VectorViewModelAction {
data class SelectRoom(val roomSummary: RoomSummary) : RoomListAction()
data class ToggleCategory(val category: RoomCategory) : RoomListAction()
data class ToggleSection(val section: RoomsSection) : RoomListAction()
data class AcceptInvitation(val roomSummary: RoomSummary) : RoomListAction()
data class RejectInvitation(val roomSummary: RoomSummary) : RoomListAction()
data class FilterWith(val filter: String) : RoomListAction()
data class ChangeRoomNotificationState(val roomId: String, val notificationState: RoomNotificationState) : RoomListAction()
data class ToggleTag(val roomId: String, val tag: String) : RoomListAction()
data class LeaveRoom(val roomId: String) : RoomListAction()
object MarkAllRoomsRead : RoomListAction()
}

View File

@ -0,0 +1,54 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.list
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.app.R
import im.vector.app.core.epoxy.helpFooterItem
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.resources.UserPreferencesProvider
import im.vector.app.features.home.RoomListDisplayMode
import im.vector.app.features.home.room.filtered.filteredRoomFooterItem
import javax.inject.Inject
class RoomListFooterController @Inject constructor(
private val stringProvider: StringProvider,
private val userPreferencesProvider: UserPreferencesProvider
) : TypedEpoxyController<RoomListViewState>() {
var listener: RoomListListener? = null
override fun buildModels(data: RoomListViewState?) {
when (data?.displayMode) {
RoomListDisplayMode.FILTERED -> {
filteredRoomFooterItem {
id("filter_footer")
listener(listener)
currentFilter(data.roomFilter)
}
}
else -> {
if (userPreferencesProvider.shouldShowLongClickOnRoomHelp()) {
helpFooterItem {
id("long_click_help")
text(stringProvider.getString(R.string.help_long_click_on_room_for_more_options))
}
}
}
}
}
}

View File

@ -20,19 +20,15 @@ import android.content.DialogInterface
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.OnModelBuildFinishedListener
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Incomplete
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
@ -44,6 +40,7 @@ import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.OnBackPressed
import im.vector.app.core.platform.StateView
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.resources.UserPreferencesProvider
import im.vector.app.databinding.FragmentRoomListBinding
import im.vector.app.features.home.RoomListDisplayMode
import im.vector.app.features.home.room.list.actions.RoomListActionsArgs
@ -53,8 +50,7 @@ import im.vector.app.features.home.room.list.actions.RoomListQuickActionsSharedA
import im.vector.app.features.home.room.list.widget.NotifsFabMenuView
import im.vector.app.features.notifications.NotificationDrawerManager
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.extensions.orTrue
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.tag.RoomTag
import org.matrix.android.sdk.api.session.room.notification.RoomNotificationState
@ -66,12 +62,13 @@ data class RoomListParams(
) : Parcelable
class RoomListFragment @Inject constructor(
private val roomController: RoomSummaryController,
private val pagedControllerFactory: RoomSummaryPagedControllerFactory,
val roomListViewModelFactory: RoomListViewModel.Factory,
private val notificationDrawerManager: NotificationDrawerManager,
private val sharedViewPool: RecyclerView.RecycledViewPool
private val footerController: RoomListFooterController,
private val userPreferencesProvider: UserPreferencesProvider
) : VectorBaseFragment<FragmentRoomListBinding>(),
RoomSummaryController.Listener,
RoomListListener,
OnBackPressed,
NotifsFabMenuView.Listener {
@ -85,28 +82,25 @@ class RoomListFragment @Inject constructor(
return FragmentRoomListBinding.inflate(inflater, container, false)
}
private var hasUnreadRooms = false
data class SectionKey(
val name: String,
val isExpanded: Boolean,
val notifyOfLocalEcho: Boolean
)
override fun getMenuRes() = R.menu.room_list
data class SectionAdapterInfo(
var section: SectionKey,
val headerHeaderAdapter: SectionHeaderAdapter,
val contentAdapter: RoomSummaryPagedController
)
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_home_mark_all_as_read -> {
roomListViewModel.handle(RoomListAction.MarkAllRoomsRead)
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onPrepareOptionsMenu(menu: Menu) {
menu.findItem(R.id.menu_home_mark_all_as_read).isVisible = hasUnreadRooms
super.onPrepareOptionsMenu(menu)
}
private val adapterInfosList = mutableListOf<SectionAdapterInfo>()
private var concatAdapter : ConcatAdapter? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.stateView.contentView = views.roomListView
views.stateView.state = StateView.State.Loading
setupCreateRoomButton()
setupRecyclerView()
sharedActionViewModel = activityViewModelProvider.get(RoomListQuickActionsSharedActionViewModel::class.java)
@ -125,6 +119,40 @@ class RoomListFragment @Inject constructor(
.observe()
.subscribe { handleQuickActions(it) }
.disposeOnDestroyView()
roomListViewModel.selectSubscribe(viewLifecycleOwner, RoomListViewState::roomMembershipChanges) { ms ->
// it's for invites local echo
adapterInfosList.filter { it.section.notifyOfLocalEcho }
.onEach {
it.contentAdapter.roomChangeMembershipStates = ms
}
}
}
private fun refreshCollapseStates() {
var contentInsertIndex = 1
roomListViewModel.sections.forEachIndexed { index, roomsSection ->
val actualBlock = adapterInfosList[index]
val isRoomSectionExpanded = roomsSection.isExpanded.value.orTrue()
if (actualBlock.section.isExpanded && !isRoomSectionExpanded) {
// we have to remove the content adapter
concatAdapter?.removeAdapter(actualBlock.contentAdapter.adapter)
} else if (!actualBlock.section.isExpanded && isRoomSectionExpanded) {
// we must add it back!
concatAdapter?.addAdapter(contentInsertIndex, actualBlock.contentAdapter.adapter)
}
contentInsertIndex = if (isRoomSectionExpanded) {
contentInsertIndex + 2
} else {
contentInsertIndex + 1
}
actualBlock.section = actualBlock.section.copy(
isExpanded = isRoomSectionExpanded
)
actualBlock.headerHeaderAdapter.updateSection(
actualBlock.headerHeaderAdapter.roomsSectionData.copy(isExpanded = isRoomSectionExpanded)
)
}
}
override fun showFailure(throwable: Throwable) {
@ -132,12 +160,15 @@ class RoomListFragment @Inject constructor(
}
override fun onDestroyView() {
roomController.removeModelBuildListener(modelBuildListener)
adapterInfosList.onEach { it.contentAdapter.removeModelBuildListener(modelBuildListener) }
adapterInfosList.clear()
modelBuildListener = null
views.roomListView.cleanup()
roomController.listener = null
footerController.listener = null
// TODO Cleanup listener on the ConcatAdapter's adapters?
stateRestorer.clear()
views.createChatFabMenu.listener = null
concatAdapter = null
super.onDestroyView()
}
@ -204,13 +235,58 @@ class RoomListFragment @Inject constructor(
stateRestorer = LayoutManagerStateRestorer(layoutManager).register()
views.roomListView.layoutManager = layoutManager
views.roomListView.itemAnimator = RoomListAnimator()
views.roomListView.setRecycledViewPool(sharedViewPool)
layoutManager.recycleChildrenOnDetach = true
roomController.listener = this
modelBuildListener = OnModelBuildFinishedListener { it.dispatchTo(stateRestorer) }
roomController.addModelBuildListener(modelBuildListener)
views.roomListView.adapter = roomController.adapter
views.stateView.contentView = views.roomListView
val concatAdapter = ConcatAdapter()
roomListViewModel.sections.forEach { section ->
val sectionAdapter = SectionHeaderAdapter {
roomListViewModel.handle(RoomListAction.ToggleSection(section))
}.also {
it.updateSection(SectionHeaderAdapter.RoomsSectionData(section.sectionName))
}
val contentAdapter = pagedControllerFactory.createRoomSummaryPagedController()
.also { controller ->
section.livePages.observe(viewLifecycleOwner) { pl ->
controller.submitList(pl)
sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy(isHidden = pl.isEmpty()))
checkEmptyState()
}
section.notificationCount.observe(viewLifecycleOwner) { counts ->
sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy(
notificationCount = counts.totalCount,
isHighlighted = counts.isHighlight
))
}
section.isExpanded.observe(viewLifecycleOwner) { _ ->
refreshCollapseStates()
}
controller.listener = this
}
adapterInfosList.add(
SectionAdapterInfo(
SectionKey(
name = section.sectionName,
isExpanded = section.isExpanded.value.orTrue(),
notifyOfLocalEcho = section.notifyOfLocalEcho
),
sectionAdapter,
contentAdapter
)
)
concatAdapter.addAdapter(sectionAdapter)
concatAdapter.addAdapter(contentAdapter.adapter)
}
// Add the footer controller
footerController.listener = this
concatAdapter.addAdapter(footerController.adapter)
this.concatAdapter = concatAdapter
views.roomListView.adapter = concatAdapter
}
private val showFabRunnable = Runnable {
@ -278,89 +354,41 @@ class RoomListFragment @Inject constructor(
}
override fun invalidate() = withState(roomListViewModel) { state ->
when (state.asyncFilteredRooms) {
is Incomplete -> renderLoading()
is Success -> renderSuccess(state)
is Fail -> renderFailure(state.asyncFilteredRooms.error)
}
roomController.update(state)
// Mark all as read menu
when (roomListParams.displayMode) {
RoomListDisplayMode.NOTIFICATIONS,
RoomListDisplayMode.PEOPLE,
RoomListDisplayMode.ROOMS -> {
val newValue = state.hasUnread
if (hasUnreadRooms != newValue) {
hasUnreadRooms = newValue
invalidateOptionsMenu()
}
}
else -> Unit
}
footerController.setData(state)
}
private fun renderSuccess(state: RoomListViewState) {
val allRooms = state.asyncRooms()
val filteredRooms = state.asyncFilteredRooms()
if (filteredRooms.isNullOrEmpty()) {
renderEmptyState(allRooms)
} else {
views.stateView.state = StateView.State.Content
}
}
private fun renderEmptyState(allRooms: List<RoomSummary>?) {
val hasNoRoom = allRooms
?.filter {
it.membership == Membership.JOIN || it.membership == Membership.INVITE
}
.isNullOrEmpty()
val emptyState = when (roomListParams.displayMode) {
RoomListDisplayMode.NOTIFICATIONS -> {
if (hasNoRoom) {
StateView.State.Empty(
title = getString(R.string.room_list_catchup_welcome_title),
image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_home_bottom_catchup),
message = getString(R.string.room_list_catchup_welcome_body)
)
} else {
private fun checkEmptyState() {
val hasNoRoom = adapterInfosList.all { it.headerHeaderAdapter.roomsSectionData.isHidden }
if (hasNoRoom) {
val emptyState = when (roomListParams.displayMode) {
RoomListDisplayMode.NOTIFICATIONS -> {
StateView.State.Empty(
title = getString(R.string.room_list_catchup_empty_title),
image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_noun_party_popper),
message = getString(R.string.room_list_catchup_empty_body))
}
RoomListDisplayMode.PEOPLE ->
StateView.State.Empty(
title = getString(R.string.room_list_people_empty_title),
image = ContextCompat.getDrawable(requireContext(), R.drawable.empty_state_dm),
isBigImage = true,
message = getString(R.string.room_list_people_empty_body)
)
RoomListDisplayMode.ROOMS ->
StateView.State.Empty(
title = getString(R.string.room_list_rooms_empty_title),
image = ContextCompat.getDrawable(requireContext(), R.drawable.empty_state_room),
isBigImage = true,
message = getString(R.string.room_list_rooms_empty_body)
)
else ->
// Always display the content in this mode, because if the footer
StateView.State.Content
}
RoomListDisplayMode.PEOPLE ->
StateView.State.Empty(
title = getString(R.string.room_list_people_empty_title),
image = ContextCompat.getDrawable(requireContext(), R.drawable.empty_state_dm),
isBigImage = true,
message = getString(R.string.room_list_people_empty_body)
)
RoomListDisplayMode.ROOMS ->
StateView.State.Empty(
title = getString(R.string.room_list_rooms_empty_title),
image = ContextCompat.getDrawable(requireContext(), R.drawable.empty_state_room),
isBigImage = true,
message = getString(R.string.room_list_rooms_empty_body)
)
else ->
// Always display the content in this mode, because if the footer
StateView.State.Content
views.stateView.state = emptyState
} else {
views.stateView.state = StateView.State.Content
}
views.stateView.state = emptyState
}
private fun renderLoading() {
views.stateView.state = StateView.State.Loading
}
private fun renderFailure(error: Throwable) {
val message = when (error) {
is Failure.NetworkConnection -> getString(R.string.network_error_please_check_and_retry)
else -> getString(R.string.unknown_error)
}
views.stateView.state = StateView.State.Error(message)
}
override fun onBackPressed(toolbarButton: Boolean): Boolean {
@ -377,7 +405,11 @@ class RoomListFragment @Inject constructor(
}
override fun onRoomLongClicked(room: RoomSummary): Boolean {
roomController.onRoomLongClicked()
userPreferencesProvider.neverShowLongClickOnRoomHelpAgain()
withState(roomListViewModel) {
// refresh footer
footerController.setData(it)
}
RoomListQuickActionsBottomSheet
.newInstance(room.roomId, RoomListActionsArgs.Mode.FULL)
.show(childFragmentManager, "ROOM_LIST_QUICK_ACTIONS")
@ -394,10 +426,6 @@ class RoomListFragment @Inject constructor(
roomListViewModel.handle(RoomListAction.RejectInvitation(room))
}
override fun onToggleRoomCategory(roomCategory: RoomCategory) {
roomListViewModel.handle(RoomListAction.ToggleCategory(roomCategory))
}
override fun createRoom(initialName: String) {
navigator.openCreateRoom(requireActivity(), initialName)
}

View File

@ -0,0 +1,27 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.list
import im.vector.app.features.home.room.filtered.FilteredRoomFooterItem
import org.matrix.android.sdk.api.session.room.model.RoomSummary
interface RoomListListener : FilteredRoomFooterItem.FilteredRoomFooterItemListener {
fun onRoomClicked(room: RoomSummary)
fun onRoomLongClicked(room: RoomSummary): Boolean
fun onRejectRoomInvitation(room: RoomSummary)
fun onAcceptRoomInvitation(room: RoomSummary)
}

View File

@ -16,37 +16,61 @@
package im.vector.app.features.home.room.list
import androidx.annotation.StringRes
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import im.vector.app.R
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.utils.DataSource
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.home.RoomListDisplayMode
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.NoOpMatrixCallback
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.query.RoomCategoryFilter
import org.matrix.android.sdk.api.query.RoomTagQueryFilter
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams
import org.matrix.android.sdk.api.session.room.UpdatableFilterLivePageResult
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.RoomSummary
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.rx.asObservable
import org.matrix.android.sdk.rx.rx
import timber.log.Timber
import java.lang.Exception
import javax.inject.Inject
class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
private val session: Session,
private val roomSummariesSource: DataSource<List<RoomSummary>>)
: VectorViewModel<RoomListViewState, RoomListAction, RoomListViewEvents>(initialState) {
class RoomListViewModel @Inject constructor(
initialState: RoomListViewState,
private val session: Session,
private val stringProvider: StringProvider
) : VectorViewModel<RoomListViewState, RoomListAction, RoomListViewEvents>(initialState) {
interface Factory {
fun create(initialState: RoomListViewState): RoomListViewModel
}
private var updatableQuery: UpdatableFilterLivePageResult? = null
init {
observeMembershipChanges()
}
private fun observeMembershipChanges() {
session.rx()
.liveRoomChangeMembershipState()
.subscribe {
setState { copy(roomMembershipChanges = it) }
}
.disposeOnClear()
}
companion object : MvRxViewModelFactory<RoomListViewModel, RoomListViewState> {
@JvmStatic
@ -56,28 +80,136 @@ class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
}
}
private val displayMode = initialState.displayMode
private val roomListDisplayModeFilter = RoomListDisplayModeFilter(displayMode)
val sections: List<RoomsSection> by lazy {
val sections = mutableListOf<RoomsSection>()
if (initialState.displayMode == RoomListDisplayMode.PEOPLE) {
addSection(sections, R.string.invitations_header, true) {
it.memberships = listOf(Membership.INVITE)
it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM
}
init {
observeRoomSummaries()
observeMembershipChanges()
addSection(sections, R.string.bottom_action_favourites) {
it.memberships = listOf(Membership.JOIN)
it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM
it.roomTagQueryFilter = RoomTagQueryFilter(true, null, null)
}
addSection(sections, R.string.bottom_action_people_x) {
it.memberships = listOf(Membership.JOIN)
it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM
}
} else if (initialState.displayMode == RoomListDisplayMode.ROOMS) {
addSection(sections, R.string.invitations_header, true) {
it.memberships = listOf(Membership.INVITE)
it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS
}
addSection(sections, R.string.bottom_action_favourites) {
it.memberships = listOf(Membership.JOIN)
it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS
it.roomTagQueryFilter = RoomTagQueryFilter(true, null, null)
}
addSection(sections, R.string.bottom_action_rooms) {
it.memberships = listOf(Membership.JOIN)
it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS
it.roomTagQueryFilter = RoomTagQueryFilter(false, false, false)
}
addSection(sections, R.string.low_priority_header) {
it.memberships = listOf(Membership.JOIN)
it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS
it.roomTagQueryFilter = RoomTagQueryFilter(null, true, null)
}
addSection(sections, R.string.system_alerts_header) {
it.memberships = listOf(Membership.JOIN)
it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS
it.roomTagQueryFilter = RoomTagQueryFilter(null, null, true)
}
} else if (initialState.displayMode == RoomListDisplayMode.FILTERED) {
withQueryParams(
{
it.memberships = Membership.activeMemberships()
},
{ qpm ->
val name = stringProvider.getString(R.string.bottom_action_rooms)
session.getFilteredPagedRoomSummariesLive(qpm)
.let { updatableFilterLivePageResult ->
updatableQuery = updatableFilterLivePageResult
sections.add(RoomsSection(name, updatableFilterLivePageResult.livePagedList))
}
}
)
} else if (initialState.displayMode == RoomListDisplayMode.NOTIFICATIONS) {
addSection(sections, R.string.invitations_header, true) {
it.memberships = listOf(Membership.INVITE)
it.roomCategoryFilter = RoomCategoryFilter.ALL
}
addSection(sections, R.string.bottom_action_rooms, true) {
it.memberships = listOf(Membership.JOIN)
it.roomCategoryFilter = RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS
}
}
sections
}
override fun handle(action: RoomListAction) {
when (action) {
is RoomListAction.SelectRoom -> handleSelectRoom(action)
is RoomListAction.ToggleCategory -> handleToggleCategory(action)
is RoomListAction.AcceptInvitation -> handleAcceptInvitation(action)
is RoomListAction.RejectInvitation -> handleRejectInvitation(action)
is RoomListAction.FilterWith -> handleFilter(action)
is RoomListAction.MarkAllRoomsRead -> handleMarkAllRoomsRead()
is RoomListAction.LeaveRoom -> handleLeaveRoom(action)
is RoomListAction.ChangeRoomNotificationState -> handleChangeNotificationMode(action)
is RoomListAction.ToggleTag -> handleToggleTag(action)
is RoomListAction.ToggleSection -> handleToggleSection(action.section)
}.exhaustive
}
private fun addSection(sections: MutableList<RoomsSection>,
@StringRes nameRes: Int,
notifyOfLocalEcho: Boolean = false,
query: (RoomSummaryQueryParams.Builder) -> Unit) {
withQueryParams(
{ query.invoke(it) },
{ roomQueryParams ->
val name = stringProvider.getString(nameRes)
session.getPagedRoomSummariesLive(roomQueryParams)
.let { livePagedList ->
// use it also as a source to update count
livePagedList.asObservable()
.observeOn(Schedulers.computation())
.subscribe {
sections.find { it.sectionName == name }
?.notificationCount
?.postValue(session.getNotificationCountForRooms(roomQueryParams))
}
.disposeOnClear()
sections.add(
RoomsSection(
sectionName = name,
livePages = livePagedList,
notifyOfLocalEcho = notifyOfLocalEcho
)
)
}
}
)
}
private fun withQueryParams(builder: (RoomSummaryQueryParams.Builder) -> Unit, block: (RoomSummaryQueryParams) -> Unit) {
RoomSummaryQueryParams.Builder()
.apply { builder.invoke(this) }
.build()
.let { block(it) }
}
fun isPublicRoom(roomId: String): Boolean {
return session.getRoom(roomId)?.isPublic().orFalse()
}
@ -88,8 +220,14 @@ class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
_viewEvents.post(RoomListViewEvents.SelectRoom(action.roomSummary))
}
private fun handleToggleCategory(action: RoomListAction.ToggleCategory) = setState {
this.toggle(action.category)
private fun handleToggleSection(roomSection: RoomsSection) {
roomSection.isExpanded.postValue(!roomSection.isExpanded.value.orFalse())
/* TODO Cleanup if it is working
sections.find { it.sectionName == roomSection.sectionName }
?.let { section ->
section.isExpanded.postValue(!section.isExpanded.value.orFalse())
}
*/
}
private fun handleFilter(action: RoomListAction.FilterWith) {
@ -98,23 +236,12 @@ class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
roomFilter = action.filter
)
}
}
private fun observeRoomSummaries() {
roomSummariesSource
.observe()
.observeOn(Schedulers.computation())
.execute { asyncRooms ->
copy(asyncRooms = asyncRooms)
}
roomSummariesSource
.observe()
.observeOn(Schedulers.computation())
.map { buildRoomSummaries(it) }
.execute { async ->
copy(asyncFilteredRooms = async)
updatableQuery?.updateQuery(
roomSummaryQueryParams {
memberships = Membership.activeMemberships()
displayName = QueryStringValue.Contains(action.filter, QueryStringValue.Case.INSENSITIVE)
}
)
}
private fun handleAcceptInvitation(action: RoomListAction.AcceptInvitation) = withState { state ->
@ -126,6 +253,19 @@ class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
return@withState
}
// quick echo
setState {
copy(
roomMembershipChanges = roomMembershipChanges.mapValues {
if (it.key == roomId) {
ChangeMembershipState.Joining
} else {
it.value
}
}
)
}
val room = session.getRoom(roomId) ?: return@withState
viewModelScope.launch {
try {
@ -163,15 +303,6 @@ class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
}
}
private fun handleMarkAllRoomsRead() = withState { state ->
state.asyncFilteredRooms.invoke()
?.flatMap { it.value }
?.filter { it.membership == Membership.JOIN }
?.map { it.roomId }
?.toList()
?.let { session.markAllAsRead(it, NoOpMatrixCallback()) }
}
private fun handleChangeNotificationMode(action: RoomListAction.ChangeRoomNotificationState) {
val room = session.getRoom(action.roomId)
if (room != null) {
@ -226,46 +357,4 @@ class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
_viewEvents.post(value)
}
}
private fun observeMembershipChanges() {
session.rx()
.liveRoomChangeMembershipState()
.subscribe {
Timber.v("ChangeMembership states: $it")
setState { copy(roomMembershipChanges = it) }
}
.disposeOnClear()
}
private fun buildRoomSummaries(rooms: List<RoomSummary>): RoomSummaries {
// Set up init size on directChats and groupRooms as they are the biggest ones
val invites = ArrayList<RoomSummary>()
val favourites = ArrayList<RoomSummary>()
val directChats = ArrayList<RoomSummary>(rooms.size)
val groupRooms = ArrayList<RoomSummary>(rooms.size)
val lowPriorities = ArrayList<RoomSummary>()
val serverNotices = ArrayList<RoomSummary>()
rooms
.filter { roomListDisplayModeFilter.test(it) }
.forEach { room ->
val tags = room.tags.map { it.name }
when {
room.membership == Membership.INVITE -> invites.add(room)
tags.contains(RoomTag.ROOM_TAG_SERVER_NOTICE) -> serverNotices.add(room)
tags.contains(RoomTag.ROOM_TAG_FAVOURITE) -> favourites.add(room)
tags.contains(RoomTag.ROOM_TAG_LOW_PRIORITY) -> lowPriorities.add(room)
room.isDirect -> directChats.add(room)
else -> groupRooms.add(room)
}
}
return RoomSummaries().apply {
put(RoomCategory.INVITE, invites)
put(RoomCategory.FAVOURITE, favourites)
put(RoomCategory.DIRECT, directChats)
put(RoomCategory.GROUP, groupRooms)
put(RoomCategory.LOW_PRIORITY, lowPriorities)
put(RoomCategory.SERVER_NOTICE, serverNotices)
}
}
}

View File

@ -16,20 +16,20 @@
package im.vector.app.features.home.room.list
import im.vector.app.features.home.HomeRoomListDataSource
import im.vector.app.core.resources.StringProvider
import org.matrix.android.sdk.api.session.Session
import javax.inject.Inject
import javax.inject.Provider
class RoomListViewModelFactory @Inject constructor(private val session: Provider<Session>,
private val homeRoomListDataSource: Provider<HomeRoomListDataSource>)
private val stringProvider: StringProvider)
: RoomListViewModel.Factory {
override fun create(initialState: RoomListViewState): RoomListViewModel {
return RoomListViewModel(
initialState,
session.get(),
homeRoomListDataSource.get()
stringProvider
)
}
}

View File

@ -16,73 +16,15 @@
package im.vector.app.features.home.room.list
import androidx.annotation.StringRes
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.app.R
import im.vector.app.features.home.RoomListDisplayMode
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.RoomSummary
data class RoomListViewState(
val displayMode: RoomListDisplayMode,
val asyncRooms: Async<List<RoomSummary>> = Uninitialized,
val roomFilter: String = "",
val asyncFilteredRooms: Async<RoomSummaries> = Uninitialized,
val roomMembershipChanges: Map<String, ChangeMembershipState> = emptyMap(),
val isInviteExpanded: Boolean = true,
val isFavouriteRoomsExpanded: Boolean = true,
val isDirectRoomsExpanded: Boolean = true,
val isGroupRoomsExpanded: Boolean = true,
val isLowPriorityRoomsExpanded: Boolean = true,
val isServerNoticeRoomsExpanded: Boolean = true
val roomMembershipChanges: Map<String, ChangeMembershipState> = emptyMap()
) : MvRxState {
constructor(args: RoomListParams) : this(displayMode = args.displayMode)
fun isCategoryExpanded(roomCategory: RoomCategory): Boolean {
return when (roomCategory) {
RoomCategory.INVITE -> isInviteExpanded
RoomCategory.FAVOURITE -> isFavouriteRoomsExpanded
RoomCategory.DIRECT -> isDirectRoomsExpanded
RoomCategory.GROUP -> isGroupRoomsExpanded
RoomCategory.LOW_PRIORITY -> isLowPriorityRoomsExpanded
RoomCategory.SERVER_NOTICE -> isServerNoticeRoomsExpanded
}
}
fun toggle(roomCategory: RoomCategory): RoomListViewState {
return when (roomCategory) {
RoomCategory.INVITE -> copy(isInviteExpanded = !isInviteExpanded)
RoomCategory.FAVOURITE -> copy(isFavouriteRoomsExpanded = !isFavouriteRoomsExpanded)
RoomCategory.DIRECT -> copy(isDirectRoomsExpanded = !isDirectRoomsExpanded)
RoomCategory.GROUP -> copy(isGroupRoomsExpanded = !isGroupRoomsExpanded)
RoomCategory.LOW_PRIORITY -> copy(isLowPriorityRoomsExpanded = !isLowPriorityRoomsExpanded)
RoomCategory.SERVER_NOTICE -> copy(isServerNoticeRoomsExpanded = !isServerNoticeRoomsExpanded)
}
}
val hasUnread: Boolean
get() = asyncFilteredRooms.invoke()
?.flatMap { it.value }
?.filter { it.membership == Membership.JOIN }
?.any { it.hasUnreadMessages }
?: false
}
typealias RoomSummaries = LinkedHashMap<RoomCategory, List<RoomSummary>>
enum class RoomCategory(@StringRes val titleRes: Int) {
INVITE(R.string.invitations_header),
FAVOURITE(R.string.bottom_action_favourites),
DIRECT(R.string.bottom_action_people_x),
GROUP(R.string.bottom_action_rooms),
LOW_PRIORITY(R.string.low_priority_header),
SERVER_NOTICE(R.string.system_alerts_header)
}
fun RoomSummaries?.isNullOrEmpty(): Boolean {
return this == null || this.values.flatten().isEmpty()
}

View File

@ -1,170 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.list
import androidx.annotation.StringRes
import com.airbnb.epoxy.EpoxyController
import im.vector.app.R
import im.vector.app.core.epoxy.helpFooterItem
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.resources.UserPreferencesProvider
import im.vector.app.features.home.RoomListDisplayMode
import im.vector.app.features.home.room.filtered.FilteredRoomFooterItem
import im.vector.app.features.home.room.filtered.filteredRoomFooterItem
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.RoomSummary
import javax.inject.Inject
class RoomSummaryController @Inject constructor(private val stringProvider: StringProvider,
private val roomSummaryItemFactory: RoomSummaryItemFactory,
private val roomListNameFilter: RoomListNameFilter,
private val userPreferencesProvider: UserPreferencesProvider
) : EpoxyController() {
var listener: Listener? = null
private var viewState: RoomListViewState? = null
init {
// We are requesting a model build directly as the first build of epoxy is on the main thread.
// It avoids to build the whole list of rooms on the main thread.
requestModelBuild()
}
fun update(viewState: RoomListViewState) {
this.viewState = viewState
requestModelBuild()
}
fun onRoomLongClicked() {
userPreferencesProvider.neverShowLongClickOnRoomHelpAgain()
requestModelBuild()
}
override fun buildModels() {
val nonNullViewState = viewState ?: return
when (nonNullViewState.displayMode) {
RoomListDisplayMode.FILTERED -> buildFilteredRooms(nonNullViewState)
else -> buildRooms(nonNullViewState)
}
}
private fun buildFilteredRooms(viewState: RoomListViewState) {
val summaries = viewState.asyncRooms() ?: return
roomListNameFilter.filter = viewState.roomFilter
val filteredSummaries = summaries
.filter { it.membership == Membership.JOIN && roomListNameFilter.test(it) }
buildRoomModels(filteredSummaries,
viewState.roomMembershipChanges,
emptySet())
addFilterFooter(viewState)
}
private fun buildRooms(viewState: RoomListViewState) {
var showHelp = false
val roomSummaries = viewState.asyncFilteredRooms()
roomSummaries?.forEach { (category, summaries) ->
if (summaries.isEmpty()) {
return@forEach
} else {
val isExpanded = viewState.isCategoryExpanded(category)
buildRoomCategory(viewState, summaries, category.titleRes, viewState.isCategoryExpanded(category)) {
listener?.onToggleRoomCategory(category)
}
if (isExpanded) {
buildRoomModels(summaries,
viewState.roomMembershipChanges,
emptySet())
// Never set showHelp to true for invitation
if (category != RoomCategory.INVITE) {
showHelp = userPreferencesProvider.shouldShowLongClickOnRoomHelp()
}
}
}
}
if (showHelp) {
buildLongClickHelp()
}
}
private fun buildLongClickHelp() {
helpFooterItem {
id("long_click_help")
text(stringProvider.getString(R.string.help_long_click_on_room_for_more_options))
}
}
private fun addFilterFooter(viewState: RoomListViewState) {
filteredRoomFooterItem {
id("filter_footer")
listener(listener)
currentFilter(viewState.roomFilter)
}
}
private fun buildRoomCategory(viewState: RoomListViewState,
summaries: List<RoomSummary>,
@StringRes titleRes: Int,
isExpanded: Boolean,
mutateExpandedState: () -> Unit) {
// TODO should add some business logic later
val unreadCount = if (summaries.isEmpty()) {
0
} else {
summaries.map { it.notificationCount }.sumBy { i -> i }
}
val showHighlighted = summaries.any { it.highlightCount > 0 }
roomCategoryItem {
id(titleRes)
title(stringProvider.getString(titleRes))
expanded(isExpanded)
unreadNotificationCount(unreadCount)
showHighlighted(showHighlighted)
listener {
mutateExpandedState()
update(viewState)
}
}
}
private fun buildRoomModels(summaries: List<RoomSummary>,
roomChangedMembershipStates: Map<String, ChangeMembershipState>,
selectedRoomIds: Set<String>) {
summaries.forEach { roomSummary ->
roomSummaryItemFactory
.create(roomSummary,
roomChangedMembershipStates,
selectedRoomIds,
listener)
.addTo(this)
}
}
interface Listener : FilteredRoomFooterItem.FilteredRoomFooterItemListener {
fun onToggleRoomCategory(roomCategory: RoomCategory)
fun onRoomClicked(room: RoomSummary)
fun onRoomLongClicked(room: RoomSummary): Boolean
fun onRejectRoomInvitation(room: RoomSummary)
fun onAcceptRoomInvitation(room: RoomSummary)
}
}

View File

@ -40,7 +40,7 @@ class RoomSummaryItemFactory @Inject constructor(private val displayableEventFor
fun create(roomSummary: RoomSummary,
roomChangeMembershipStates: Map<String, ChangeMembershipState>,
selectedRoomIds: Set<String>,
listener: RoomSummaryController.Listener?): VectorEpoxyModel<*> {
listener: RoomListListener?): VectorEpoxyModel<*> {
return when (roomSummary.membership) {
Membership.INVITE -> {
val changeMembershipState = roomChangeMembershipStates[roomSummary.roomId] ?: ChangeMembershipState.Unknown
@ -52,7 +52,7 @@ class RoomSummaryItemFactory @Inject constructor(private val displayableEventFor
private fun createInvitationItem(roomSummary: RoomSummary,
changeMembershipState: ChangeMembershipState,
listener: RoomSummaryController.Listener?): VectorEpoxyModel<*> {
listener: RoomListListener?): VectorEpoxyModel<*> {
val secondLine = if (roomSummary.isDirect) {
roomSummary.inviterId
} else {

View File

@ -0,0 +1,68 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.list
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.paging.PagedListEpoxyController
import im.vector.app.core.utils.createUIHandler
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import javax.inject.Inject
class RoomSummaryPagedControllerFactory @Inject constructor(
private val roomSummaryItemFactory: RoomSummaryItemFactory
) {
fun createRoomSummaryPagedController(): RoomSummaryPagedController {
return RoomSummaryPagedController(roomSummaryItemFactory)
}
}
class RoomSummaryPagedController(
private val roomSummaryItemFactory: RoomSummaryItemFactory
) : PagedListEpoxyController<RoomSummary>(
// Important it must match the PageList builder notify Looper
modelBuildingHandler = createUIHandler()
) {
var listener: RoomListListener? = null
var roomChangeMembershipStates: Map<String, ChangeMembershipState>? = null
set(value) {
field = value
// ideally we could search for visible models and update only those
requestForcedModelBuild()
}
override fun buildItemModel(currentPosition: Int, item: RoomSummary?): EpoxyModel<*> {
// for place holder if enabled
item ?: return roomSummaryItemFactory.createRoomItem(
roomSummary = RoomSummary(
roomId = "null_item_pos_$currentPosition",
name = "",
encryptionEventTs = null,
isEncrypted = false,
typingUsers = emptyList()
),
selectedRoomIds = emptySet(),
onClick = null,
onLongClick = null
)
return roomSummaryItemFactory.create(item, roomChangeMembershipStates.orEmpty(), emptySet(), listener)
}
}

View File

@ -0,0 +1,31 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.PagedList
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount
data class RoomsSection(
val sectionName: String,
val livePages: LiveData<PagedList<RoomSummary>>,
val isExpanded: MutableLiveData<Boolean> = MutableLiveData(true),
val notificationCount: MutableLiveData<RoomAggregateNotificationCount> = MutableLiveData(RoomAggregateNotificationCount(0, 0)),
val notifyOfLocalEcho: Boolean = false
)

View File

@ -0,0 +1,100 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.list
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.recyclerview.widget.RecyclerView
import im.vector.app.R
import im.vector.app.core.utils.DebouncedClickListener
import im.vector.app.databinding.ItemRoomCategoryBinding
import im.vector.app.features.themes.ThemeUtils
class SectionHeaderAdapter constructor(
private val onClickAction: (() -> Unit)
) : RecyclerView.Adapter<SectionHeaderAdapter.VH>() {
data class RoomsSectionData(
val name: String,
val isExpanded: Boolean = true,
val notificationCount: Int = 0,
val isHighlighted: Boolean = false,
val isHidden: Boolean = true
)
lateinit var roomsSectionData: RoomsSectionData
private set
fun updateSection(newRoomsSectionData: RoomsSectionData) {
if (!::roomsSectionData.isInitialized || newRoomsSectionData != roomsSectionData) {
roomsSectionData = newRoomsSectionData
notifyDataSetChanged()
}
}
init {
setHasStableIds(true)
}
override fun getItemId(position: Int) = roomsSectionData.hashCode().toLong()
override fun getItemViewType(position: Int) = R.layout.item_room_category
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
return VH.create(parent, this.onClickAction)
}
override fun onBindViewHolder(holder: VH, position: Int) {
holder.bind(roomsSectionData)
}
override fun getItemCount(): Int = if (roomsSectionData.isHidden) 0 else 1
class VH constructor(
private val binding: ItemRoomCategoryBinding,
onClickAction: (() -> Unit)
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.root.setOnClickListener(DebouncedClickListener({
onClickAction.invoke()
}))
}
fun bind(roomsSectionData: RoomsSectionData) {
binding.roomCategoryTitleView.text = roomsSectionData.name
val tintColor = ThemeUtils.getColor(binding.root.context, R.attr.riotx_text_secondary)
val expandedArrowDrawableRes = if (roomsSectionData.isExpanded) R.drawable.ic_expand_more_white else R.drawable.ic_expand_less_white
val expandedArrowDrawable = ContextCompat.getDrawable(binding.root.context, expandedArrowDrawableRes)?.also {
DrawableCompat.setTint(it, tintColor)
}
binding.roomCategoryUnreadCounterBadgeView.render(UnreadCounterBadgeView.State(roomsSectionData.notificationCount, roomsSectionData.isHighlighted))
binding.roomCategoryTitleView.setCompoundDrawablesWithIntrinsicBounds(null, null, expandedArrowDrawable, null)
}
companion object {
fun create(parent: ViewGroup, onClickAction: () -> Unit): VH {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_room_category, parent, false)
val binding = ItemRoomCategoryBinding.bind(view)
return VH(binding, onClickAction)
}
}
}
}