Merge branch 'develop' into feature/aris/thread_live_thread_list

# Conflicts:
#	vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt
This commit is contained in:
ariskotsomitopoulos 2022-03-15 14:21:04 +01:00
commit 8a862d006e
82 changed files with 1107 additions and 353 deletions

1
changelog.d/4533.misc Normal file
View file

@ -0,0 +1 @@
Improve headers UI in Rooms/Messages lists

1
changelog.d/5340.bugfix Normal file
View file

@ -0,0 +1 @@
Support both stable and unstable prefixes for Events about Polls and Location

1
changelog.d/5375.wip Normal file
View file

@ -0,0 +1 @@
Dynamically showing/hiding onboarding personalisation screens based on the users homeserver capabilities

1
changelog.d/5514.bugfix Normal file
View file

@ -0,0 +1 @@
Read receipt in wrong order

View file

@ -58,6 +58,7 @@ ext.libs = [
'lifecycleCommon' : "androidx.lifecycle:lifecycle-common:$lifecycle",
'lifecycleLivedata' : "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle",
'lifecycleProcess' : "androidx.lifecycle:lifecycle-process:$lifecycle",
'lifecycleRuntimeKtx' : "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle",
'datastore' : "androidx.datastore:datastore:1.0.0",
'datastorepreferences' : "androidx.datastore:datastore-preferences:1.0.0",
'pagingRuntimeKtx' : "androidx.paging:paging-runtime-ktx:2.1.2",
@ -141,4 +142,4 @@ ext.libs = [
'timberJunitRule' : "net.lachlanmckee:timber-junit-rule:1.0.1",
'junit' : "junit:junit:4.13.2"
]
]
]

View file

@ -353,7 +353,7 @@ fun Event.isAttachmentMessage(): Boolean {
}
}
fun Event.isPoll(): Boolean = getClearType() == EventType.POLL_START || getClearType() == EventType.POLL_END
fun Event.isPoll(): Boolean = getClearType() in EventType.POLL_START || getClearType() in EventType.POLL_END
fun Event.isSticker(): Boolean = getClearType() == EventType.STICKER
@ -376,7 +376,7 @@ fun Event.getRelationContent(): RelationDefaultContent? {
* Returns the poll question or null otherwise
*/
fun Event.getPollQuestion(): String? =
getPollContent()?.pollCreationInfo?.question?.question
getPollContent()?.getBestPollCreationInfo()?.question?.getBestQuestion()
/**
* Returns the relation content for a specific type or null otherwise

View file

@ -103,9 +103,9 @@ object EventType {
const val REACTION = "m.reaction"
// Poll
const val POLL_START = "org.matrix.msc3381.poll.start"
const val POLL_RESPONSE = "org.matrix.msc3381.poll.response"
const val POLL_END = "org.matrix.msc3381.poll.end"
val POLL_START = listOf("org.matrix.msc3381.poll.start", "m.poll.start")
val POLL_RESPONSE = listOf("org.matrix.msc3381.poll.response", "m.poll.response")
val POLL_END = listOf("org.matrix.msc3381.poll.end", "m.poll.end")
// Unwedging
internal const val DUMMY = "m.dummy"

View file

@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.session.room
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import kotlinx.coroutines.flow.Flow
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.model.Membership
@ -216,6 +217,11 @@ interface RoomService {
pagedListConfig: PagedList.Config = defaultPagedListConfig,
sortOrder: RoomSortOrder = RoomSortOrder.ACTIVITY): UpdatableLivePageResult
/**
* Retrieve a flow on the number of rooms.
*/
fun getRoomCountFlow(queryParams: RoomSummaryQueryParams): Flow<Int>
/**
* TODO Doc
*/

View file

@ -22,10 +22,8 @@ import org.matrix.android.sdk.api.session.room.model.RoomSummary
interface UpdatableLivePageResult {
val livePagedList: LiveData<PagedList<RoomSummary>>
fun updateQuery(builder: (RoomSummaryQueryParams) -> RoomSummaryQueryParams)
val liveBoundaries: LiveData<ResultBoundaries>
var queryParams: RoomSummaryQueryParams
}
data class ResultBoundaries(

View file

@ -39,37 +39,46 @@ data class MessageLocationContent(
*/
@Json(name = "geo_uri") val geoUri: String,
@Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null,
@Json(name = "m.new_content") override val newContent: Content? = null,
/**
* See https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md
*/
@Json(name = "org.matrix.msc3488.location") val locationInfo: LocationInfo? = null,
@Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null,
@Json(name = "m.new_content") override val newContent: Content? = null,
@Json(name = "org.matrix.msc3488.location") val unstableLocationInfo: LocationInfo? = null,
@Json(name = "m.location") val locationInfo: LocationInfo? = null,
/**
* Exact time that the data in the event refers to (milliseconds since the UNIX epoch)
*/
@Json(name = "org.matrix.msc3488.ts") val unstableTs: Long? = null,
@Json(name = "m.ts") val ts: Long? = null,
@Json(name = "org.matrix.msc1767.text") val unstableText: String? = null,
@Json(name = "m.text") val text: String? = null,
/**
* m.asset defines a generic asset that can be used for location tracking but also in other places like
* inventories, geofencing, checkins/checkouts etc.
* It should contain a mandatory namespaced type key defining what particular asset is being referred to.
* For the purposes of user location tracking m.self should be used in order to avoid duplicating the mxid.
*/
@Json(name = "m.asset") val locationAsset: LocationAsset? = null,
/**
* Exact time that the data in the event refers to (milliseconds since the UNIX epoch)
*/
@Json(name = "org.matrix.msc3488.ts") val ts: Long? = null,
@Json(name = "org.matrix.msc1767.text") val text: String? = null
@Json(name = "org.matrix.msc3488.asset") val unstableLocationAsset: LocationAsset? = null,
@Json(name = "m.asset") val locationAsset: LocationAsset? = null
) : MessageContent {
fun getBestGeoUri() = locationInfo?.geoUri ?: geoUri
fun getBestLocationInfo() = locationInfo ?: unstableLocationInfo
fun getBestTs() = ts ?: unstableTs
fun getBestText() = text ?: unstableText
fun getBestLocationAsset() = locationAsset ?: unstableLocationAsset
fun getBestGeoUri() = getBestLocationInfo()?.geoUri ?: geoUri
/**
* @return true if the location asset is a user location, not a generic one.
*/
fun isSelfLocation(): Boolean {
// Should behave like m.self if locationAsset is null
val locationAsset = getBestLocationAsset()
return locationAsset?.type == null || locationAsset.type == LocationAssetType.SELF
}
}

View file

@ -31,5 +31,9 @@ data class MessagePollContent(
@Json(name = "body") override val body: String = "",
@Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null,
@Json(name = "m.new_content") override val newContent: Content? = null,
@Json(name = "org.matrix.msc3381.poll.start") val pollCreationInfo: PollCreationInfo? = null
) : MessageContent
@Json(name = "org.matrix.msc3381.poll.start") val unstablePollCreationInfo: PollCreationInfo? = null,
@Json(name = "m.poll.start") val pollCreationInfo: PollCreationInfo? = null
) : MessageContent {
fun getBestPollCreationInfo() = pollCreationInfo ?: unstablePollCreationInfo
}

View file

@ -31,5 +31,9 @@ data class MessagePollResponseContent(
@Json(name = "body") override val body: String = "",
@Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null,
@Json(name = "m.new_content") override val newContent: Content? = null,
@Json(name = "org.matrix.msc3381.poll.response") val response: PollResponse? = null
) : MessageContent
@Json(name = "org.matrix.msc3381.poll.response") val unstableResponse: PollResponse? = null,
@Json(name = "m.response") val response: PollResponse? = null
) : MessageContent {
fun getBestResponse() = response ?: unstableResponse
}

View file

@ -22,5 +22,9 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PollAnswer(
@Json(name = "id") val id: String? = null,
@Json(name = "org.matrix.msc1767.text") val answer: String? = null
)
@Json(name = "org.matrix.msc1767.text") val unstableAnswer: String? = null,
@Json(name = "m.text") val answer: String? = null
) {
fun getBestAnswer() = answer ?: unstableAnswer
}

View file

@ -21,8 +21,8 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PollCreationInfo(
@Json(name = "question") val question: PollQuestion? = null,
@Json(name = "kind") val kind: PollType? = PollType.DISCLOSED,
@Json(name = "max_selections") val maxSelections: Int = 1,
@Json(name = "answers") val answers: List<PollAnswer>? = null
@Json(name = "question") val question: PollQuestion? = null,
@Json(name = "kind") val kind: PollType? = PollType.DISCLOSED_UNSTABLE,
@Json(name = "max_selections") val maxSelections: Int = 1,
@Json(name = "answers") val answers: List<PollAnswer>? = null
)

View file

@ -21,5 +21,9 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PollQuestion(
@Json(name = "org.matrix.msc1767.text") val question: String? = null
)
@Json(name = "org.matrix.msc1767.text") val unstableQuestion: String? = null,
@Json(name = "m.text") val question: String? = null
) {
fun getBestQuestion() = question ?: unstableQuestion
}

View file

@ -25,11 +25,17 @@ enum class PollType {
* Voters should see results as soon as they have voted.
*/
@Json(name = "org.matrix.msc3381.poll.disclosed")
DISCLOSED_UNSTABLE,
@Json(name = "m.poll.disclosed")
DISCLOSED,
/**
* Results should be only revealed when the poll is ended.
*/
@Json(name = "org.matrix.msc3381.poll.undisclosed")
UNDISCLOSED_UNSTABLE,
@Json(name = "m.poll.undisclosed")
UNDISCLOSED
}

View file

@ -32,7 +32,6 @@ object RoomSummaryConstants {
EventType.CALL_ANSWER,
EventType.ENCRYPTED,
EventType.STICKER,
EventType.REACTION,
EventType.POLL_START
)
EventType.REACTION
) + EventType.POLL_START
}

View file

@ -135,9 +135,9 @@ fun TimelineEvent.getEditedEventId(): String? {
*/
fun TimelineEvent.getLastMessageContent(): MessageContent? {
return when (root.getClearType()) {
EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>()
EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessagePollContent>()
else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>()
in EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessagePollContent>()
else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
}
}

View file

@ -56,7 +56,7 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
val allEvents = (newJoinEvents + inviteEvents).filter { event ->
when (event.type) {
EventType.POLL_START,
in EventType.POLL_START,
EventType.MESSAGE,
EventType.REDACTION,
EventType.ENCRYPTED,

View file

@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.paging.PagedList
import com.zhuinden.monarchy.Monarchy
import kotlinx.coroutines.flow.Flow
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
@ -109,6 +110,10 @@ internal class DefaultRoomService @Inject constructor(
return roomSummaryDataSource.getUpdatablePagedRoomSummariesLive(queryParams, pagedListConfig, sortOrder)
}
override fun getRoomCountFlow(queryParams: RoomSummaryQueryParams): Flow<Int> {
return roomSummaryDataSource.getCountFlow(queryParams)
}
override fun getNotificationCountForRooms(queryParams: RoomSummaryQueryParams): RoomAggregateNotificationCount {
return roomSummaryDataSource.getNotificationCountForRooms(queryParams)
}

View file

@ -87,11 +87,8 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
// TODO Add ?
// EventType.KEY_VERIFICATION_READY,
EventType.KEY_VERIFICATION_KEY,
EventType.ENCRYPTED,
EventType.POLL_START,
EventType.POLL_RESPONSE,
EventType.POLL_END
)
EventType.ENCRYPTED
) + EventType.POLL_START + EventType.POLL_RESPONSE + EventType.POLL_END
override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean {
return allowedTypes.contains(eventType)
@ -157,7 +154,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
// A replace!
handleReplace(realm, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId)
} else if (event.getClearType() == EventType.POLL_RESPONSE) {
} else if (event.getClearType() in EventType.POLL_RESPONSE) {
event.getClearContent().toModel<MessagePollResponseContent>(catchError = true)?.let { pollResponseContent ->
Timber.v("###RESPONSE in room $roomId for event ${event.eventId}")
handleResponse(realm, event, pollResponseContent, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId)
@ -178,12 +175,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
handleVerification(realm, event, roomId, isLocalEcho, it)
}
}
EventType.POLL_RESPONSE -> {
in EventType.POLL_RESPONSE -> {
event.getClearContent().toModel<MessagePollResponseContent>(catchError = true)?.let {
handleResponse(realm, event, it, roomId, isLocalEcho, event.getRelationContent()?.eventId)
}
}
EventType.POLL_END -> {
in EventType.POLL_END -> {
event.content.toModel<MessageEndPollContent>(catchError = true)?.let {
handleEndPoll(realm, event, it, roomId, isLocalEcho)
}
@ -228,7 +225,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
}
}
}
EventType.POLL_START -> {
in EventType.POLL_START -> {
val content: MessagePollContent? = event.content.toModel()
if (content?.relatesTo?.type == RelationType.REPLACE) {
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
@ -236,12 +233,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
handleReplace(realm, event, content, roomId, isLocalEcho)
}
}
EventType.POLL_RESPONSE -> {
in EventType.POLL_RESPONSE -> {
event.content.toModel<MessagePollResponseContent>(catchError = true)?.let {
handleResponse(realm, event, it, roomId, isLocalEcho)
}
}
EventType.POLL_END -> {
in EventType.POLL_END -> {
event.content.toModel<MessageEndPollContent>(catchError = true)?.let {
handleEndPoll(realm, event, it, roomId, isLocalEcho)
}
@ -423,12 +420,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
return
}
val option = content.response?.answers?.first() ?: return Unit.also {
val option = content.getBestResponse()?.answers?.first() ?: return Unit.also {
Timber.d("## POLL Ignoring malformed response no option eventId:$eventId content: ${event.content}")
}
// Check if this option is in available options
if (!targetPollContent.pollCreationInfo?.answers?.map { it.id }?.contains(option).orFalse()) {
if (!targetPollContent.getBestPollCreationInfo()?.answers?.map { it.id }?.contains(option).orFalse()) {
Timber.v("## POLL $targetEventId doesn't contain option $option")
return
}

View file

@ -71,7 +71,7 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr
when (typeToPrune) {
EventType.ENCRYPTED,
EventType.MESSAGE,
EventType.POLL_START -> {
in EventType.POLL_START -> {
Timber.d("REDACTION for message ${eventToPrune.eventId}")
val unsignedData = EventMapper.map(eventToPrune).unsignedData
?: UnsignedData(null, null)

View file

@ -137,16 +137,11 @@ internal class LocalEchoEventFactory @Inject constructor(
options: List<String>,
pollType: PollType): MessagePollContent {
return MessagePollContent(
pollCreationInfo = PollCreationInfo(
question = PollQuestion(
question = question
),
unstablePollCreationInfo = PollCreationInfo(
question = PollQuestion(unstableQuestion = question),
kind = pollType,
answers = options.map { option ->
PollAnswer(
id = UUID.randomUUID().toString(),
answer = option
)
PollAnswer(id = UUID.randomUUID().toString(), unstableAnswer = option)
}
)
)
@ -167,7 +162,7 @@ internal class LocalEchoEventFactory @Inject constructor(
originServerTs = dummyOriginServerTs(),
senderId = userId,
eventId = localId,
type = EventType.POLL_START,
type = EventType.POLL_START.first(),
content = newContent.toContent()
)
}
@ -179,11 +174,9 @@ internal class LocalEchoEventFactory @Inject constructor(
body = answerId,
relatesTo = RelationDefaultContent(
type = RelationType.REFERENCE,
eventId = pollEventId),
response = PollResponse(
answers = listOf(answerId)
)
eventId = pollEventId
),
unstableResponse = PollResponse(answers = listOf(answerId))
)
val localId = LocalEcho.createLocalEchoId()
return Event(
@ -191,7 +184,7 @@ internal class LocalEchoEventFactory @Inject constructor(
originServerTs = dummyOriginServerTs(),
senderId = userId,
eventId = localId,
type = EventType.POLL_RESPONSE,
type = EventType.POLL_RESPONSE.first(),
content = content.toContent(),
unsignedData = UnsignedData(age = null, transactionId = localId))
}
@ -207,7 +200,7 @@ internal class LocalEchoEventFactory @Inject constructor(
originServerTs = dummyOriginServerTs(),
senderId = userId,
eventId = localId,
type = EventType.POLL_START,
type = EventType.POLL_START.first(),
content = content.toContent(),
unsignedData = UnsignedData(age = null, transactionId = localId))
}
@ -226,7 +219,7 @@ internal class LocalEchoEventFactory @Inject constructor(
originServerTs = dummyOriginServerTs(),
senderId = userId,
eventId = localId,
type = EventType.POLL_END,
type = EventType.POLL_END.first(),
content = content.toContent(),
unsignedData = UnsignedData(age = null, transactionId = localId))
}
@ -239,15 +232,10 @@ internal class LocalEchoEventFactory @Inject constructor(
val content = MessageLocationContent(
geoUri = geoUri,
body = geoUri,
locationInfo = LocationInfo(
geoUri = geoUri,
description = geoUri
),
locationAsset = LocationAsset(
type = LocationAssetType.SELF
),
ts = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()),
text = geoUri
unstableLocationInfo = LocationInfo(geoUri = geoUri, description = geoUri),
unstableLocationAsset = LocationAsset(type = LocationAssetType.SELF),
unstableTs = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()),
unstableText = geoUri
)
return createMessageEvent(roomId, content)
}
@ -644,7 +632,9 @@ internal class LocalEchoEventFactory @Inject constructor(
MessageType.MSGTYPE_AUDIO -> return TextContent("sent an audio file.")
MessageType.MSGTYPE_IMAGE -> return TextContent("sent an image.")
MessageType.MSGTYPE_VIDEO -> return TextContent("sent a video.")
MessageType.MSGTYPE_POLL_START -> return TextContent((content as? MessagePollContent)?.pollCreationInfo?.question?.question ?: "")
MessageType.MSGTYPE_POLL_START -> {
return TextContent((content as? MessagePollContent)?.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "")
}
else -> return TextContent(content?.body ?: "")
}
}

View file

@ -25,7 +25,13 @@ import androidx.paging.PagedList
import com.zhuinden.monarchy.Monarchy
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.kotlin.toFlow
import io.realm.kotlin.where
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
import org.matrix.android.sdk.api.query.ActiveSpaceFilter
import org.matrix.android.sdk.api.query.RoomCategoryFilter
import org.matrix.android.sdk.api.query.isNormalized
@ -42,6 +48,7 @@ import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotification
import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
import org.matrix.android.sdk.internal.database.RealmSessionProvider
import org.matrix.android.sdk.internal.database.mapper.RoomSummaryMapper
import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity
import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields
@ -55,8 +62,10 @@ import javax.inject.Inject
internal class RoomSummaryDataSource @Inject constructor(
@SessionDatabase private val monarchy: Monarchy,
private val realmSessionProvider: RealmSessionProvider,
private val roomSummaryMapper: RoomSummaryMapper,
private val queryStringValueProcessor: QueryStringValueProcessor
private val queryStringValueProcessor: QueryStringValueProcessor,
private val coroutineDispatchers: MatrixCoroutineDispatchers
) {
fun getRoomSummary(roomIdOrAlias: String): RoomSummary? {
@ -219,17 +228,29 @@ internal class RoomSummaryDataSource @Inject constructor(
return object : UpdatableLivePageResult {
override val livePagedList: LiveData<PagedList<RoomSummary>> = mapped
override fun updateQuery(builder: (RoomSummaryQueryParams) -> RoomSummaryQueryParams) {
realmDataSourceFactory.updateQuery {
roomSummariesQuery(it, builder.invoke(queryParams)).process(sortOrder)
}
}
override val liveBoundaries: LiveData<ResultBoundaries>
get() = boundaries
override var queryParams: RoomSummaryQueryParams = queryParams
set(value) {
field = value
realmDataSourceFactory.updateQuery {
roomSummariesQuery(it, value).process(sortOrder)
}
}
}
}
fun getCountFlow(queryParams: RoomSummaryQueryParams): Flow<Int> =
realmSessionProvider
.withRealm { realm -> roomSummariesQuery(realm, queryParams).findAllAsync() }
.toFlow()
// need to create the flow on a context dispatcher with a thread with attached Looper
.flowOn(coroutineDispatchers.main)
.map { it.size }
.flowOn(coroutineDispatchers.io)
.distinctUntilChanged()
fun getNotificationCountForRooms(queryParams: RoomSummaryQueryParams): RoomAggregateNotificationCount {
var notificationCount: RoomAggregateNotificationCount? = null
monarchy.doWithRealm { realm ->

View file

@ -355,6 +355,7 @@ dependencies {
// Lifecycle
implementation libs.androidx.lifecycleLivedata
implementation libs.androidx.lifecycleProcess
implementation libs.androidx.lifecycleRuntimeKtx
implementation libs.androidx.datastore
implementation libs.androidx.datastorepreferences

View file

@ -22,13 +22,17 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import im.vector.app.features.HomeserverCapabilitiesOverride
import im.vector.app.features.VectorOverrides
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import org.matrix.android.sdk.api.extensions.orFalse
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "vector_overrides")
private val keyForceDialPadDisplay = booleanPreferencesKey("force_dial_pad_display")
private val keyForceLoginFallback = booleanPreferencesKey("force_login_fallback")
private val forceCanChangeDisplayName = booleanPreferencesKey("force_can_change_display_name")
private val forceCanChangeAvatar = booleanPreferencesKey("force_can_change_avatar")
class DebugVectorOverrides(private val context: Context) : VectorOverrides {
@ -40,6 +44,13 @@ class DebugVectorOverrides(private val context: Context) : VectorOverrides {
preferences[keyForceLoginFallback].orFalse()
}
override val forceHomeserverCapabilities = context.dataStore.data.map { preferences ->
HomeserverCapabilitiesOverride(
canChangeDisplayName = preferences[forceCanChangeDisplayName],
canChangeAvatar = preferences[forceCanChangeAvatar]
)
}
suspend fun setForceDialPadDisplay(force: Boolean) {
context.dataStore.edit { settings ->
settings[keyForceDialPadDisplay] = force
@ -51,4 +62,18 @@ class DebugVectorOverrides(private val context: Context) : VectorOverrides {
settings[keyForceLoginFallback] = force
}
}
suspend fun setHomeserverCapabilities(block: HomeserverCapabilitiesOverride.() -> HomeserverCapabilitiesOverride) {
val capabilitiesOverride = block(forceHomeserverCapabilities.firstOrNull() ?: HomeserverCapabilitiesOverride(null, null))
context.dataStore.edit { settings ->
when (capabilitiesOverride.canChangeDisplayName) {
null -> settings.remove(forceCanChangeDisplayName)
else -> settings[forceCanChangeDisplayName] = capabilitiesOverride.canChangeDisplayName
}
when (capabilitiesOverride.canChangeAvatar) {
null -> settings.remove(forceCanChangeAvatar)
else -> settings[forceCanChangeAvatar] = capabilitiesOverride.canChangeAvatar
}
}
}
}

View file

@ -50,6 +50,12 @@ class DebugPrivateSettingsFragment : VectorBaseFragment<FragmentDebugPrivateSett
override fun invalidate() = withState(viewModel) {
views.forceDialPadTabDisplay.isChecked = it.dialPadVisible
views.forceChangeDisplayNameCapability.bind(it.homeserverCapabilityOverrides.displayName) { option ->
viewModel.handle(DebugPrivateSettingsViewActions.SetDisplayNameCapabilityOverride(option))
}
views.forceChangeAvatarCapability.bind(it.homeserverCapabilityOverrides.avatar) { option ->
viewModel.handle(DebugPrivateSettingsViewActions.SetAvatarCapabilityOverride(option))
}
views.forceLoginFallback.isChecked = it.forceLoginFallback
}
}

View file

@ -18,7 +18,9 @@ package im.vector.app.features.debug.settings
import im.vector.app.core.platform.VectorViewModelAction
sealed class DebugPrivateSettingsViewActions : VectorViewModelAction {
data class SetDialPadVisibility(val force: Boolean) : DebugPrivateSettingsViewActions()
data class SetForceLoginFallbackEnabled(val force: Boolean) : DebugPrivateSettingsViewActions()
sealed interface DebugPrivateSettingsViewActions : VectorViewModelAction {
data class SetDialPadVisibility(val force: Boolean) : DebugPrivateSettingsViewActions
data class SetForceLoginFallbackEnabled(val force: Boolean) : DebugPrivateSettingsViewActions
data class SetDisplayNameCapabilityOverride(val option: BooleanHomeserverCapabilitiesOverride?) : DebugPrivateSettingsViewActions
data class SetAvatarCapabilityOverride(val option: BooleanHomeserverCapabilitiesOverride?) : DebugPrivateSettingsViewActions
}

View file

@ -22,9 +22,12 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.debug.features.DebugVectorOverrides
import im.vector.app.features.debug.settings.DebugPrivateSettingsViewActions.SetAvatarCapabilityOverride
import im.vector.app.features.debug.settings.DebugPrivateSettingsViewActions.SetDisplayNameCapabilityOverride
import kotlinx.coroutines.launch
class DebugPrivateSettingsViewModel @AssistedInject constructor(
@ -40,10 +43,10 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory<DebugPrivateSettingsViewModel, DebugPrivateSettingsViewState> by hiltMavericksViewModelFactory()
init {
observeVectorDataStore()
observeVectorOverrides()
}
private fun observeVectorDataStore() {
private fun observeVectorOverrides() {
debugVectorOverrides.forceDialPad.setOnEach {
copy(
dialPadVisible = it
@ -52,13 +55,23 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
debugVectorOverrides.forceLoginFallback.setOnEach {
copy(forceLoginFallback = it)
}
debugVectorOverrides.forceHomeserverCapabilities.setOnEach {
val activeDisplayNameOption = BooleanHomeserverCapabilitiesOverride.from(it.canChangeDisplayName)
val activeAvatarOption = BooleanHomeserverCapabilitiesOverride.from(it.canChangeAvatar)
copy(homeserverCapabilityOverrides = homeserverCapabilityOverrides.copy(
displayName = homeserverCapabilityOverrides.displayName.copy(activeOption = activeDisplayNameOption),
avatar = homeserverCapabilityOverrides.avatar.copy(activeOption = activeAvatarOption),
))
}
}
override fun handle(action: DebugPrivateSettingsViewActions) {
when (action) {
is DebugPrivateSettingsViewActions.SetDialPadVisibility -> handleSetDialPadVisibility(action)
is DebugPrivateSettingsViewActions.SetForceLoginFallbackEnabled -> handleSetForceLoginFallbackEnabled(action)
}
is SetDisplayNameCapabilityOverride -> handSetDisplayNameCapabilityOverride(action)
is SetAvatarCapabilityOverride -> handSetAvatarCapabilityOverride(action)
}.exhaustive
}
private fun handleSetDialPadVisibility(action: DebugPrivateSettingsViewActions.SetDialPadVisibility) {
@ -72,4 +85,18 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
debugVectorOverrides.setForceLoginFallback(action.force)
}
}
private fun handSetDisplayNameCapabilityOverride(action: SetDisplayNameCapabilityOverride) {
viewModelScope.launch {
val forceDisplayName = action.option.toBoolean()
debugVectorOverrides.setHomeserverCapabilities { copy(canChangeDisplayName = forceDisplayName) }
}
}
private fun handSetAvatarCapabilityOverride(action: SetAvatarCapabilityOverride) {
viewModelScope.launch {
val forceAvatar = action.option.toBoolean()
debugVectorOverrides.setHomeserverCapabilities { copy(canChangeAvatar = forceAvatar) }
}
}
}

View file

@ -17,8 +17,23 @@
package im.vector.app.features.debug.settings
import com.airbnb.mvrx.MavericksState
import im.vector.app.features.debug.settings.OverrideDropdownView.OverrideDropdown
data class DebugPrivateSettingsViewState(
val dialPadVisible: Boolean = false,
val forceLoginFallback: Boolean = false,
val homeserverCapabilityOverrides: HomeserverCapabilityOverrides = HomeserverCapabilityOverrides()
) : MavericksState
data class HomeserverCapabilityOverrides(
val displayName: OverrideDropdown<BooleanHomeserverCapabilitiesOverride> = OverrideDropdown(
label = "Override display name capability",
activeOption = null,
options = listOf(BooleanHomeserverCapabilitiesOverride.ForceEnabled, BooleanHomeserverCapabilitiesOverride.ForceDisabled)
),
val avatar: OverrideDropdown<BooleanHomeserverCapabilitiesOverride> = OverrideDropdown(
label = "Override avatar capability",
activeOption = null,
options = listOf(BooleanHomeserverCapabilitiesOverride.ForceEnabled, BooleanHomeserverCapabilitiesOverride.ForceDisabled)
)
)

View file

@ -0,0 +1,86 @@
/*
* Copyright (c) 2022 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.debug.settings
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import im.vector.app.R
import im.vector.app.databinding.ViewBooleanDropdownBinding
class OverrideDropdownView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : LinearLayout(context, attrs) {
private val binding = ViewBooleanDropdownBinding.inflate(
LayoutInflater.from(context),
this
)
init {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
fun <T : OverrideOption> bind(feature: OverrideDropdown<T>, listener: Listener<T>) {
binding.overrideLabel.text = feature.label
binding.overrideOptions.apply {
val arrayAdapter = ArrayAdapter<String>(context, android.R.layout.simple_spinner_dropdown_item)
val options = listOf("Inactive") + feature.options.map { it.label }
arrayAdapter.addAll(options)
adapter = arrayAdapter
feature.activeOption?.let {
setSelection(options.indexOf(it.label), false)
}
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
when (position) {
0 -> listener.onOverrideSelected(option = null)
else -> listener.onOverrideSelected(feature.options[position - 1])
}
}
override fun onNothingSelected(parent: AdapterView<*>?) {
// do nothing
}
}
}
}
fun interface Listener<T> {
fun onOverrideSelected(option: T?)
}
data class OverrideDropdown<T : OverrideOption>(
val label: String,
val options: List<T>,
val activeOption: T?,
)
}
interface OverrideOption {
val label: String
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2022 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.debug.settings
sealed interface BooleanHomeserverCapabilitiesOverride : OverrideOption {
companion object {
fun from(value: Boolean?) = when (value) {
null -> null
true -> ForceEnabled
false -> ForceDisabled
}
}
object ForceEnabled : BooleanHomeserverCapabilitiesOverride {
override val label = "Force enabled"
}
object ForceDisabled : BooleanHomeserverCapabilitiesOverride {
override val label = "Force disabled"
}
}
fun BooleanHomeserverCapabilitiesOverride?.toBoolean() = when (this) {
null -> null
BooleanHomeserverCapabilitiesOverride.ForceDisabled -> false
BooleanHomeserverCapabilitiesOverride.ForceEnabled -> true
}

View file

@ -31,6 +31,24 @@
android:layout_height="wrap_content"
android:text="Force login and registration fallback" />
<im.vector.app.features.debug.settings.OverrideDropdownView
android:id="@+id/forceChangeDisplayNameCapability"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="4dp" />
<im.vector.app.features.debug.settings.OverrideDropdownView
android:id="@+id/forceChangeAvatarCapability"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="4dp" />
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:parentTag="android.widget.LinearLayout">
<TextView
android:id="@+id/overrideLabel"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:gravity="center"
android:textColor="?vctr_content_primary"
tools:text="Login version" />
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/overrideOptions"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</merge>

View file

@ -22,7 +22,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
fun TimelineEvent.canReact(): Boolean {
// Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment
return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER, EventType.POLL_START) &&
return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START &&
root.sendState == SendState.SYNCED &&
!root.isRedacted()
}

View file

@ -22,9 +22,16 @@ import kotlinx.coroutines.flow.flowOf
interface VectorOverrides {
val forceDialPad: Flow<Boolean>
val forceLoginFallback: Flow<Boolean>
val forceHomeserverCapabilities: Flow<HomeserverCapabilitiesOverride>?
}
data class HomeserverCapabilitiesOverride(
val canChangeDisplayName: Boolean?,
val canChangeAvatar: Boolean?
)
class DefaultVectorOverrides : VectorOverrides {
override val forceDialPad = flowOf(false)
override val forceLoginFallback = flowOf(false)
override val forceHomeserverCapabilities: Flow<HomeserverCapabilitiesOverride>? = null
}

View file

@ -1189,7 +1189,7 @@ class TimelineFragment @Inject constructor(
val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong())
getString(R.string.voice_message_reply_content, formattedDuration)
} else if (messageContent is MessagePollContent) {
messageContent.pollCreationInfo?.question?.question
messageContent.getBestPollCreationInfo()?.question?.getBestQuestion()
} else {
messageContent?.body ?: ""
}
@ -2165,7 +2165,7 @@ class TimelineFragment @Inject constructor(
timelineViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
}
is EventSharedAction.Edit -> {
if (action.eventType == EventType.POLL_START) {
if (action.eventType in EventType.POLL_START) {
navigator.openCreatePoll(requireContext(), timelineArgs.roomId, action.eventId, PollMode.EDIT)
} else if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) {
messageComposerViewModel.handle(MessageComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString()))

View file

@ -181,7 +181,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
} else {
when (timelineEvent.root.getClearType()) {
EventType.MESSAGE,
EventType.STICKER -> {
EventType.STICKER -> {
val messageContent: MessageContent? = timelineEvent.getLastMessageContent()
if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) {
val html = messageContent.formattedBody
@ -207,13 +207,14 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
EventType.CALL_INVITE,
EventType.CALL_CANDIDATES,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> {
EventType.CALL_ANSWER -> {
noticeEventFormatter.format(timelineEvent, room?.roomSummary()?.isDirect.orFalse())
}
EventType.POLL_START -> {
timelineEvent.root.getClearContent().toModel<MessagePollContent>(catchError = true)?.pollCreationInfo?.question?.question ?: ""
in EventType.POLL_START -> {
timelineEvent.root.getClearContent().toModel<MessagePollContent>(catchError = true)
?.getBestPollCreationInfo()?.question?.getBestQuestion() ?: ""
}
else -> null
else -> null
}
}
} catch (failure: Throwable) {
@ -373,7 +374,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
}
if (canRedact(timelineEvent, actionPermissions)) {
if (timelineEvent.root.getClearType() == EventType.POLL_START) {
if (timelineEvent.root.getClearType() in EventType.POLL_START) {
add(EventSharedAction.Redact(
eventId,
askForReason = informationData.senderId != session.myUserId,
@ -425,7 +426,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
private fun canReply(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean {
// Only EventType.MESSAGE and EventType.POLL_START event types are supported for the moment
if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.POLL_START)) return false
if (event.root.getClearType() !in EventType.POLL_START + EventType.MESSAGE) return false
if (!actionPermissions.canSendMessage) return false
return when (messageContent?.msgType) {
MessageType.MSGTYPE_TEXT,
@ -511,7 +512,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
private fun canRedact(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean {
// Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment
if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER, EventType.POLL_START)) return false
if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START) return false
// Message sent by the current user can always be redacted
if (event.root.senderId == session.myUserId) return true
// Check permission for messages sent by other users
@ -526,13 +527,13 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
private fun canViewReactions(event: TimelineEvent): Boolean {
// Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment
if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER, EventType.POLL_START)) return false
if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START) return false
return event.annotations?.reactionsSummary?.isNotEmpty() ?: false
}
private fun canEdit(event: TimelineEvent, myUserId: String, actionPermissions: ActionPermissions): Boolean {
// Only event of type EventType.MESSAGE and EventType.POLL_START are supported for the moment
if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.POLL_START)) return false
if (event.root.getClearType() !in listOf(EventType.MESSAGE) + EventType.POLL_START) return false
if (!actionPermissions.canSendMessage) return false
// TODO if user is admin or moderator
val messageContent = event.root.getClearContent().toModel<MessageContent>()
@ -578,13 +579,13 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
}
private fun canEndPoll(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean {
return event.root.getClearType() == EventType.POLL_START &&
return event.root.getClearType() in EventType.POLL_START &&
canRedact(event, actionPermissions) &&
event.annotations?.pollResponseSummary?.closedTime == null
}
private fun canEditPoll(event: TimelineEvent): Boolean {
return event.root.getClearType() == EventType.POLL_START &&
return event.root.getClearType() in EventType.POLL_START &&
event.annotations?.pollResponseSummary?.closedTime == null &&
event.annotations?.pollResponseSummary?.aggregatedContent?.totalVotes ?: 0 == 0
}

View file

@ -247,7 +247,7 @@ class MessageItemFactory @Inject constructor(
val didUserVoted = pollResponseSummary?.myVote?.isNotEmpty().orFalse()
val winnerVoteCount = pollResponseSummary?.winnerVoteCount
val isPollSent = informationData.sendState.isSent()
val isPollUndisclosed = pollContent.pollCreationInfo?.kind == PollType.UNDISCLOSED
val isPollUndisclosed = pollContent.getBestPollCreationInfo()?.kind == PollType.UNDISCLOSED_UNSTABLE
val totalVotesText = (pollResponseSummary?.totalVotes ?: 0).let {
when {
@ -262,13 +262,13 @@ class MessageItemFactory @Inject constructor(
}
}
pollContent.pollCreationInfo?.answers?.forEach { option ->
pollContent.getBestPollCreationInfo()?.answers?.forEach { option ->
val voteSummary = pollResponseSummary?.votes?.get(option.id)
val isMyVote = pollResponseSummary?.myVote == option.id
val voteCount = voteSummary?.total ?: 0
val votePercentage = voteSummary?.percentage ?: 0.0
val optionId = option.id ?: ""
val optionAnswer = option.answer ?: ""
val optionAnswer = option.getBestAnswer() ?: ""
optionViewStates.add(
if (!isPollSent) {
@ -291,7 +291,7 @@ class MessageItemFactory @Inject constructor(
)
}
val question = pollContent.pollCreationInfo?.question?.question ?: ""
val question = pollContent.getBestPollCreationInfo()?.question?.getBestQuestion() ?: ""
return PollItem_()
.attributes(attributes)

View file

@ -38,8 +38,7 @@ class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: Av
.map {
ReadReceiptData(it.roomMember.userId, it.roomMember.avatarUrl, it.roomMember.displayName, it.originServerTs)
}
.toList()
.sortedByDescending { it.timestamp }
return ReadReceiptsItem_()
.id("read_receipts_$eventId")
.eventId(eventId)

View file

@ -94,7 +94,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
when (event.root.getClearType()) {
// Message itemsX
EventType.STICKER,
EventType.POLL_START,
in EventType.POLL_START,
EventType.MESSAGE -> messageItemFactory.create(params)
EventType.REDACTION,
EventType.KEY_VERIFICATION_ACCEPT,
@ -107,8 +107,8 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
EventType.CALL_SELECT_ANSWER,
EventType.CALL_NEGOTIATE,
EventType.REACTION,
EventType.POLL_RESPONSE,
EventType.POLL_END -> noticeItemFactory.create(params)
in EventType.POLL_RESPONSE,
in EventType.POLL_END -> noticeItemFactory.create(params)
// Calls
EventType.CALL_INVITE,
EventType.CALL_HANGUP,

View file

@ -122,14 +122,14 @@ class DisplayableEventFormatter @Inject constructor(
EventType.CALL_CANDIDATES -> {
span { }
}
EventType.POLL_START -> {
timelineEvent.root.getClearContent().toModel<MessagePollContent>(catchError = true)?.pollCreationInfo?.question?.question
in EventType.POLL_START -> {
timelineEvent.root.getClearContent().toModel<MessagePollContent>(catchError = true)?.getBestPollCreationInfo()?.question?.getBestQuestion()
?: stringProvider.getString(R.string.sent_a_poll)
}
EventType.POLL_RESPONSE -> {
in EventType.POLL_RESPONSE -> {
stringProvider.getString(R.string.poll_response_room_list_preview)
}
EventType.POLL_END -> {
in EventType.POLL_END -> {
stringProvider.getString(R.string.poll_end_room_list_preview)
}
else -> {

View file

@ -106,8 +106,8 @@ class NoticeEventFormatter @Inject constructor(
EventType.STATE_SPACE_PARENT,
EventType.REDACTION,
EventType.STICKER,
EventType.POLL_RESPONSE,
EventType.POLL_END -> formatDebug(timelineEvent.root)
in EventType.POLL_RESPONSE,
in EventType.POLL_END -> formatDebug(timelineEvent.root)
else -> {
Timber.v("Type $type not handled by this formatter")
null
@ -196,8 +196,8 @@ class NoticeEventFormatter @Inject constructor(
}
private fun formatDebug(event: Event): CharSequence {
val threadPrefix = if (event.isThread()) "thread" else ""
return "Debug: $threadPrefix event type \"${event.getClearType()}\""
val threadPrefix = if (event.isThread()) "thread" else ""
return "Debug: $threadPrefix event type \"${event.getClearType()}\""
}
private fun formatRoomCreateEvent(event: Event, isDm: Boolean): CharSequence? {

View file

@ -50,9 +50,8 @@ object TimelineDisplayableEvents {
EventType.STATE_ROOM_TOMBSTONE,
EventType.STATE_ROOM_JOIN_RULES,
EventType.KEY_VERIFICATION_DONE,
EventType.KEY_VERIFICATION_CANCEL,
EventType.POLL_START
)
EventType.KEY_VERIFICATION_CANCEL
) + EventType.POLL_START
}
fun TimelineEvent.canBeMerged(): Boolean {

View file

@ -106,8 +106,6 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
holder.timeView.isVisible = false
}
holder.additionalTopSpace.isVisible = attributes.informationData.messageLayout.addTopMargin
// Render send state indicator
holder.sendStateImageView.render(attributes.informationData.sendStateDecoration)
holder.eventSendingIndicator.isVisible = attributes.informationData.sendStateDecoration == SendStateDecoration.SENDING_MEDIA
@ -157,7 +155,6 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
abstract class Holder(@IdRes stubId: Int) : AbsBaseMessageItem.Holder(stubId) {
val additionalTopSpace by bind<View>(R.id.additionalTopSpace)
val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
val memberNameView by bind<TextView>(R.id.messageMemberNameView)
val timeView by bind<TextView>(R.id.messageTimeView)

View file

@ -21,43 +21,44 @@ import im.vector.app.R
import kotlinx.parcelize.Parcelize
sealed interface TimelineMessageLayout : Parcelable {
val layoutRes: Int
val showAvatar: Boolean
val showDisplayName: Boolean
val addTopMargin: Boolean
val showTimestamp: Boolean
@Parcelize
data class Default(override val showAvatar: Boolean,
override val showDisplayName: Boolean,
override val showTimestamp: Boolean,
override val addTopMargin: Boolean = false,
// Keep defaultLayout generated on epoxy items
override val layoutRes: Int = 0) : TimelineMessageLayout
data class Default(
override val showAvatar: Boolean,
override val showDisplayName: Boolean,
override val showTimestamp: Boolean,
// Keep defaultLayout generated on epoxy items
override val layoutRes: Int = 0,
) : TimelineMessageLayout
@Parcelize
data class Bubble(
override val showAvatar: Boolean,
override val showDisplayName: Boolean,
override val showTimestamp: Boolean = true,
override val addTopMargin: Boolean = false,
val isIncoming: Boolean,
val isPseudoBubble: Boolean,
val cornersRadius: CornersRadius,
val timestampAsOverlay: Boolean,
override val layoutRes: Int = if (isIncoming) {
R.layout.item_timeline_event_bubble_incoming_base
} else {
R.layout.item_timeline_event_bubble_outgoing_base
}
override val showAvatar: Boolean,
override val showDisplayName: Boolean,
override val showTimestamp: Boolean = true,
val addTopMargin: Boolean = false,
val isIncoming: Boolean,
val isPseudoBubble: Boolean,
val cornersRadius: CornersRadius,
val timestampAsOverlay: Boolean,
override val layoutRes: Int = if (isIncoming) {
R.layout.item_timeline_event_bubble_incoming_base
} else {
R.layout.item_timeline_event_bubble_outgoing_base
},
) : TimelineMessageLayout {
@Parcelize
data class CornersRadius(
val topStartRadius: Float,
val topEndRadius: Float,
val bottomStartRadius: Float,
val bottomEndRadius: Float
val topStartRadius: Float,
val topEndRadius: Float,
val bottomStartRadius: Float,
val bottomEndRadius: Float,
) : Parcelable
}
}

View file

@ -43,10 +43,9 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
// Can be rendered in bubbles, other types will fallback to default
private val EVENT_TYPES_WITH_BUBBLE_LAYOUT = setOf(
EventType.MESSAGE,
EventType.POLL_START,
EventType.ENCRYPTED,
EventType.STICKER
)
) + EventType.POLL_START
// Can't be rendered in bubbles, so get back to default layout
private val MSG_TYPES_WITHOUT_BUBBLE_LAYOUT = setOf(

View file

@ -42,9 +42,11 @@ import im.vector.app.features.home.room.detail.timeline.style.shapeAppearanceMod
import im.vector.app.features.themes.ThemeUtils
import timber.log.Timber
class MessageBubbleView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0) :
RelativeLayout(context, attrs, defStyleAttr), TimelineMessageLayoutRenderer {
class MessageBubbleView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : RelativeLayout(context, attrs, defStyleAttr), TimelineMessageLayoutRenderer {
private var isIncoming: Boolean = false
@ -87,22 +89,43 @@ class MessageBubbleView @JvmOverloads constructor(context: Context, attrs: Attri
outlineProvider = ViewOutlineProvider.BACKGROUND
clipToOutline = true
background = RippleDrawable(
ContextCompat.getColorStateList(context, R.color.mtrl_btn_ripple_color) ?: ColorStateList.valueOf(Color.TRANSPARENT),
bubbleDrawable,
rippleMaskDrawable)
ContextCompat.getColorStateList(context, R.color.mtrl_btn_ripple_color) ?: ColorStateList.valueOf(Color.TRANSPARENT),
bubbleDrawable,
rippleMaskDrawable)
}
}
override fun renderMessageLayout(messageLayout: TimelineMessageLayout) {
if (messageLayout !is TimelineMessageLayout.Bubble) {
Timber.v("Can't render messageLayout $messageLayout")
return
(messageLayout as? TimelineMessageLayout.Bubble)
?.updateDrawables()
?.setConstraintsAndColor()
?.toggleMessageOverlay()
?.setPadding()
?.setMargins()
?.setAdditionalTopSpace()
?: Timber.v("Can't render messageLayout $messageLayout")
}
private fun TimelineMessageLayout.Bubble.updateDrawables() = apply {
val shapeAppearanceModel = cornersRadius.shapeAppearanceModel()
bubbleDrawable.apply {
this.shapeAppearanceModel = shapeAppearanceModel
this.fillColor = if (isPseudoBubble) {
ColorStateList.valueOf(Color.TRANSPARENT)
} else {
val backgroundColorAttr = if (isIncoming) R.attr.vctr_message_bubble_inbound else R.attr.vctr_message_bubble_outbound
val backgroundColor = ThemeUtils.getColor(context, backgroundColorAttr)
ColorStateList.valueOf(backgroundColor)
}
}
updateDrawables(messageLayout)
rippleMaskDrawable.shapeAppearanceModel = shapeAppearanceModel
}
private fun TimelineMessageLayout.Bubble.setConstraintsAndColor() = apply {
ConstraintSet().apply {
clone(views.bubbleView)
clear(R.id.viewStubContainer, ConstraintSet.END)
if (messageLayout.timestampAsOverlay) {
if (timestampAsOverlay) {
val timeColor = ContextCompat.getColor(context, R.color.palette_white)
views.messageTimeView.setTextColor(timeColor)
connect(R.id.viewStubContainer, ConstraintSet.END, R.id.parent, ConstraintSet.END, 0)
@ -113,17 +136,26 @@ class MessageBubbleView @JvmOverloads constructor(context: Context, attrs: Attri
}
applyTo(views.bubbleView)
}
if (messageLayout.timestampAsOverlay) {
}
private fun TimelineMessageLayout.Bubble.toggleMessageOverlay() = apply {
if (timestampAsOverlay) {
views.messageOverlayView.isVisible = true
(views.messageOverlayView.background as? GradientDrawable)?.cornerRadii = messageLayout.cornersRadius.toFloatArray()
(views.messageOverlayView.background as? GradientDrawable)?.cornerRadii = cornersRadius.toFloatArray()
} else {
views.messageOverlayView.isVisible = false
}
if (messageLayout.isPseudoBubble && messageLayout.timestampAsOverlay) {
}
private fun TimelineMessageLayout.Bubble.setPadding() = apply {
if (isPseudoBubble && timestampAsOverlay) {
views.viewStubContainer.root.setPadding(0, 0, 0, 0)
} else {
views.viewStubContainer.root.setPadding(horizontalStubPadding, verticalStubPadding, horizontalStubPadding, verticalStubPadding)
}
}
private fun TimelineMessageLayout.Bubble.setMargins() = apply {
if (isIncoming) {
views.messageEndGuideline.updateLayoutParams<LayoutParams> {
marginEnd = resources.getDimensionPixelSize(R.dimen.chat_bubble_margin_end)
@ -141,22 +173,11 @@ class MessageBubbleView @JvmOverloads constructor(context: Context, attrs: Attri
}
}
private fun TimelineMessageLayout.Bubble.setAdditionalTopSpace() = apply {
views.additionalTopSpace.isVisible = addTopMargin
}
private fun TimelineMessageLayout.Bubble.CornersRadius.toFloatArray(): FloatArray {
return floatArrayOf(topStartRadius, topStartRadius, topEndRadius, topEndRadius, bottomEndRadius, bottomEndRadius, bottomStartRadius, bottomStartRadius)
}
private fun updateDrawables(messageLayout: TimelineMessageLayout.Bubble) {
val shapeAppearanceModel = messageLayout.cornersRadius.shapeAppearanceModel()
bubbleDrawable.apply {
this.shapeAppearanceModel = shapeAppearanceModel
this.fillColor = if (messageLayout.isPseudoBubble) {
ColorStateList.valueOf(Color.TRANSPARENT)
} else {
val backgroundColorAttr = if (isIncoming) R.attr.vctr_message_bubble_inbound else R.attr.vctr_message_bubble_outbound
val backgroundColor = ThemeUtils.getColor(context, backgroundColorAttr)
ColorStateList.valueOf(backgroundColor)
}
}
rippleMaskDrawable.shapeAppearanceModel = shapeAppearanceModel
}
}

View file

@ -33,6 +33,7 @@ import im.vector.app.features.themes.ThemeUtils
abstract class RoomCategoryItem : VectorEpoxyModel<RoomCategoryItem.Holder>() {
@EpoxyAttribute lateinit var title: String
@EpoxyAttribute var itemCount: Int = 0
@EpoxyAttribute var expanded: Boolean = false
@EpoxyAttribute var unreadNotificationCount: Int = 0
@EpoxyAttribute var showHighlighted: Boolean = false
@ -46,14 +47,16 @@ abstract class RoomCategoryItem : VectorEpoxyModel<RoomCategoryItem.Holder>() {
DrawableCompat.setTint(it, tintColor)
}
holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted))
holder.titleView.setCompoundDrawablesWithIntrinsicBounds(null, null, expandedArrowDrawable, null)
holder.titleView.text = title
holder.counterView.text = itemCount.takeIf { it > 0 }?.toString().orEmpty()
holder.counterView.setCompoundDrawablesWithIntrinsicBounds(null, null, expandedArrowDrawable, null)
holder.rootView.onClick(listener)
}
class Holder : VectorEpoxyHolder() {
val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.roomCategoryUnreadCounterBadgeView)
val titleView by bind<TextView>(R.id.roomCategoryTitleView)
val counterView by bind<TextView>(R.id.roomCategoryCounterView)
val rootView by bind<ViewGroup>(R.id.roomCategoryRootView)
}
}

View file

@ -23,6 +23,8 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
@ -50,8 +52,10 @@ import im.vector.app.features.home.room.list.actions.RoomListQuickActionsSharedA
import im.vector.app.features.home.room.list.actions.RoomListQuickActionsSharedActionViewModel
import im.vector.app.features.home.room.list.widget.NotifsFabMenuView
import im.vector.app.features.notifications.NotificationDrawerManager
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.extensions.orTrue
import org.matrix.android.sdk.api.session.room.model.RoomSummary
@ -287,6 +291,7 @@ class RoomListFragment @Inject constructor(
))
checkEmptyState()
}
observeItemCount(section, sectionAdapter)
section.notificationCount.observe(viewLifecycleOwner) { counts ->
sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy(
notificationCount = counts.totalCount,
@ -310,6 +315,7 @@ class RoomListFragment @Inject constructor(
))
checkEmptyState()
}
observeItemCount(section, sectionAdapter)
section.isExpanded.observe(viewLifecycleOwner) { _ ->
refreshCollapseStates()
}
@ -326,6 +332,7 @@ class RoomListFragment @Inject constructor(
isLoading = false))
checkEmptyState()
}
observeItemCount(section, sectionAdapter)
section.notificationCount.observe(viewLifecycleOwner) { counts ->
sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy(
notificationCount = counts.totalCount,
@ -373,6 +380,18 @@ class RoomListFragment @Inject constructor(
}
}
private fun observeItemCount(section: RoomsSection, sectionAdapter: SectionHeaderAdapter) {
lifecycleScope.launch {
section.itemCount
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.collect { count ->
sectionAdapter.updateSection(
sectionAdapter.roomsSectionData.copy(itemCount = count)
)
}
}
}
private fun handleQuickActions(quickAction: RoomListQuickActionsSharedAction) {
when (quickAction) {
is RoomListQuickActionsSharedAction.NotificationsAllNoisy -> {

View file

@ -28,6 +28,7 @@ import im.vector.app.features.invite.showInvites
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -72,7 +73,18 @@ class RoomListSectionBuilderGroup(
session.getFilteredPagedRoomSummariesLive(qpm)
.let { updatableFilterLivePageResult ->
onUpdatable(updatableFilterLivePageResult)
sections.add(RoomsSection(name, updatableFilterLivePageResult.livePagedList))
val itemCountFlow = updatableFilterLivePageResult.livePagedList.asFlow()
.flatMapLatest { session.getRoomCountFlow(updatableFilterLivePageResult.queryParams) }
.distinctUntilChanged()
sections.add(
RoomsSection(
sectionName = name,
livePages = updatableFilterLivePageResult.livePagedList,
itemCount = itemCountFlow
)
)
}
}
)
@ -109,9 +121,7 @@ class RoomListSectionBuilderGroup(
.onEach { groupingMethod ->
val selectedGroupId = (groupingMethod.orNull() as? RoomGroupingMethod.ByLegacyGroup)?.groupSummary?.groupId
activeGroupAwareQueries.onEach { updater ->
updater.updateQuery { query ->
query.copy(activeGroupId = selectedGroupId)
}
updater.queryParams = updater.queryParams.copy(activeGroupId = selectedGroupId)
}
}.launchIn(coroutineScope)
@ -265,7 +275,8 @@ class RoomListSectionBuilderGroup(
RoomsSection(
sectionName = name,
livePages = livePagedList,
notifyOfLocalEcho = notifyOfLocalEcho
notifyOfLocalEcho = notifyOfLocalEcho,
itemCount = session.getRoomCountFlow(roomQueryParams)
)
)
}

View file

@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.query.ActiveSpaceFilter
@ -91,7 +92,18 @@ class RoomListSectionBuilderSpace(
session.getFilteredPagedRoomSummariesLive(qpm)
.let { updatableFilterLivePageResult ->
onUpdatable(updatableFilterLivePageResult)
sections.add(RoomsSection(name, updatableFilterLivePageResult.livePagedList))
val itemCountFlow = updatableFilterLivePageResult.livePagedList.asFlow()
.flatMapLatest { session.getRoomCountFlow(updatableFilterLivePageResult.queryParams) }
.distinctUntilChanged()
sections.add(
RoomsSection(
sectionName = name,
livePages = updatableFilterLivePageResult.livePagedList,
itemCount = itemCountFlow
)
)
}
}
)
@ -261,7 +273,8 @@ class RoomListSectionBuilderSpace(
RoomsSection(
sectionName = stringProvider.getString(R.string.suggested_header),
liveSuggested = liveSuggestedRooms,
notifyOfLocalEcho = false
notifyOfLocalEcho = false,
itemCount = suggestedRoomsFlow.map { suggestions -> suggestions.size }
)
)
}
@ -338,11 +351,9 @@ class RoomListSectionBuilderSpace(
RoomListViewModel.SpaceFilterStrategy.ORPHANS_IF_SPACE_NULL -> {
activeSpaceUpdaters.add(object : RoomListViewModel.ActiveSpaceQueryUpdater {
override fun updateForSpaceId(roomId: String?) {
it.updateQuery {
it.copy(
activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(roomId)
)
}
it.queryParams = roomQueryParams.copy(
activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(roomId)
)
}
})
}
@ -350,17 +361,13 @@ class RoomListSectionBuilderSpace(
activeSpaceUpdaters.add(object : RoomListViewModel.ActiveSpaceQueryUpdater {
override fun updateForSpaceId(roomId: String?) {
if (roomId != null) {
it.updateQuery {
it.copy(
activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(roomId)
)
}
it.queryParams = roomQueryParams.copy(
activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(roomId)
)
} else {
it.updateQuery {
it.copy(
activeSpaceFilter = ActiveSpaceFilter.None
)
}
it.queryParams = roomQueryParams.copy(
activeSpaceFilter = ActiveSpaceFilter.None
)
}
}
})
@ -390,11 +397,19 @@ class RoomListSectionBuilderSpace(
.flowOn(Dispatchers.Default)
.launchIn(viewModelScope)
val itemCountFlow = livePagedList.asFlow()
.flatMapLatest {
val queryParams = roomQueryParams.process(spaceFilterStrategy, appStateHandler.safeActiveSpaceId())
session.getRoomCountFlow(queryParams)
}
.distinctUntilChanged()
sections.add(
RoomsSection(
sectionName = name,
livePages = livePagedList,
notifyOfLocalEcho = notifyOfLocalEcho
notifyOfLocalEcho = notifyOfLocalEcho,
itemCount = itemCountFlow
)
)
}

View file

@ -192,8 +192,8 @@ class RoomListViewModel @AssistedInject constructor(
roomFilter = action.filter
)
}
updatableQuery?.updateQuery {
it.copy(
updatableQuery?.apply {
queryParams = queryParams.copy(
displayName = QueryStringValue.Contains(action.filter, QueryStringValue.Case.NORMALIZED)
)
}

View file

@ -19,6 +19,7 @@ package im.vector.app.features.home.room.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.PagedList
import kotlinx.coroutines.flow.Flow
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount
@ -29,6 +30,7 @@ data class RoomsSection(
val liveList: LiveData<List<RoomSummary>>? = null,
val liveSuggested: LiveData<SuggestedRoomInfo>? = null,
val isExpanded: MutableLiveData<Boolean> = MutableLiveData(true),
val itemCount: Flow<Int>,
val notificationCount: MutableLiveData<RoomAggregateNotificationCount> = MutableLiveData(RoomAggregateNotificationCount(0, 0)),
val notifyOfLocalEcho: Boolean = false
)

View file

@ -33,6 +33,7 @@ class SectionHeaderAdapter constructor(
data class RoomsSectionData(
val name: String,
val itemCount: Int = 0,
val isExpanded: Boolean = true,
val notificationCount: Int = 0,
val isHighlighted: Boolean = false,
@ -85,8 +86,9 @@ class SectionHeaderAdapter constructor(
val expandedArrowDrawable = ContextCompat.getDrawable(binding.root.context, expandedArrowDrawableRes)?.also {
DrawableCompat.setTint(it, tintColor)
}
binding.roomCategoryCounterView.setCompoundDrawablesWithIntrinsicBounds(null, null, expandedArrowDrawable, null)
binding.roomCategoryCounterView.text = roomsSectionData.itemCount.takeIf { it > 0 }?.toString().orEmpty()
binding.roomCategoryUnreadCounterBadgeView.render(UnreadCounterBadgeView.State(roomsSectionData.notificationCount, roomsSectionData.isHighlighted))
binding.roomCategoryTitleView.setCompoundDrawablesWithIntrinsicBounds(null, null, expandedArrowDrawable, null)
}
companion object {

View file

@ -39,7 +39,7 @@ class UrlMapProvider @Inject constructor(
suspend fun getMapUrl(): String {
val upstreamMapUrl = tryOrNull { rawService.getElementWellknown(session.sessionParams) }
?.mapTileServerConfig
?.getBestMapTileServerConfig()
?.mapStyleUrl
return upstreamMapUrl ?: fallbackMapUrl
}

View file

@ -75,6 +75,7 @@ sealed class OnboardingAction : VectorViewModelAction {
data class UserAcceptCertificate(val fingerprint: Fingerprint) : OnboardingAction()
object PersonalizeProfile : OnboardingAction()
data class UpdateDisplayName(val displayName: String) : OnboardingAction()
object UpdateDisplayNameSkipped : OnboardingAction()
data class ProfilePictureSelected(val uri: Uri) : OnboardingAction()

View file

@ -51,9 +51,8 @@ sealed class OnboardingViewEvents : VectorViewEvents {
object OnAccountCreated : OnboardingViewEvents()
object OnAccountSignedIn : OnboardingViewEvents()
object OnTakeMeHome : OnboardingViewEvents()
object OnPersonalizeProfile : OnboardingViewEvents()
object OnDisplayNameUpdated : OnboardingViewEvents()
object OnDisplayNameSkipped : OnboardingViewEvents()
object OnChooseDisplayName : OnboardingViewEvents()
object OnChooseProfilePicture : OnboardingViewEvents()
object OnPersonalizationComplete : OnboardingViewEvents()
object OnBack : OnboardingViewEvents()
}

View file

@ -48,6 +48,7 @@ import im.vector.app.features.login.ReAuthHelper
import im.vector.app.features.login.ServerType
import im.vector.app.features.login.SignMode
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.auth.AuthenticationService
@ -156,12 +157,13 @@ class OnboardingViewModel @AssistedInject constructor(
is OnboardingAction.ResetAction -> handleResetAction(action)
is OnboardingAction.UserAcceptCertificate -> handleUserAcceptCertificate(action)
OnboardingAction.ClearHomeServerHistory -> handleClearHomeServerHistory()
is OnboardingAction.PostViewEvent -> _viewEvents.post(action.viewEvent)
is OnboardingAction.UpdateDisplayName -> updateDisplayName(action.displayName)
OnboardingAction.UpdateDisplayNameSkipped -> _viewEvents.post(OnboardingViewEvents.OnDisplayNameSkipped)
OnboardingAction.UpdateProfilePictureSkipped -> _viewEvents.post(OnboardingViewEvents.OnPersonalizationComplete)
OnboardingAction.UpdateDisplayNameSkipped -> handleDisplayNameStepComplete()
OnboardingAction.UpdateProfilePictureSkipped -> completePersonalization()
OnboardingAction.PersonalizeProfile -> handlePersonalizeProfile()
is OnboardingAction.ProfilePictureSelected -> handleProfilePictureSelected(action)
OnboardingAction.SaveSelectedProfilePicture -> updateProfilePicture()
is OnboardingAction.PostViewEvent -> _viewEvents.post(action.viewEvent)
}.exhaustive
}
@ -762,15 +764,33 @@ class OnboardingViewModel @AssistedInject constructor(
authenticationService.reset()
session.configureAndStart(applicationContext)
setState {
copy(
asyncLoginAction = Success(Unit)
)
}
when (isAccountCreated) {
true -> _viewEvents.post(OnboardingViewEvents.OnAccountCreated)
false -> _viewEvents.post(OnboardingViewEvents.OnAccountSignedIn)
true -> {
val personalizationState = createPersonalizationState(session, state)
setState {
copy(asyncLoginAction = Success(Unit), personalizationState = personalizationState)
}
_viewEvents.post(OnboardingViewEvents.OnAccountCreated)
}
false -> {
setState { copy(asyncLoginAction = Success(Unit)) }
_viewEvents.post(OnboardingViewEvents.OnAccountSignedIn)
}
}
}
private suspend fun createPersonalizationState(session: Session, state: OnboardingViewState): PersonalizationState {
return when {
vectorFeatures.isOnboardingPersonalizeEnabled() -> {
val homeServerCapabilities = session.getHomeServerCapabilities()
val capabilityOverrides = vectorOverrides.forceHomeserverCapabilities?.firstOrNull()
state.personalizationState.copy(
supportsChangingDisplayName = capabilityOverrides?.canChangeDisplayName ?: homeServerCapabilities.canChangeDisplayName,
supportsChangingProfilePicture = capabilityOverrides?.canChangeAvatar ?: homeServerCapabilities.canChangeAvatar
)
}
else -> state.personalizationState
}
}
@ -910,7 +930,7 @@ class OnboardingViewModel @AssistedInject constructor(
personalizationState = personalizationState.copy(displayName = displayName)
)
}
_viewEvents.post(OnboardingViewEvents.OnDisplayNameUpdated)
handleDisplayNameStepComplete()
} catch (error: Throwable) {
setState { copy(asyncDisplayName = Fail(error)) }
_viewEvents.post(OnboardingViewEvents.Failure(error))
@ -918,12 +938,37 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
private fun handlePersonalizeProfile() {
withPersonalisationState {
when {
it.supportsChangingDisplayName -> _viewEvents.post(OnboardingViewEvents.OnChooseDisplayName)
it.supportsChangingProfilePicture -> _viewEvents.post(OnboardingViewEvents.OnChooseProfilePicture)
else -> {
throw IllegalStateException("It should not be possible to personalize without supporting display name or avatar changing")
}
}
}
}
private fun handleDisplayNameStepComplete() {
withPersonalisationState {
when {
it.supportsChangingProfilePicture -> _viewEvents.post(OnboardingViewEvents.OnChooseProfilePicture)
else -> completePersonalization()
}
}
}
private fun handleProfilePictureSelected(action: OnboardingAction.ProfilePictureSelected) {
setState {
copy(personalizationState = personalizationState.copy(selectedPictureUri = action.uri))
}
}
private fun withPersonalisationState(block: (PersonalizationState) -> Unit) {
withState { block(it.personalizationState) }
}
private fun updateProfilePicture() {
withState { state ->
when (val pictureUri = state.personalizationState.selectedPictureUri) {
@ -955,6 +1000,10 @@ class OnboardingViewModel @AssistedInject constructor(
}
private fun onProfilePictureSaved() {
completePersonalization()
}
private fun completePersonalization() {
_viewEvents.post(OnboardingViewEvents.OnPersonalizationComplete)
}
}

View file

@ -22,7 +22,6 @@ import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.PersistState
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.login.LoginMode
import im.vector.app.features.login.ServerType
@ -83,10 +82,6 @@ data class OnboardingViewState(
asyncDisplayName is Loading ||
asyncProfilePicture is Loading
}
fun isAuthTaskCompleted(): Boolean {
return asyncLoginAction is Success
}
}
enum class OnboardingFlow {
@ -97,6 +92,11 @@ enum class OnboardingFlow {
@Parcelize
data class PersonalizationState(
val supportsChangingDisplayName: Boolean = false,
val supportsChangingProfilePicture: Boolean = false,
val displayName: String? = null,
val selectedPictureUri: Uri? = null
) : Parcelable
) : Parcelable {
fun supportsPersonalization() = supportsChangingDisplayName || supportsChangingProfilePicture
}

View file

@ -20,11 +20,13 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.databinding.FragmentFtueAccountCreatedBinding
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState
import javax.inject.Inject
class FtueAuthAccountCreatedFragment @Inject constructor(
@ -42,8 +44,15 @@ class FtueAuthAccountCreatedFragment @Inject constructor(
private fun setupViews() {
views.accountCreatedSubtitle.text = getString(R.string.ftue_account_created_subtitle, activeSessionHolder.getActiveSession().myUserId)
views.accountCreatedPersonalize.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnPersonalizeProfile)) }
views.accountCreatedPersonalize.debouncedClicks { viewModel.handle(OnboardingAction.PersonalizeProfile) }
views.accountCreatedTakeMeHome.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome)) }
views.accountCreatedTakeMeHomeCta.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome)) }
}
override fun updateWithState(state: OnboardingViewState) {
val canPersonalize = state.personalizationState.supportsPersonalization()
views.personalizeButtonGroup.isVisible = canPersonalize
views.takeMeHomeButtonGroup.isVisible = !canPersonalize
}
override fun resetViewModel() {

View file

@ -22,6 +22,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.isInvisible
import com.airbnb.mvrx.withState
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
@ -70,6 +71,8 @@ class FtueAuthChooseProfilePictureFragment @Inject constructor(
}
override fun updateWithState(state: OnboardingViewState) {
views.profilePictureToolbar.isInvisible = !state.personalizationState.supportsChangingDisplayName
val hasSetPicture = state.personalizationState.selectedPictureUri != null
views.profilePictureSubmit.isEnabled = hasSetPicture
views.changeProfilePictureIcon.setImageResource(if (hasSetPicture) R.drawable.ic_edit else R.drawable.ic_camera_plain)
@ -93,4 +96,14 @@ class FtueAuthChooseProfilePictureFragment @Inject constructor(
override fun resetViewModel() {
// Nothing to do
}
override fun onBackPressed(toolbarButton: Boolean): Boolean {
return when (withState(viewModel) { it.personalizationState.supportsChangingDisplayName }) {
true -> super.onBackPressed(toolbarButton)
false -> {
viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome))
true
}
}
}
}

View file

@ -122,17 +122,9 @@ class FtueAuthVariant(
private fun updateWithState(viewState: OnboardingViewState) {
isForceLoginFallbackEnabled = viewState.isForceLoginFallbackEnabled
views.loginLoading.isVisible = shouldShowLoading(viewState)
views.loginLoading.isVisible = viewState.isLoading()
}
private fun shouldShowLoading(viewState: OnboardingViewState) =
if (vectorFeatures.isOnboardingPersonalizeEnabled()) {
viewState.isLoading()
} else {
// Keep loading when during success because of the delay when switching to the next Activity
viewState.isLoading() || viewState.isAuthTaskCompleted()
}
override fun setIsLoading(isLoading: Boolean) = Unit
private fun handleOnboardingViewEvents(viewEvents: OnboardingViewEvents) {
@ -230,12 +222,11 @@ class FtueAuthVariant(
FtueAuthUseCaseFragment::class.java,
option = commonOption)
}
OnboardingViewEvents.OnAccountCreated -> onAccountCreated()
is OnboardingViewEvents.OnAccountCreated -> onAccountCreated()
OnboardingViewEvents.OnAccountSignedIn -> onAccountSignedIn()
OnboardingViewEvents.OnPersonalizeProfile -> onPersonalizeProfile()
OnboardingViewEvents.OnChooseDisplayName -> onChooseDisplayName()
OnboardingViewEvents.OnTakeMeHome -> navigateToHome(createdAccount = true)
OnboardingViewEvents.OnDisplayNameUpdated -> onDisplayNameUpdated()
OnboardingViewEvents.OnDisplayNameSkipped -> onDisplayNameUpdated()
OnboardingViewEvents.OnChooseProfilePicture -> onChooseProfilePicture()
OnboardingViewEvents.OnPersonalizationComplete -> navigateToHome(createdAccount = true)
OnboardingViewEvents.OnBack -> activity.popBackstack()
}.exhaustive
@ -399,15 +390,11 @@ class FtueAuthVariant(
}
private fun onAccountCreated() {
if (vectorFeatures.isOnboardingPersonalizeEnabled()) {
activity.supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
activity.replaceFragment(
views.loginFragmentContainer,
FtueAuthAccountCreatedFragment::class.java,
)
} else {
navigateToHome(createdAccount = true)
}
activity.supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
activity.replaceFragment(
views.loginFragmentContainer,
FtueAuthAccountCreatedFragment::class.java
)
}
private fun navigateToHome(createdAccount: Boolean) {
@ -416,14 +403,14 @@ class FtueAuthVariant(
activity.finish()
}
private fun onPersonalizeProfile() {
private fun onChooseDisplayName() {
activity.addFragmentToBackstack(views.loginFragmentContainer,
FtueAuthChooseDisplayNameFragment::class.java,
option = commonOption
)
}
private fun onDisplayNameUpdated() {
private fun onChooseProfilePicture() {
activity.addFragmentToBackstack(views.loginFragmentContainer,
FtueAuthChooseProfilePictureFragment::class.java,
option = commonOption

View file

@ -60,9 +60,9 @@ class CreatePollController @Inject constructor(
pollTypeChangedListener { _, id ->
host.callback?.onPollTypeChanged(
if (id == R.id.openPollTypeRadioButton) {
PollType.DISCLOSED
PollType.DISCLOSED_UNSTABLE
} else {
PollType.UNDISCLOSED
PollType.UNDISCLOSED_UNSTABLE
}
)
}

View file

@ -71,9 +71,10 @@ class CreatePollViewModel @AssistedInject constructor(
val event = room.getTimelineEvent(eventId) ?: return
val content = event.getLastMessageContent() as? MessagePollContent ?: return
val pollType = content.pollCreationInfo?.kind ?: PollType.DISCLOSED
val question = content.pollCreationInfo?.question?.question ?: ""
val options = content.pollCreationInfo?.answers?.mapNotNull { it.answer } ?: List(MIN_OPTIONS_COUNT) { "" }
val pollCreationInfo = content.getBestPollCreationInfo()
val pollType = pollCreationInfo?.kind ?: PollType.DISCLOSED_UNSTABLE
val question = pollCreationInfo?.question?.getBestQuestion() ?: ""
val options = pollCreationInfo?.answers?.mapNotNull { it.getBestAnswer() } ?: List(MIN_OPTIONS_COUNT) { "" }
setState {
copy(

View file

@ -27,7 +27,7 @@ data class CreatePollViewState(
val options: List<String> = List(CreatePollViewModel.MIN_OPTIONS_COUNT) { "" },
val canCreatePoll: Boolean = false,
val canAddMoreOptions: Boolean = true,
val pollType: PollType = PollType.DISCLOSED
val pollType: PollType = PollType.DISCLOSED_UNSTABLE
) : MavericksState {
constructor(args: CreatePollArgs) : this(

View file

@ -28,7 +28,7 @@ import org.matrix.android.sdk.api.session.room.model.message.PollType
abstract class PollTypeSelectionItem : VectorEpoxyModel<PollTypeSelectionItem.Holder>() {
@EpoxyAttribute
var pollType: PollType = PollType.DISCLOSED
var pollType: PollType = PollType.DISCLOSED_UNSTABLE
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var pollTypeChangedListener: RadioGroup.OnCheckedChangeListener? = null
@ -38,8 +38,8 @@ abstract class PollTypeSelectionItem : VectorEpoxyModel<PollTypeSelectionItem.Ho
holder.pollTypeRadioGroup.check(
when (pollType) {
PollType.DISCLOSED -> R.id.openPollTypeRadioButton
PollType.UNDISCLOSED -> R.id.closedPollTypeRadioButton
PollType.DISCLOSED_UNSTABLE, PollType.DISCLOSED -> R.id.openPollTypeRadioButton
PollType.UNDISCLOSED_UNSTABLE, PollType.UNDISCLOSED -> R.id.closedPollTypeRadioButton
}
)

View file

@ -38,8 +38,13 @@ data class ElementWellKnown(
val riotE2E: E2EWellKnownConfig? = null,
@Json(name = "org.matrix.msc3488.tile_server")
val unstableMapTileServerConfig: MapTileServerConfig? = null,
@Json(name = "m.tile_server")
val mapTileServerConfig: MapTileServerConfig? = null
)
) {
fun getBestMapTileServerConfig() = mapTileServerConfig ?: unstableMapTileServerConfig
}
@JsonClass(generateAdapter = true)
data class E2EWellKnownConfig(

View file

@ -94,6 +94,12 @@ class AddRoomListController @Inject constructor(
}
var totalSize: Int = 0
set(value) {
if (value != field) {
field = value
requestForcedModelBuild()
}
}
var selectedItems: Map<String, Boolean> = emptyMap()
set(value) {
@ -120,7 +126,8 @@ class AddRoomListController @Inject constructor(
add(
RoomCategoryItem_().apply {
id("header")
title(host.sectionName ?: "")
title(host.sectionName.orEmpty())
itemCount(host.totalSize)
expanded(host.expanded)
listener {
host.expanded = !host.expanded

View file

@ -22,6 +22,8 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
@ -35,9 +37,12 @@ import im.vector.app.core.extensions.cleanup
import im.vector.app.core.platform.OnBackPressed
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentSpaceAddRoomsBinding
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import reactivecircus.flowbinding.appcompat.queryTextChanges
import javax.inject.Inject
@ -169,48 +174,63 @@ class SpaceAddRoomFragment @Inject constructor(
}
private fun setupRecyclerView() {
val concatAdapter = ConcatAdapter()
spaceEpoxyController.sectionName = getString(R.string.spaces_header)
roomEpoxyController.sectionName = getString(R.string.rooms_header)
spaceEpoxyController.listener = this
roomEpoxyController.listener = this
setupSpaceSection()
setupRoomSection()
setupDmSection()
viewModel.updatableLiveSpacePageResult.liveBoundaries.observe(viewLifecycleOwner) {
views.roomList.adapter = ConcatAdapter().apply {
addAdapter(roomEpoxyController.adapter)
addAdapter(spaceEpoxyController.adapter)
addAdapter(dmEpoxyController.adapter)
}
}
private fun setupSpaceSection() {
spaceEpoxyController.sectionName = getString(R.string.spaces_header)
spaceEpoxyController.listener = this
viewModel.spaceUpdatableLivePageResult.liveBoundaries.observe(viewLifecycleOwner) {
spaceEpoxyController.boundaryChange(it)
}
viewModel.updatableLiveSpacePageResult.livePagedList.observe(viewLifecycleOwner) {
spaceEpoxyController.totalSize = it.size
viewModel.spaceUpdatableLivePageResult.livePagedList.observe(viewLifecycleOwner) {
spaceEpoxyController.submitList(it)
}
listenItemCount(viewModel.spaceCountFlow) { spaceEpoxyController.totalSize = it }
}
viewModel.updatableLivePageResult.liveBoundaries.observe(viewLifecycleOwner) {
private fun setupRoomSection() {
roomEpoxyController.sectionName = getString(R.string.rooms_header)
roomEpoxyController.listener = this
viewModel.roomUpdatableLivePageResult.liveBoundaries.observe(viewLifecycleOwner) {
roomEpoxyController.boundaryChange(it)
}
viewModel.updatableLivePageResult.livePagedList.observe(viewLifecycleOwner) {
roomEpoxyController.totalSize = it.size
viewModel.roomUpdatableLivePageResult.livePagedList.observe(viewLifecycleOwner) {
roomEpoxyController.submitList(it)
}
listenItemCount(viewModel.roomCountFlow) { roomEpoxyController.totalSize = it }
views.roomList.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
views.roomList.setHasFixedSize(true)
}
concatAdapter.addAdapter(roomEpoxyController.adapter)
concatAdapter.addAdapter(spaceEpoxyController.adapter)
private fun setupDmSection() {
// This controller can be disabled depending on the space type (public or not)
viewModel.updatableDMLivePageResult.liveBoundaries.observe(viewLifecycleOwner) {
dmEpoxyController.boundaryChange(it)
}
viewModel.updatableDMLivePageResult.livePagedList.observe(viewLifecycleOwner) {
dmEpoxyController.totalSize = it.size
dmEpoxyController.submitList(it)
}
dmEpoxyController.sectionName = getString(R.string.direct_chats_header)
dmEpoxyController.listener = this
viewModel.dmUpdatableLivePageResult.liveBoundaries.observe(viewLifecycleOwner) {
dmEpoxyController.boundaryChange(it)
}
viewModel.dmUpdatableLivePageResult.livePagedList.observe(viewLifecycleOwner) {
dmEpoxyController.submitList(it)
}
listenItemCount(viewModel.dmCountFlow) { dmEpoxyController.totalSize = it }
}
concatAdapter.addAdapter(dmEpoxyController.adapter)
views.roomList.adapter = concatAdapter
private fun listenItemCount(itemCountFlow: Flow<Int>, onEachAction: (Int) -> Unit) {
lifecycleScope.launch {
itemCountFlow
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.collect { count -> onEachAction(count) }
}
}
override fun onBackPressed(toolbarButton: Boolean): Boolean {

View file

@ -17,7 +17,7 @@
package im.vector.app.features.spaces.manage
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.asFlow
import androidx.paging.PagedList
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
@ -30,6 +30,9 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.query.ActiveSpaceFilter
@ -60,7 +63,7 @@ class SpaceAddRoomsViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory<SpaceAddRoomsViewModel, SpaceAddRoomsState> by hiltMavericksViewModelFactory()
val updatableLiveSpacePageResult: UpdatableLivePageResult by lazy {
val spaceUpdatableLivePageResult: UpdatableLivePageResult by lazy {
session.getFilteredPagedRoomSummariesLive(
roomSummaryQueryParams {
this.memberships = listOf(Membership.JOIN)
@ -79,7 +82,13 @@ class SpaceAddRoomsViewModel @AssistedInject constructor(
)
}
val updatableLivePageResult: UpdatableLivePageResult by lazy {
val spaceCountFlow: Flow<Int> by lazy {
spaceUpdatableLivePageResult.livePagedList.asFlow()
.flatMapLatest { session.getRoomCountFlow(spaceUpdatableLivePageResult.queryParams) }
.distinctUntilChanged()
}
val roomUpdatableLivePageResult: UpdatableLivePageResult by lazy {
session.getFilteredPagedRoomSummariesLive(
roomSummaryQueryParams {
this.memberships = listOf(Membership.JOIN)
@ -99,7 +108,13 @@ class SpaceAddRoomsViewModel @AssistedInject constructor(
)
}
val updatableDMLivePageResult: UpdatableLivePageResult by lazy {
val roomCountFlow: Flow<Int> by lazy {
roomUpdatableLivePageResult.livePagedList.asFlow()
.flatMapLatest { session.getRoomCountFlow(roomUpdatableLivePageResult.queryParams) }
.distinctUntilChanged()
}
val dmUpdatableLivePageResult: UpdatableLivePageResult by lazy {
session.getFilteredPagedRoomSummariesLive(
roomSummaryQueryParams {
this.memberships = listOf(Membership.JOIN)
@ -119,6 +134,12 @@ class SpaceAddRoomsViewModel @AssistedInject constructor(
)
}
val dmCountFlow: Flow<Int> by lazy {
dmUpdatableLivePageResult.livePagedList.asFlow()
.flatMapLatest { session.getRoomCountFlow(dmUpdatableLivePageResult.queryParams) }
.distinctUntilChanged()
}
private val selectionList = mutableMapOf<String, Boolean>()
val selectionListLiveData = MutableLiveData<Map<String, Boolean>>()
@ -143,17 +164,13 @@ class SpaceAddRoomsViewModel @AssistedInject constructor(
override fun handle(action: SpaceAddRoomActions) {
when (action) {
is SpaceAddRoomActions.UpdateFilter -> {
updatableLivePageResult.updateQuery {
it.copy(
displayName = QueryStringValue.Contains(action.filter, QueryStringValue.Case.INSENSITIVE)
)
}
updatableLiveSpacePageResult.updateQuery {
it.copy(
displayName = QueryStringValue.Contains(action.filter, QueryStringValue.Case.INSENSITIVE)
)
}
is SpaceAddRoomActions.UpdateFilter -> {
roomUpdatableLivePageResult.queryParams = roomUpdatableLivePageResult.queryParams.copy(
displayName = QueryStringValue.Contains(action.filter, QueryStringValue.Case.INSENSITIVE)
)
roomUpdatableLivePageResult.queryParams = roomUpdatableLivePageResult.queryParams.copy(
displayName = QueryStringValue.Contains(action.filter, QueryStringValue.Case.INSENSITIVE)
)
setState {
copy(
currentFilter = action.filter
@ -164,7 +181,7 @@ class SpaceAddRoomsViewModel @AssistedInject constructor(
selectionList[action.roomSummary.roomId] = (selectionList[action.roomSummary.roomId] ?: false).not()
selectionListLiveData.postValue(selectionList.toMap())
}
SpaceAddRoomActions.Save -> {
SpaceAddRoomActions.Save -> {
doAddSelectedRooms()
}
}

View file

@ -86,6 +86,14 @@
app:layout_constraintBottom_toTopOf="@id/accountCreatedPersonalize"
app:layout_constraintTop_toBottomOf="@id/accountCreatedSubtitle" />
<androidx.constraintlayout.widget.Group
android:id="@+id/personalizeButtonGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="accountCreatedPersonalize,accountCreatedTakeMeHome"
tools:visibility="visible" />
<Button
android:id="@+id/accountCreatedPersonalize"
style="@style/Widget.Vector.Button.Login"
@ -96,11 +104,10 @@
android:textAllCaps="true"
android:textColor="?colorSecondary"
android:transitionName="loginSubmitTransition"
app:layout_constraintBottom_toTopOf="@id/accountCreatedSpace5"
app:layout_constraintBottom_toTopOf="@id/accountCreatedTakeMeHome"
app:layout_constraintEnd_toEndOf="@id/ftueAuthGutterEnd"
app:layout_constraintStart_toStartOf="@id/ftueAuthGutterStart"
app:layout_constraintTop_toBottomOf="@id/accountCreatedSpace4"
tools:text="@string/ftue_account_created_personalize" />
app:layout_constraintTop_toBottomOf="@id/accountCreatedSpace4" />
<Button
android:id="@+id/accountCreatedTakeMeHome"
@ -111,17 +118,46 @@
android:textAllCaps="true"
android:textColor="@color/element_background_light"
android:transitionName="loginSubmitTransition"
app:layout_constraintBottom_toTopOf="@id/accountCreatedSpace5"
app:layout_constraintBottom_toTopOf="@id/ctaBottomBarrier"
app:layout_constraintEnd_toEndOf="@id/ftueAuthGutterEnd"
app:layout_constraintStart_toStartOf="@id/ftueAuthGutterStart"
app:layout_constraintTop_toBottomOf="@id/accountCreatedPersonalize" />
<androidx.constraintlayout.widget.Group
android:id="@+id/takeMeHomeButtonGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="accountCreatedTakeMeHomeCta" />
<Button
android:id="@+id/accountCreatedTakeMeHomeCta"
style="@style/Widget.Vector.Button.Login"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:backgroundTint="@color/element_background_light"
android:text="@string/ftue_account_created_take_me_home"
android:textAllCaps="true"
android:textColor="?colorSecondary"
android:transitionName="loginSubmitTransition"
app:layout_constraintBottom_toTopOf="@id/ctaBottomBarrier"
app:layout_constraintEnd_toEndOf="@id/ftueAuthGutterEnd"
app:layout_constraintStart_toStartOf="@id/ftueAuthGutterStart"
app:layout_constraintTop_toBottomOf="@id/accountCreatedSpace4" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/ctaBottomBarrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="accountCreatedTakeMeHomeCta,accountCreatedTakeMeHome" />
<Space
android:id="@+id/accountCreatedSpace5"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHeight_percent="0.05"
app:layout_constraintTop_toBottomOf="@id/accountCreatedPersonalize" />
app:layout_constraintTop_toBottomOf="@id/ctaBottomBarrier" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -31,6 +31,7 @@
style="@style/Widget.Vector.Toolbar.Settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@id/profilePictureView"
app:layout_constraintTop_toBottomOf="@id/profilePictureToolbar"
app:layout_constraintTop_toTopOf="parent"

View file

@ -1,36 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/roomCategoryRootView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:colorBackground"
android:background="?attr/vctr_header_background"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="12dp"
android:paddingEnd="@dimen/layout_horizontal_margin"
android:paddingBottom="4dp">
android:paddingHorizontal="@dimen/layout_horizontal_margin"
android:paddingVertical="8dp">
<TextView
android:id="@+id/roomCategoryTitleView"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="wrap_content"
style="@style/Widget.Vector.TextView.Subtitle.Medium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:textColor="?vctr_content_secondary"
android:textStyle="bold"
app:drawableTint="?vctr_content_secondary"
tools:drawableEnd="@drawable/ic_expand_more"
android:textAllCaps="true"
android:textColor="?vctr_content_primary"
app:layout_constraintEnd_toStartOf="@id/roomCategoryCounterView"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth="wrap_content_constrained"
tools:text="@string/room_participants_header_direct_chats" />
<TextView
android:id="@+id/roomCategoryCounterView"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:drawablePadding="2dp"
android:gravity="center_vertical"
android:maxLines="1"
android:textColor="?vctr_content_secondary"
app:drawableTint="?vctr_content_secondary"
app:layout_constraintBottom_toBottomOf="@id/roomCategoryTitleView"
app:layout_constraintEnd_toStartOf="@id/roomCategoryUnreadCounterBadgeView"
app:layout_constraintStart_toEndOf="@id/roomCategoryTitleView"
app:layout_constraintTop_toTopOf="@id/roomCategoryTitleView"
app:layout_constraintWidth="wrap_content_constrained"
tools:drawableEnd="@drawable/ic_expand_more"
tools:text="14" />
<im.vector.app.features.home.room.list.UnreadCounterBadgeView
android:id="@+id/roomCategoryUnreadCounterBadgeView"
style="@style/Widget.Vector.TextView.Micro"
@ -39,10 +59,12 @@
android:gravity="center"
android:minWidth="16dp"
android:minHeight="16dp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:paddingHorizontal="4dp"
android:textColor="?colorOnError"
app:layout_constraintBottom_toBottomOf="@id/roomCategoryTitleView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/roomCategoryTitleView"
tools:background="@drawable/bg_unread_highlight"
tools:text="24" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -22,6 +22,7 @@ import org.amshove.kluent.shouldBeTrue
import org.junit.Test
import org.matrix.android.sdk.api.session.room.model.message.LocationAsset
import org.matrix.android.sdk.api.session.room.model.message.LocationAssetType
import org.matrix.android.sdk.api.session.room.model.message.LocationInfo
import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent
class LocationDataTest {
@ -64,13 +65,24 @@ class LocationDataTest {
@Test
fun selfLocationTest() {
val contentWithNullAsset = MessageLocationContent(body = "", geoUri = "", locationAsset = null)
val contentWithNullAsset = MessageLocationContent(body = "", geoUri = "")
contentWithNullAsset.isSelfLocation().shouldBeTrue()
val contentWithNullAssetType = MessageLocationContent(body = "", geoUri = "", locationAsset = LocationAsset(type = null))
val contentWithNullAssetType = MessageLocationContent(body = "", geoUri = "", unstableLocationAsset = LocationAsset(type = null))
contentWithNullAssetType.isSelfLocation().shouldBeTrue()
val contentWithSelfAssetType = MessageLocationContent(body = "", geoUri = "", locationAsset = LocationAsset(type = LocationAssetType.SELF))
val contentWithSelfAssetType = MessageLocationContent(body = "", geoUri = "", unstableLocationAsset = LocationAsset(type = LocationAssetType.SELF))
contentWithSelfAssetType.isSelfLocation().shouldBeTrue()
}
@Test
fun unstablePrefixTest() {
val geoUri = "geo :12.34,56.78;13.56"
val contentWithUnstablePrefixes = MessageLocationContent(body = "", geoUri = "", unstableLocationInfo = LocationInfo(geoUri = geoUri))
contentWithUnstablePrefixes.getBestLocationInfo()?.geoUri.shouldBeEqualTo(geoUri)
val contentWithStablePrefixes = MessageLocationContent(body = "", geoUri = "", locationInfo = LocationInfo(geoUri = geoUri))
contentWithStablePrefixes.getBestLocationInfo()?.geoUri.shouldBeEqualTo(geoUri)
}
}

View file

@ -20,8 +20,8 @@ import android.net.Uri
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.features.DefaultVectorOverrides
import im.vector.app.features.login.ReAuthHelper
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeAnalyticsTracker
@ -29,20 +29,27 @@ import im.vector.app.test.fakes.FakeAuthenticationService
import im.vector.app.test.fakes.FakeContext
import im.vector.app.test.fakes.FakeHomeServerConnectionConfigFactory
import im.vector.app.test.fakes.FakeHomeServerHistoryService
import im.vector.app.test.fakes.FakeRegistrationWizard
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fakes.FakeUri
import im.vector.app.test.fakes.FakeUriFilenameResolver
import im.vector.app.test.fakes.FakeVectorFeatures
import im.vector.app.test.fakes.FakeVectorOverrides
import im.vector.app.test.test
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
private const val A_DISPLAY_NAME = "a display name"
private const val A_PICTURE_FILENAME = "a-picture.png"
private val AN_ERROR = RuntimeException("an error!")
private val AN_UNSUPPORTED_PERSONALISATION_STATE = PersonalizationState(
supportsChangingDisplayName = false,
supportsChangingProfilePicture = false
)
class OnboardingViewModelTest {
@ -55,6 +62,7 @@ class OnboardingViewModelTest {
private val fakeSession = FakeSession()
private val fakeUriFilenameResolver = FakeUriFilenameResolver()
private val fakeActiveSessionHolder = FakeActiveSessionHolder(fakeSession)
private val fakeAuthenticationService = FakeAuthenticationService()
lateinit var viewModel: OnboardingViewModel
@ -75,21 +83,84 @@ class OnboardingViewModelTest {
}
@Test
fun `when handling display name update then updates upstream user display name`() = runBlockingTest {
fun `given supports changing display name when handling PersonalizeProfile then emits contents choose display name`() = runBlockingTest {
val initialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingDisplayName = true, supportsChangingProfilePicture = false))
viewModel = createViewModel(initialState)
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.PersonalizeProfile)
test
.assertEvents(OnboardingViewEvents.OnChooseDisplayName)
.finish()
}
@Test
fun `given only supports changing profile picture when handling PersonalizeProfile then emits contents choose profile picture`() = runBlockingTest {
val initialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingDisplayName = false, supportsChangingProfilePicture = true))
viewModel = createViewModel(initialState)
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.PersonalizeProfile)
test
.assertEvents(OnboardingViewEvents.OnChooseProfilePicture)
.finish()
}
@Test
fun `given homeserver does not support personalisation when registering account then updates state and emits account created event`() = runBlockingTest {
fakeSession.fakeHomeServerCapabilitiesService.givenCapabilities(HomeServerCapabilities(canChangeDisplayName = false, canChangeAvatar = false))
givenSuccessfullyCreatesAccount()
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.RegisterDummy)
test
.assertStates(
initialState,
initialState.copy(asyncRegistration = Loading()),
initialState.copy(
asyncLoginAction = Success(Unit),
asyncRegistration = Loading(),
personalizationState = AN_UNSUPPORTED_PERSONALISATION_STATE
),
initialState.copy(
asyncLoginAction = Success(Unit),
asyncRegistration = Uninitialized,
personalizationState = AN_UNSUPPORTED_PERSONALISATION_STATE
)
)
.assertEvents(OnboardingViewEvents.OnAccountCreated)
.finish()
}
@Test
fun `given changing profile picture is supported when updating display name then updates upstream user display name and moves to choose profile picture`() = runBlockingTest {
val personalisedInitialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingProfilePicture = true))
viewModel = createViewModel(personalisedInitialState)
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME))
test
.assertStates(
initialState,
initialState.copy(asyncDisplayName = Loading()),
initialState.copy(
asyncDisplayName = Success(Unit),
personalizationState = initialState.personalizationState.copy(displayName = A_DISPLAY_NAME)
)
)
.assertEvents(OnboardingViewEvents.OnDisplayNameUpdated)
.assertStates(expectedSuccessfulDisplayNameUpdateStates(personalisedInitialState))
.assertEvents(OnboardingViewEvents.OnChooseProfilePicture)
.finish()
fakeSession.fakeProfileService.verifyUpdatedName(fakeSession.myUserId, A_DISPLAY_NAME)
}
@Test
fun `given changing profile picture is not supported when updating display name then updates upstream user display name and completes personalization`() = runBlockingTest {
val personalisedInitialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingProfilePicture = false))
viewModel = createViewModel(personalisedInitialState)
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME))
test
.assertStates(expectedSuccessfulDisplayNameUpdateStates(personalisedInitialState))
.assertEvents(OnboardingViewEvents.OnPersonalizationComplete)
.finish()
fakeSession.fakeProfileService.verifyUpdatedName(fakeSession.myUserId, A_DISPLAY_NAME)
}
@ -184,7 +255,7 @@ class OnboardingViewModelTest {
return OnboardingViewModel(
state,
fakeContext.instance,
FakeAuthenticationService(),
fakeAuthenticationService,
fakeActiveSessionHolder.instance,
FakeHomeServerConnectionConfigFactory().instance,
ReAuthHelper(),
@ -193,7 +264,7 @@ class OnboardingViewModelTest {
FakeVectorFeatures(),
FakeAnalyticsTracker(),
fakeUriFilenameResolver.instance,
DefaultVectorOverrides()
FakeVectorOverrides()
)
}
@ -214,4 +285,23 @@ class OnboardingViewModelTest {
state.copy(asyncProfilePicture = Loading()),
state.copy(asyncProfilePicture = Fail(cause))
)
private fun givenSuccessfullyCreatesAccount() {
fakeActiveSessionHolder.expectSetsActiveSession(fakeSession)
val registrationWizard = FakeRegistrationWizard().also { it.givenSuccessfulDummy(fakeSession) }
fakeAuthenticationService.givenRegistrationWizard(registrationWizard)
fakeAuthenticationService.expectReset()
fakeSession.expectStartsSyncing()
}
private fun expectedSuccessfulDisplayNameUpdateStates(personalisedInitialState: OnboardingViewState): List<OnboardingViewState> {
return listOf(
personalisedInitialState,
personalisedInitialState.copy(asyncDisplayName = Loading()),
personalisedInitialState.copy(
asyncDisplayName = Success(Unit),
personalizationState = personalisedInitialState.personalizationState.copy(displayName = A_DISPLAY_NAME)
)
)
}
}

View file

@ -18,7 +18,9 @@ package im.vector.app.test.fakes
import im.vector.app.core.di.ActiveSessionHolder
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
import org.matrix.android.sdk.api.session.Session
class FakeActiveSessionHolder(
private val fakeSession: FakeSession = FakeSession()
@ -26,4 +28,8 @@ class FakeActiveSessionHolder(
val instance = mockk<ActiveSessionHolder> {
every { getActiveSession() } returns fakeSession
}
fun expectSetsActiveSession(session: Session) {
justRun { instance.setActiveSession(session) }
}
}

View file

@ -16,7 +16,18 @@
package im.vector.app.test.fakes
import io.mockk.coJustRun
import io.mockk.every
import io.mockk.mockk
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
class FakeAuthenticationService : AuthenticationService by mockk()
class FakeAuthenticationService : AuthenticationService by mockk() {
fun givenRegistrationWizard(registrationWizard: RegistrationWizard) {
every { getRegistrationWizard() } returns registrationWizard
}
fun expectReset() {
coJustRun { reset() }
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2022 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.test.fakes
import io.mockk.every
import io.mockk.mockk
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
class FakeHomeServerCapabilitiesService : HomeServerCapabilitiesService by mockk() {
fun givenCapabilities(homeServerCapabilities: HomeServerCapabilities) {
every { getHomeServerCapabilities() } returns homeServerCapabilities
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2022 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.test.fakes
import io.mockk.coEvery
import io.mockk.mockk
import org.matrix.android.sdk.api.auth.registration.RegistrationResult
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
import org.matrix.android.sdk.api.session.Session
class FakeRegistrationWizard : RegistrationWizard by mockk() {
fun givenSuccessfulDummy(session: Session) {
coEvery { dummy() } returns RegistrationResult.Success(session)
}
}

View file

@ -17,10 +17,13 @@
package im.vector.app.test.fakes
import android.net.Uri
import im.vector.app.core.extensions.configureAndStart
import im.vector.app.core.extensions.startSyncing
import im.vector.app.core.extensions.vectorStore
import im.vector.app.features.session.VectorSessionStore
import im.vector.app.test.testCoroutineDispatchers
import io.mockk.coEvery
import io.mockk.coJustRun
import io.mockk.mockk
import io.mockk.mockkStatic
import org.matrix.android.sdk.api.session.Session
@ -28,6 +31,7 @@ import org.matrix.android.sdk.api.session.Session
class FakeSession(
val fakeCryptoService: FakeCryptoService = FakeCryptoService(),
val fakeProfileService: FakeProfileService = FakeProfileService(),
val fakeHomeServerCapabilitiesService: FakeHomeServerCapabilitiesService = FakeHomeServerCapabilitiesService(),
val fakeSharedSecretStorageService: FakeSharedSecretStorageService = FakeSharedSecretStorageService()
) : Session by mockk(relaxed = true) {
@ -42,6 +46,7 @@ class FakeSession(
override val coroutineDispatchers = testCoroutineDispatchers
override suspend fun setDisplayName(userId: String, newDisplayName: String) = fakeProfileService.setDisplayName(userId, newDisplayName)
override suspend fun updateAvatar(userId: String, newAvatarUri: Uri, fileName: String) = fakeProfileService.updateAvatar(userId, newAvatarUri, fileName)
override fun getHomeServerCapabilities() = fakeHomeServerCapabilitiesService.getHomeServerCapabilities()
fun givenVectorStore(vectorSessionStore: VectorSessionStore) {
coEvery {
@ -50,4 +55,11 @@ class FakeSession(
vectorSessionStore
}
}
fun expectStartsSyncing() {
coJustRun {
this@FakeSession.configureAndStart(any(), startSyncing = true)
this@FakeSession.startSyncing(any())
}
}
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2022 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.test.fakes
import im.vector.app.features.DefaultVectorOverrides
import im.vector.app.features.VectorOverrides
class FakeVectorOverrides : VectorOverrides by DefaultVectorOverrides()