Merge pull request #449 from vector-im/feature/room_update

Feature/room upgrade
This commit is contained in:
ganfra 2019-07-31 15:34:38 +02:00 committed by GitHub
commit c300c50093
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1230 additions and 111 deletions

View File

@ -45,6 +45,10 @@ class RxRoom(private val room: Room) {
room.loadRoomMembersIfNeeded(MatrixCallbackSingle(it)).toSingle(it)
}

fun joinRoom(viaServers: List<String> = emptyList()): Single<Unit> = Single.create {
room.join(viaServers, MatrixCallbackSingle(it)).toSingle(it)
}

}

fun Room.rx(): RxRoom {

View File

@ -63,6 +63,10 @@ class RxSession(private val session: Session) {
session.searchUsersDirectory(search, limit, excludedUserIds, MatrixCallbackSingle(it)).toSingle(it)
}

fun joinRoom(roomId: String, viaServers: List<String> = emptyList()): Single<Unit> = Single.create {
session.joinRoom(roomId, viaServers, MatrixCallbackSingle(it)).toSingle(it)
}

}

fun Session.rx(): RxSession {

View File

@ -123,9 +123,9 @@ object MatrixPatterns {
*/
fun isEventId(str: String?): Boolean {
return str != null
&& (str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER
|| str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3
|| str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4)
&& (str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER
|| str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3
|| str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4)
}

/**
@ -137,4 +137,23 @@ object MatrixPatterns {
fun isGroupId(str: String?): Boolean {
return str != null && str matches PATTERN_CONTAIN_MATRIX_GROUP_IDENTIFIER
}

/**
* Extract server name from a matrix id
*
* @param matrixId
* @return null if not found or if matrixId is null
*/
fun extractServerNameFromId(matrixId: String?): String? {
if (matrixId == null) {
return null
}

val index = matrixId.lastIndexOf(":")

return if (index == -1) {
null
} else matrixId.substring(index + 1)

}
}

View File

@ -26,8 +26,13 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class MatrixError(
@Json(name = "errcode") val code: String,
@Json(name = "error") val message: String
) {
@Json(name = "error") val message: String,

@Json(name = "consent_uri") val consentUri: String? = null,
// RESOURCE_LIMIT_EXCEEDED data
@Json(name = "limit_type") val limitType: String? = null,
@Json(name = "admin_contact") val adminUri: String? = null) {


companion object {
const val FORBIDDEN = "M_FORBIDDEN"
@ -55,5 +60,8 @@ data class MatrixError(
const val M_CONSENT_NOT_GIVEN = "M_CONSENT_NOT_GIVEN"
const val RESOURCE_LIMIT_EXCEEDED = "M_RESOURCE_LIMIT_EXCEEDED"
const val WRONG_ROOM_KEYS_VERSION = "M_WRONG_ROOM_KEYS_VERSION"

// Possible value for "limit_type"
const val LIMIT_TYPE_MAU = "monthly_active_user"
}
}

View File

@ -32,6 +32,15 @@ interface RoomService {
*/
fun createRoom(createRoomParams: CreateRoomParams, callback: MatrixCallback<String>): Cancelable

/**
* Join a room by id
* @param roomId the roomId of the room to join
* @param viaServers the servers to attempt to join the room through. One of the servers must be participating in the room.
*/
fun joinRoom(roomId: String,
viaServers: List<String> = emptyList(),
callback: MatrixCallback<Unit>): Cancelable

/**
* Get a room from a roomId
* @param roomId the roomId to look for.

View File

@ -0,0 +1,25 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.matrix.android.api.session.room.failure

import im.vector.matrix.android.api.failure.Failure

sealed class CreateRoomFailure : Failure.FeatureFailure() {

object CreatedWithTimeout: CreateRoomFailure()

}

View File

@ -0,0 +1,25 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.matrix.android.api.session.room.failure

import im.vector.matrix.android.api.failure.Failure

sealed class JoinRoomFailure : Failure.FeatureFailure() {

object JoinedWithTimeout : JoinRoomFailure()

}

View File

@ -57,7 +57,8 @@ interface MembershipService {
/**
* Join the room, or accept an invitation.
*/
fun join(callback: MatrixCallback<Unit>): Cancelable

fun join(viaServers: List<String> = emptyList(), callback: MatrixCallback<Unit>): Cancelable

/**
* Leave the room, or reject an invitation.

View File

@ -34,5 +34,10 @@ data class RoomSummary(
val notificationCount: Int = 0,
val highlightCount: Int = 0,
val tags: List<RoomTag> = emptyList(),
val membership: Membership = Membership.NONE
)
val membership: Membership = Membership.NONE,
val versioningState: VersioningState = VersioningState.NONE
) {

val isVersioned: Boolean
get() = versioningState != VersioningState.NONE
}

View File

@ -0,0 +1,23 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.matrix.android.api.session.room.model

enum class VersioningState {
NONE,
UPGRADED_ROOM_NOT_JOINED,
UPGRADED_ROOM_JOINED
}

View File

@ -0,0 +1,28 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.api.session.room.model.create

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

/**
* A link to an old room in case of room versioning
*/
@JsonClass(generateAdapter = true)
data class Predecessor(
@Json(name = "room_id") val roomId: String? = null,
@Json(name = "event_id") val eventId: String? = null
)

View File

@ -0,0 +1,32 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.matrix.android.api.session.room.model.create

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

/**
* Content of a m.room.create type event
*/
@JsonClass(generateAdapter = true)
data class RoomCreateContent(
@Json(name = "creator") val creator: String? = null,
@Json(name = "room_version") val roomVersion: String? = null,
@Json(name = "predecessor") val predecessor: Predecessor? = null
)


View File

@ -0,0 +1,28 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.api.session.room.model.tombstone

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

/**
* Class to contains Tombstone information
*/
@JsonClass(generateAdapter = true)
data class RoomTombstoneContent(
@Json(name = "body") val body: String? = null,
@Json(name = "replacement_room") val replacementRoom: String?
)

View File

@ -17,6 +17,7 @@
package im.vector.matrix.android.api.session.room.state

import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.events.model.Event

interface StateService {

@ -25,4 +26,6 @@ interface StateService {
*/
fun updateTopic(topic: String, callback: MatrixCallback<Unit>)

fun getStateEvent(eventType: String): Event?

}

View File

@ -16,45 +16,45 @@

package im.vector.matrix.android.internal.database

import android.os.Handler
import android.os.HandlerThread
import im.vector.matrix.android.internal.util.createBackgroundHandler
import io.realm.*
import timber.log.Timber
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference

private const val THREAD_NAME = "REALM_QUERY_LATCH"

class RealmQueryLatch<E : RealmObject>(private val realmConfiguration: RealmConfiguration,
private val realmQueryBuilder: (Realm) -> RealmQuery<E>) {

@Throws(InterruptedException::class)
fun await(timeout: Long = Long.MAX_VALUE, timeUnit: TimeUnit = TimeUnit.MILLISECONDS) {
val latch = CountDownLatch(1)
val handlerThread = HandlerThread(THREAD_NAME + hashCode())
handlerThread.start()
val handler = Handler(handlerThread.looper)
val runnable = Runnable {
val realm = Realm.getInstance(realmConfiguration)
val result = realmQueryBuilder(realm).findAllAsync()
private companion object {
val QUERY_LATCH_HANDLER = createBackgroundHandler("REALM_QUERY_LATCH")
}

@Throws(InterruptedException::class)
fun await(timeout: Long, timeUnit: TimeUnit) {
val realmRef = AtomicReference<Realm>()
val latch = CountDownLatch(1)
QUERY_LATCH_HANDLER.post {
val realm = Realm.getInstance(realmConfiguration)
realmRef.set(realm)
val result = realmQueryBuilder(realm).findAllAsync()
result.addChangeListener(object : RealmChangeListener<RealmResults<E>> {
override fun onChange(t: RealmResults<E>) {
if (t.isNotEmpty()) {
result.removeChangeListener(this)
realm.close()
latch.countDown()
}
}
})
}
handler.post(runnable)
try {
latch.await(timeout, timeUnit)
} catch (exception: InterruptedException) {
throw exception
} finally {
handlerThread.quit()
QUERY_LATCH_HANDLER.post {
realmRef.getAndSet(null).close()
}
}
}


View File

@ -61,7 +61,8 @@ internal class RoomSummaryMapper @Inject constructor(
highlightCount = roomSummaryEntity.highlightCount,
notificationCount = roomSummaryEntity.notificationCount,
tags = tags,
membership = roomSummaryEntity.membership
membership = roomSummaryEntity.membership,
versioningState = roomSummaryEntity.versioningState
)
}
}

View File

@ -17,6 +17,7 @@
package im.vector.matrix.android.internal.database.model

import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.VersioningState
import io.realm.RealmList
import io.realm.RealmObject
import io.realm.annotations.Ignore
@ -40,12 +41,19 @@ internal open class RoomSummaryEntity(@PrimaryKey var roomId: String = "",
) : RealmObject() {

private var membershipStr: String = Membership.NONE.name
private var versioningStateStr: String = VersioningState.NONE.name


@delegate:Ignore
var membership: Membership by Delegates.observable(Membership.valueOf(membershipStr)) { _, _, newValue ->
membershipStr = newValue.name
}

@delegate:Ignore
var versioningState: VersioningState by Delegates.observable(VersioningState.valueOf(versioningStateStr)) { _, _, newValue ->
versioningStateStr = newValue.name
}

companion object

}

View File

@ -37,6 +37,8 @@ import im.vector.matrix.android.internal.network.AccessTokenInterceptor
import im.vector.matrix.android.internal.network.RetrofitFactory
import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater
import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater
import im.vector.matrix.android.internal.session.room.create.RoomCreateEventLiveObserver
import im.vector.matrix.android.internal.session.room.tombstone.RoomTombstoneEventLiveObserver
import im.vector.matrix.android.internal.session.room.prune.EventsPruner
import im.vector.matrix.android.internal.util.md5
import io.realm.RealmConfiguration
@ -128,6 +130,14 @@ internal abstract class SessionModule {
@IntoSet
abstract fun bindEventRelationsAggregationUpdater(groupSummaryUpdater: EventRelationsAggregationUpdater): LiveEntityObserver

@Binds
@IntoSet
abstract fun bindRoomTombstoneEventLiveObserver(roomTombstoneEventLiveObserver: RoomTombstoneEventLiveObserver): LiveEntityObserver

@Binds
@IntoSet
abstract fun bindRoomCreateEventLiveObserver(roomCreateEventLiveObserver: RoomCreateEventLiveObserver): LiveEntityObserver

@Binds
abstract fun bindInitialSyncProgressService(initialSyncProgressService: DefaultInitialSyncProgressService): InitialSyncProgressService


View File

@ -22,6 +22,7 @@ import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.api.session.room.RoomService
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.VersioningState
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.database.mapper.RoomSummaryMapper
@ -30,6 +31,7 @@ import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntityFields
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.room.create.CreateRoomTask
import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.util.fetchManaged
@ -38,6 +40,7 @@ import javax.inject.Inject
internal class DefaultRoomService @Inject constructor(private val monarchy: Monarchy,
private val roomSummaryMapper: RoomSummaryMapper,
private val createRoomTask: CreateRoomTask,
private val joinRoomTask: JoinRoomTask,
private val roomFactory: RoomFactory,
private val taskExecutor: TaskExecutor) : RoomService {

@ -55,8 +58,19 @@ internal class DefaultRoomService @Inject constructor(private val monarchy: Mona

override fun liveRoomSummaries(): LiveData<List<RoomSummary>> {
return monarchy.findAllMappedWithChanges(
{ realm -> RoomSummaryEntity.where(realm).isNotEmpty(RoomSummaryEntityFields.DISPLAY_NAME) },
{ realm ->
RoomSummaryEntity.where(realm)
.isNotEmpty(RoomSummaryEntityFields.DISPLAY_NAME)
.notEqualTo(RoomSummaryEntityFields.VERSIONING_STATE_STR, VersioningState.UPGRADED_ROOM_JOINED.name)
},
{ roomSummaryMapper.map(it) }
)
}

override fun joinRoom(roomId: String, viaServers: List<String>, callback: MatrixCallback<Unit>): Cancelable {
return joinRoomTask
.configureWith(JoinRoomTask.Params(roomId, viaServers))
.dispatchTo(callback)
.executeBy(taskExecutor)
}
}

View File

@ -218,6 +218,7 @@ internal interface RoomAPI {
*/
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/join")
fun join(@Path("roomId") roomId: String,
@Query("server_name") viaServers: List<String>,
@Body params: Map<String, String>): Call<Unit>

/**

View File

@ -22,6 +22,7 @@ import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.internal.database.mapper.RoomSummaryMapper
import im.vector.matrix.android.internal.di.SessionDatabase
import im.vector.matrix.android.internal.session.room.membership.DefaultMembershipService
import im.vector.matrix.android.internal.session.room.membership.LoadRoomMembersTask
import im.vector.matrix.android.internal.session.room.membership.joining.InviteTask
@ -40,6 +41,7 @@ import im.vector.matrix.android.internal.session.room.timeline.DefaultTimelineSe
import im.vector.matrix.android.internal.session.room.timeline.GetContextOfEventTask
import im.vector.matrix.android.internal.session.room.timeline.PaginationTask
import im.vector.matrix.android.internal.task.TaskExecutor
import io.realm.RealmConfiguration
import javax.inject.Inject

internal class RoomFactory @Inject constructor(private val context: Context,
@ -63,7 +65,7 @@ internal class RoomFactory @Inject constructor(private val context: Context,
fun create(roomId: String): Room {
val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, cryptoService, paginationTask)
val sendService = DefaultSendService(context, credentials, roomId, eventFactory, cryptoService, monarchy)
val stateService = DefaultStateService(roomId, taskExecutor, sendStateTask)
val stateService = DefaultStateService(roomId, monarchy.realmConfiguration, taskExecutor, sendStateTask)
val roomMembersService = DefaultMembershipService(roomId, monarchy, taskExecutor, loadRoomMembersTask, inviteTask, joinRoomTask, leaveRoomTask)
val readService = DefaultReadService(roomId, monarchy, taskExecutor, setReadMarkersTask, credentials)
val relationService = DefaultRelationService(context,

View File

@ -17,7 +17,9 @@
package im.vector.matrix.android.internal.session.room.create

import arrow.core.Try
import arrow.core.recoverWith
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.room.failure.CreateRoomFailure
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse
import im.vector.matrix.android.internal.database.RealmQueryLatch
@ -57,9 +59,11 @@ internal class DefaultCreateRoomTask @Inject constructor(private val roomAPI: Ro
realm.where(RoomEntity::class.java)
.equalTo(RoomEntityFields.ROOM_ID, roomId)
}
Try {
rql.await(timeout = 20L, timeUnit = TimeUnit.SECONDS)
roomId
try {
rql.await(timeout = 1L, timeUnit = TimeUnit.MINUTES)
Try.just(roomId)
} catch (exception: Exception) {
Try.raise<String>(CreateRoomFailure.CreatedWithTimeout)
}
}.flatMap { roomId ->
if (params.isDirect()) {

View File

@ -0,0 +1,74 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.matrix.android.internal.session.room.create

import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.VersioningState
import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
import im.vector.matrix.android.internal.database.RealmLiveEntityObserver
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.query.types
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.di.SessionDatabase
import io.realm.OrderedCollectionChangeSet
import io.realm.Realm
import io.realm.RealmConfiguration
import io.realm.RealmResults
import javax.inject.Inject

internal class RoomCreateEventLiveObserver @Inject constructor(@SessionDatabase
realmConfiguration: RealmConfiguration)
: RealmLiveEntityObserver<EventEntity>(realmConfiguration) {

override val query = Monarchy.Query<EventEntity> {
EventEntity.types(it, listOf(EventType.STATE_ROOM_CREATE))
}

override fun onChange(results: RealmResults<EventEntity>, changeSet: OrderedCollectionChangeSet) {
changeSet.insertions
.asSequence()
.mapNotNull {
results[it]?.asDomain()
}
.toList()
.also {
handleRoomCreateEvents(it)
}
}

private fun handleRoomCreateEvents(createEvents: List<Event>) = Realm.getInstance(realmConfiguration).use {
it.executeTransactionAsync { realm ->
for (event in createEvents) {
val createRoomContent = event.getClearContent().toModel<RoomCreateContent>()
val predecessorRoomId = createRoomContent?.predecessor?.roomId ?: continue

val predecessorRoomSummary = RoomSummaryEntity.where(realm, predecessorRoomId).findFirst()
?: RoomSummaryEntity(predecessorRoomId)
predecessorRoomSummary.versioningState = VersioningState.UPGRADED_ROOM_JOINED
realm.insertOrUpdate(predecessorRoomSummary)

}
}
}

}

View File

@ -82,8 +82,8 @@ internal class DefaultMembershipService @Inject constructor(private val roomId:
.executeBy(taskExecutor)
}

override fun join(callback: MatrixCallback<Unit>): Cancelable {
val params = JoinRoomTask.Params(roomId)
override fun join(viaServers: List<String>, callback: MatrixCallback<Unit>): Cancelable {
val params = JoinRoomTask.Params(roomId, viaServers)
return joinTask.configureWith(params)
.dispatchTo(callback)
.executeBy(taskExecutor)

View File

@ -17,6 +17,8 @@
package im.vector.matrix.android.internal.session.room.membership.joining

import arrow.core.Try
import im.vector.matrix.android.api.session.room.failure.CreateRoomFailure
import im.vector.matrix.android.api.session.room.failure.JoinRoomFailure
import im.vector.matrix.android.internal.database.RealmQueryLatch
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.model.RoomEntityFields
@ -31,7 +33,8 @@ import javax.inject.Inject

internal interface JoinRoomTask : Task<JoinRoomTask.Params, Unit> {
data class Params(
val roomId: String
val roomId: String,
val viaServers: List<String> = emptyList()
)
}

@ -41,7 +44,7 @@ internal class DefaultJoinRoomTask @Inject constructor(private val roomAPI: Room

override suspend fun execute(params: JoinRoomTask.Params): Try<Unit> {
return executeRequest<Unit> {
apiCall = roomAPI.join(params.roomId, HashMap())
apiCall = roomAPI.join(params.roomId, params.viaServers, HashMap())
}.flatMap {
val roomId = params.roomId
// Wait for room to come back from the sync (but it can maybe be in the DB is the sync response is received before)
@ -49,9 +52,11 @@ internal class DefaultJoinRoomTask @Inject constructor(private val roomAPI: Room
realm.where(RoomEntity::class.java)
.equalTo(RoomEntityFields.ROOM_ID, roomId)
}
Try {
rql.await(20L, TimeUnit.SECONDS)
roomId
try {
rql.await(timeout = 1L, timeUnit = TimeUnit.MINUTES)
Try.just(roomId)
} catch (exception: Exception) {
Try.raise<String>(JoinRoomFailure.JoinedWithTimeout)
}
}.flatMap { roomId ->
setReadMarkers(roomId)

View File

@ -17,16 +17,32 @@
package im.vector.matrix.android.internal.session.room.state

import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.state.StateService
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.query.prev
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.di.SessionDatabase
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import io.realm.Realm
import io.realm.RealmConfiguration
import javax.inject.Inject

internal class DefaultStateService @Inject constructor(private val roomId: String,
@SessionDatabase
private val realmConfiguration: RealmConfiguration,
private val taskExecutor: TaskExecutor,
private val sendStateTask: SendStateTask) : StateService {

override fun getStateEvent(eventType: String): Event? {
return Realm.getInstance(realmConfiguration).use { realm ->
EventEntity.where(realm, roomId, eventType).prev()?.asDomain()
}
}

override fun updateTopic(topic: String, callback: MatrixCallback<Unit>) {
val params = SendStateTask.Params(roomId,
EventType.STATE_ROOM_TOPIC,
@ -39,4 +55,6 @@ internal class DefaultStateService @Inject constructor(private val roomId: Strin
.dispatchTo(callback)
.executeBy(taskExecutor)
}


}

View File

@ -0,0 +1,76 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.matrix.android.internal.session.room.tombstone

import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.VersioningState
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
import im.vector.matrix.android.internal.database.RealmLiveEntityObserver
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.query.types
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.di.SessionDatabase
import io.realm.OrderedCollectionChangeSet
import io.realm.Realm
import io.realm.RealmConfiguration
import io.realm.RealmResults
import javax.inject.Inject

internal class RoomTombstoneEventLiveObserver @Inject constructor(@SessionDatabase
realmConfiguration: RealmConfiguration)
: RealmLiveEntityObserver<EventEntity>(realmConfiguration) {

override val query = Monarchy.Query<EventEntity> {
EventEntity.types(it, listOf(EventType.STATE_ROOM_TOMBSTONE))
}

override fun onChange(results: RealmResults<EventEntity>, changeSet: OrderedCollectionChangeSet) {
changeSet.insertions
.asSequence()
.mapNotNull {
results[it]?.asDomain()
}
.toList()
.also {
handleRoomTombstoneEvents(it)
}
}

private fun handleRoomTombstoneEvents(tombstoneEvents: List<Event>) = Realm.getInstance(realmConfiguration).use {
it.executeTransactionAsync { realm ->
for (event in tombstoneEvents) {
if (event.roomId == null) continue
val createRoomContent = event.getClearContent().toModel<RoomTombstoneContent>()
if (createRoomContent?.replacementRoom == null) continue

val predecessorRoomSummary = RoomSummaryEntity.where(realm, event.roomId).findFirst()
?: RoomSummaryEntity(event.roomId)
if (predecessorRoomSummary.versioningState == VersioningState.NONE) {
predecessorRoomSummary.versioningState = VersioningState.UPGRADED_ROOM_NOT_JOINED
}
realm.insertOrUpdate(predecessorRoomSummary)

}
}
}

}

View File

@ -29,6 +29,7 @@ import im.vector.matrix.android.internal.crypto.CryptoManager
import im.vector.matrix.android.internal.database.helper.add
import im.vector.matrix.android.internal.database.helper.addOrUpdate
import im.vector.matrix.android.internal.database.helper.addStateEvent
import im.vector.matrix.android.internal.database.helper.lastStateIndex
import im.vector.matrix.android.internal.database.helper.updateSenderDataFor
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.ChunkEntity
@ -157,8 +158,7 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
roomEntity,
roomSync.timeline.events,
roomSync.timeline.prevToken,
roomSync.timeline.limited,
0
roomSync.timeline.limited
)
roomEntity.addOrUpdate(chunkEntity)
}
@ -206,15 +206,18 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
roomEntity: RoomEntity,
eventList: List<Event>,
prevToken: String? = null,
isLimited: Boolean = true,
stateIndexOffset: Int = 0): ChunkEntity {
isLimited: Boolean = true): ChunkEntity {

val lastChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomEntity.roomId)
var stateIndexOffset = 0
val chunkEntity = if (!isLimited && lastChunk != null) {
lastChunk
} else {
realm.createObject<ChunkEntity>().apply { this.prevToken = prevToken }
}
if (isLimited && lastChunk != null) {
stateIndexOffset = lastChunk.lastStateIndex(PaginationDirection.FORWARDS)
}
lastChunk?.isLastForward = false
chunkEntity.isLastForward = true


View File

@ -31,6 +31,7 @@
<string name="notice_room_visibility_world_readable">anyone.</string>
<string name="notice_room_visibility_unknown">unknown (%s).</string>
<string name="notice_end_to_end">%1$s turned on end-to-end encryption (%2$s)</string>
<string name="notice_room_update">%s upgraded this room.</string>

<string name="notice_requested_voip_conference">%1$s requested a VoIP conference</string>
<string name="notice_voip_started">VoIP conference started</string>

View File

@ -0,0 +1,33 @@
<?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="wrap_content"
android:minHeight="42dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
tools:background="@color/vector_fuchsia_color"
tools:parentTag="android.widget.RelativeLayout">

<ImageView
android:id="@+id/room_notification_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_centerVertical="true"
android:layout_marginStart="24dp"
android:padding="5dp"
tools:src="@drawable/vector_typing" />

<TextView
android:id="@+id/room_notification_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="64dp"
android:layout_marginEnd="16dp"
android:accessibilityLiveRegion="polite"
android:textColor="?attr/vctr_room_notification_text_color"
tools:text="a text here" />

</merge>

View File

@ -30,12 +30,16 @@ class ErrorFormatter @Inject constructor(val stringProvider: StringProvider) {
}

fun toHumanReadable(throwable: Throwable?): String {

return when (throwable) {
null -> ""
is Failure.NetworkConnection -> stringProvider.getString(R.string.error_no_network)
is Failure.ServerError -> {
throwable.error.message.takeIf { it.isNotEmpty() }
?: throwable.error.code.takeIf { it.isNotEmpty() }
?: stringProvider.getString(R.string.unknown_error)
}
else -> throwable.localizedMessage
?: stringProvider.getString(R.string.unknown_error)
}

}
}

View File

@ -0,0 +1,68 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.riotx.core.error

import android.content.Context
import android.text.Html
import androidx.annotation.StringRes
import im.vector.matrix.android.api.failure.MatrixError
import im.vector.riotx.R
import me.gujun.android.span.span

class ResourceLimitErrorFormatter(private val context: Context) {

// 'hard' if the logged in user has been locked out, 'soft' if they haven't
sealed class Mode(@StringRes val mauErrorRes: Int, @StringRes val defaultErrorRes: Int, @StringRes val contactRes: Int) {
// User can still send message (will be used in a near future)
object Soft : Mode(R.string.resource_limit_soft_mau, R.string.resource_limit_soft_default, R.string.resource_limit_soft_contact)

// User cannot send message anymore
object Hard : Mode(R.string.resource_limit_hard_mau, R.string.resource_limit_hard_default, R.string.resource_limit_hard_contact)
}

fun format(matrixError: MatrixError,
mode: Mode,
separator: CharSequence = " ",
clickable: Boolean = false): CharSequence {
val error = if (MatrixError.LIMIT_TYPE_MAU == matrixError.limitType) {
context.getString(mode.mauErrorRes)
} else {
context.getString(mode.defaultErrorRes)
}
val contact = if (clickable && matrixError.adminUri != null) {
val contactSubString = uriAsLink(matrixError.adminUri!!)
val contactFullString = context.getString(mode.contactRes, contactSubString)
Html.fromHtml(contactFullString)
} else {
val contactSubString = context.getString(R.string.resource_limit_contact_admin)
context.getString(mode.contactRes, contactSubString)
}
return span {
text = error
}
.append(separator)
.append(contact)
}

/**
* Create a HTML link with a uri
*/
private fun uriAsLink(uri: String): String {
val contactStr = context.getString(R.string.resource_limit_contact_admin)
return "<a href=\"$uri\">$contactStr</a>"
}
}

View File

@ -0,0 +1,318 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.riotx.core.platform

import android.content.Context
import android.graphics.Color
import android.text.SpannableString
import android.text.TextPaint
import android.text.TextUtils
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import butterknife.BindView
import butterknife.ButterKnife
import im.vector.matrix.android.api.failure.MatrixError
import im.vector.matrix.android.api.permalinks.PermalinkFactory
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
import im.vector.riotx.R
import im.vector.riotx.core.error.ResourceLimitErrorFormatter
import im.vector.riotx.features.themes.ThemeUtils
import me.gujun.android.span.span
import me.saket.bettermovementmethod.BetterLinkMovementMethod
import timber.log.Timber

/**
* The view used to show some information about the room
* It does have a unique render method
*/
class NotificationAreaView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RelativeLayout(context, attrs, defStyleAttr) {

@BindView(R.id.room_notification_icon)
lateinit var imageView: ImageView
@BindView(R.id.room_notification_message)
lateinit var messageView: TextView

var delegate: Delegate? = null
private var state: State = State.Initial

init {
setupView()
}

/**
* This methods is responsible for rendering the view according to the newState
*
* @param newState the newState representing the view
*/
fun render(newState: State) {
if (newState == state) {
Timber.d("State unchanged")
return
}
Timber.d("Rendering $newState")
cleanUp()
state = newState
when (newState) {
is State.Default -> renderDefault()
is State.Hidden -> renderHidden()
is State.Tombstone -> renderTombstone(newState)
is State.ResourceLimitExceededError -> renderResourceLimitExceededError(newState)
is State.ConnectionError -> renderConnectionError()
is State.Typing -> renderTyping(newState)
is State.UnreadPreview -> renderUnreadPreview()
is State.ScrollToBottom -> renderScrollToBottom(newState)
is State.UnsentEvents -> renderUnsent(newState)
}
}

// PRIVATE METHODS *****************************************************************************************************************************************

private fun setupView() {
inflate(context, R.layout.view_notification_area, this)
ButterKnife.bind(this)
}

private fun cleanUp() {
messageView.setOnClickListener(null)
imageView.setOnClickListener(null)
setBackgroundColor(Color.TRANSPARENT)
messageView.text = null
imageView.setImageResource(0)
}

private fun renderTombstone(state: State.Tombstone) {
visibility = View.VISIBLE
imageView.setImageResource(R.drawable.error)
val message = span {
+resources.getString(R.string.room_tombstone_versioned_description)
+"\n"
span(resources.getString(R.string.room_tombstone_continuation_link)) {
textDecorationLine = "underline"
onClick = { delegate?.onTombstoneEventClicked(state.tombstoneEvent) }
}
}
messageView.movementMethod = BetterLinkMovementMethod.getInstance()
messageView.text = message
}

private fun renderResourceLimitExceededError(state: State.ResourceLimitExceededError) {
visibility = View.VISIBLE
val resourceLimitErrorFormatter = ResourceLimitErrorFormatter(context)
val formatterMode: ResourceLimitErrorFormatter.Mode
val backgroundColor: Int
if (state.isSoft) {
backgroundColor = R.color.soft_resource_limit_exceeded
formatterMode = ResourceLimitErrorFormatter.Mode.Soft
} else {
backgroundColor = R.color.hard_resource_limit_exceeded
formatterMode = ResourceLimitErrorFormatter.Mode.Hard
}
val message = resourceLimitErrorFormatter.format(state.matrixError, formatterMode, clickable = true)
messageView.setTextColor(Color.WHITE)
messageView.text = message
messageView.movementMethod = LinkMovementMethod.getInstance()
messageView.setLinkTextColor(Color.WHITE)
setBackgroundColor(ContextCompat.getColor(context, backgroundColor))
}

private fun renderConnectionError() {
visibility = View.VISIBLE
imageView.setImageResource(R.drawable.error)
messageView.setTextColor(ContextCompat.getColor(context, R.color.vector_fuchsia_color))
messageView.text = SpannableString(resources.getString(R.string.room_offline_notification))
}

private fun renderTyping(state: State.Typing) {
visibility = View.VISIBLE
imageView.setImageResource(R.drawable.vector_typing)
messageView.text = SpannableString(state.message)
messageView.setTextColor(ThemeUtils.getColor(context, R.attr.vctr_room_notification_text_color))
}

private fun renderUnreadPreview() {
visibility = View.VISIBLE
imageView.setImageResource(R.drawable.scrolldown)
messageView.setTextColor(ThemeUtils.getColor(context, R.attr.vctr_room_notification_text_color))
imageView.setOnClickListener { delegate?.closeScreen() }
}

private fun renderScrollToBottom(state: State.ScrollToBottom) {
visibility = View.VISIBLE
if (state.unreadCount > 0) {
imageView.setImageResource(R.drawable.newmessages)
messageView.setTextColor(ContextCompat.getColor(context, R.color.vector_fuchsia_color))
messageView.text = SpannableString(resources.getQuantityString(R.plurals.room_new_messages_notification, state.unreadCount, state.unreadCount))
} else {
imageView.setImageResource(R.drawable.scrolldown)
messageView.setTextColor(ThemeUtils.getColor(context, R.attr.vctr_room_notification_text_color))
if (!TextUtils.isEmpty(state.message)) {
messageView.text = SpannableString(state.message)
}
}
messageView.setOnClickListener { delegate?.jumpToBottom() }
imageView.setOnClickListener { delegate?.jumpToBottom() }
}

private fun renderUnsent(state: State.UnsentEvents) {
visibility = View.VISIBLE
imageView.setImageResource(R.drawable.error)
val cancelAll = resources.getString(R.string.room_prompt_cancel)
val resendAll = resources.getString(R.string.room_prompt_resend)
val messageRes = if (state.hasUnknownDeviceEvents) R.string.room_unknown_devices_messages_notification else R.string.room_unsent_messages_notification
val message = context.getString(messageRes, resendAll, cancelAll)
val cancelAllPos = message.indexOf(cancelAll)
val resendAllPos = message.indexOf(resendAll)
val spannableString = SpannableString(message)
// cancelAllPos should always be > 0 but a GA crash reported here
if (cancelAllPos >= 0) {
spannableString.setSpan(CancelAllClickableSpan(), cancelAllPos, cancelAllPos + cancelAll.length, 0)
}

// resendAllPos should always be > 0 but a GA crash reported here
if (resendAllPos >= 0) {
spannableString.setSpan(ResendAllClickableSpan(), resendAllPos, resendAllPos + resendAll.length, 0)
}
messageView.movementMethod = LinkMovementMethod.getInstance()
messageView.setTextColor(ContextCompat.getColor(context, R.color.vector_fuchsia_color))
messageView.text = spannableString
}

private fun renderDefault() {
visibility = View.GONE
}

private fun renderHidden() {
visibility = View.GONE
}

/**
* Track the cancel all click.
*/
private inner class CancelAllClickableSpan : ClickableSpan() {
override fun onClick(widget: View) {
delegate?.deleteUnsentEvents()
render(state)
}

override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.color = ContextCompat.getColor(context, R.color.vector_fuchsia_color)
ds.bgColor = 0
ds.isUnderlineText = true
}
}

/**
* Track the resend all click.
*/
private inner class ResendAllClickableSpan : ClickableSpan() {
override fun onClick(widget: View) {
delegate?.resendUnsentEvents()
render(state)
}

override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.color = ContextCompat.getColor(context, R.color.vector_fuchsia_color)
ds.bgColor = 0
ds.isUnderlineText = true
}
}

/**
* The state representing the view
* It can take one state at a time
* Priority of state is managed in {@link VectorRoomActivity.refreshNotificationsArea() }
*/
sealed class State {

// Not yet rendered
object Initial : State()

// View will be Invisible
object Default : State()

// View will be Gone
object Hidden : State()

// Resource limit exceeded error will be displayed (only hard for the moment)
data class ResourceLimitExceededError(val isSoft: Boolean, val matrixError: MatrixError) : State()

// Server connection is lost
object ConnectionError : State()

// The room is dead
data class Tombstone(val tombstoneEvent: Event) : State()

// Somebody is typing
data class Typing(val message: String) : State()

// Some new messages are unread in preview
object UnreadPreview : State()

// Some new messages are unread (grey or red)
data class ScrollToBottom(val unreadCount: Int, val message: String? = null) : State()

// Some event has been unsent
data class UnsentEvents(val hasUndeliverableEvents: Boolean, val hasUnknownDeviceEvents: Boolean) : State()
}

/**
* An interface to delegate some actions to another object
*/
interface Delegate {
fun onTombstoneEventClicked(tombstoneEvent: Event)
fun resendUnsentEvents()
fun deleteUnsentEvents()
fun closeScreen()
fun jumpToBottom()
}

companion object {
/**
* Preference key.
*/
private const val SHOW_INFO_AREA_KEY = "SETTINGS_SHOW_INFO_AREA_KEY"

/**
* Always show the info area.
*/
private const val SHOW_INFO_AREA_VALUE_ALWAYS = "always"

/**
* Show the info area when it has messages or errors.
*/
private const val SHOW_INFO_AREA_VALUE_MESSAGES_AND_ERRORS = "messages_and_errors"

/**
* Show the info area only when it has errors.
*/
private const val SHOW_INFO_AREA_VALUE_ONLY_ERRORS = "only_errors"
}
}

View File

@ -144,7 +144,6 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable {

override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)

if (intent?.hasExtra(EXTRA_CLEAR_EXISTING_NOTIFICATION) == true) {
notificationDrawerManager.clearAllEvents()
intent.removeExtra(EXTRA_CLEAR_EXISTING_NOTIFICATION)
@ -193,7 +192,7 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable {
bugReporter.openBugReportScreen(this, false)
return true
}
R.id.menu_home_filter -> {
R.id.menu_home_filter -> {
navigator.openRoomsFiltering(this)
return true
}

View File

@ -29,6 +29,7 @@ import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.viewModel
import im.vector.matrix.android.api.session.room.failure.CreateRoomFailure
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.error.ErrorFormatter
@ -38,6 +39,7 @@ import im.vector.riotx.core.extensions.observeEvent
import im.vector.riotx.core.platform.SimpleFragmentActivity
import im.vector.riotx.core.platform.WaitingViewData
import kotlinx.android.synthetic.main.activity.*
import timber.log.Timber
import javax.inject.Inject

class CreateDirectRoomActivity : SimpleFragmentActivity() {
@ -91,10 +93,13 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {

private fun renderCreationFailure(error: Throwable) {
hideWaitingView()
AlertDialog.Builder(this)
.setMessage(errorFormatter.toHumanReadable(error))
.setPositiveButton(R.string.ok) { dialog, id -> dialog.cancel() }
.show()
if (error is CreateRoomFailure.CreatedWithTimeout) {
finish()
} else
AlertDialog.Builder(this)
.setMessage(errorFormatter.toHumanReadable(error))
.setPositiveButton(R.string.ok) { dialog, id -> dialog.cancel() }
.show()
}

private fun renderCreationSuccess(roomId: String?) {

View File

@ -17,6 +17,7 @@
package im.vector.riotx.features.home.room.detail

import com.jaiselrahman.filepicker.model.MediaFile
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.timeline.Timeline
@ -27,13 +28,14 @@ sealed class RoomDetailActions {
data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailActions()
data class SendMedia(val mediaFiles: List<MediaFile>) : RoomDetailActions()
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions()
data class LoadMoreTimelineEvents(val direction: Timeline.Direction) : RoomDetailActions()
data class SendReaction(val reaction: String, val targetEventId: String) : RoomDetailActions()
data class RedactAction(val targetEventId: String, val reason: String? = "") : RoomDetailActions()
data class UndoReaction(val targetEventId: String, val key: String, val reason: String? = "") : RoomDetailActions()
data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailActions()
data class NavigateToEvent(val eventId: String, val position: Int?) : RoomDetailActions()
data class DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : RoomDetailActions()
data class HandleTombstoneEvent(val event: Event): RoomDetailActions()
object AcceptInvite : RoomDetailActions()
object RejectInvite : RoomDetailActions()


View File

@ -29,6 +29,7 @@ import im.vector.riotx.R
import im.vector.riotx.core.extensions.replaceFragment
import im.vector.riotx.core.platform.ToolbarConfigurable
import im.vector.riotx.core.platform.VectorBaseActivity
import kotlinx.android.synthetic.main.merge_overlay_waiting_view.*

class RoomDetailActivity : VectorBaseActivity(), ToolbarConfigurable {

@ -38,6 +39,7 @@ class RoomDetailActivity : VectorBaseActivity(), ToolbarConfigurable {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
waitingView = waiting_view
if (isFirstCreation()) {
val roomDetailArgs: RoomDetailArgs = intent?.extras?.getParcelable(EXTRA_ROOM_DETAIL_ARGS)
?: return

View File

@ -44,8 +44,7 @@ import androidx.recyclerview.widget.RecyclerView
import butterknife.BindView
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.*
import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.ImageLoader
import com.google.android.material.snackbar.Snackbar
@ -57,6 +56,7 @@ import com.otaliastudios.autocomplete.AutocompleteCallback
import com.otaliastudios.autocomplete.CharPolicy
import im.vector.matrix.android.api.permalinks.PermalinkFactory
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.message.*
@ -75,6 +75,7 @@ import im.vector.riotx.core.extensions.observeEvent
import im.vector.riotx.core.extensions.setTextOrHide
import im.vector.riotx.core.files.addEntryToDownloadManager
import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.platform.NotificationAreaView
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.utils.*
import im.vector.riotx.features.autocomplete.command.AutocompleteCommandPresenter
@ -107,6 +108,7 @@ import im.vector.riotx.features.themes.ThemeUtils
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_room_detail.*
import kotlinx.android.synthetic.main.merge_composer_layout.view.*
import kotlinx.android.synthetic.main.merge_overlay_waiting_view.*
import org.commonmark.parser.Parser
import timber.log.Timber
import java.io.File
@ -204,6 +206,7 @@ class RoomDetailFragment :
setupComposer()
setupAttachmentButton()
setupInviteView()
setupNotificationView()
roomDetailViewModel.subscribe { renderState(it) }
textComposerViewModel.subscribe { renderTextComposerState(it) }
roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) }
@ -221,6 +224,10 @@ class RoomDetailFragment :
scrollOnHighlightedEventCallback.scheduleScrollTo(it)
}

roomDetailViewModel.selectSubscribe(this, RoomDetailViewState::tombstoneEventHandling, uniqueOnly("tombstoneEventHandling")) {
renderTombstoneEventHandling(it)
}

roomDetailViewModel.downloadedFileEvent.observeEvent(this) { downloadFileState ->
if (downloadFileState.throwable != null) {
requireActivity().toast(errorFormatter.toHumanReadable(downloadFileState.throwable))
@ -240,6 +247,31 @@ class RoomDetailFragment :
}
}

private fun setupNotificationView() {
notificationAreaView.delegate = object : NotificationAreaView.Delegate {

override fun onTombstoneEventClicked(tombstoneEvent: Event) {
roomDetailViewModel.process(RoomDetailActions.HandleTombstoneEvent(tombstoneEvent))
}

override fun resendUnsentEvents() {
vectorBaseActivity.notImplemented()
}

override fun deleteUnsentEvents() {
vectorBaseActivity.notImplemented()
}

override fun closeScreen() {
vectorBaseActivity.notImplemented()
}

override fun jumpToBottom() {
vectorBaseActivity.notImplemented()
}
}
}

override fun onPrepareOptionsMenu(menu: Menu) {
menu.forEach {
it.isVisible = roomDetailViewModel.isMenuItemVisible(it.itemId)
@ -266,7 +298,8 @@ class RoomDetailFragment :
composerLayout.collapse()
}

private fun enterSpecialMode(event: TimelineEvent, @DrawableRes iconRes: Int, useText: Boolean) {
private fun enterSpecialMode(event: TimelineEvent, @DrawableRes
iconRes: Int, useText: Boolean) {
commandAutocompletePolicy.enabled = false
//switch to expanded bar
composerLayout.composerRelatedMessageTitle.apply {
@ -280,17 +313,17 @@ class RoomDetailFragment :
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
val parser = Parser.builder().build()
val document = parser.parse(messageContent.formattedBody
?: messageContent.body)
?: messageContent.body)
formattedBody = eventHtmlRenderer.render(document)
}
composerLayout.composerRelatedMessageContent.text = formattedBody
?: nonFormattedBody
?: nonFormattedBody

composerLayout.composerEditText.setText(if (useText) event.getTextEditableContent() else "")
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))

avatarRenderer.render(event.senderAvatar, event.root.senderId
?: "", event.senderName, composerLayout.composerRelatedMessageAvatar)
?: "", event.senderName, composerLayout.composerRelatedMessageAvatar)

composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
composerLayout.expand {
@ -319,9 +352,9 @@ class RoomDetailFragment :
REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data)
REACTION_SELECT_REQUEST_CODE -> {
val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID)
?: return
?: return
val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT)
?: return
?: return
//TODO check if already reacted with that?
roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId))
}
@ -349,33 +382,33 @@ class RoomDetailFragment :

recyclerView.addOnScrollListener(
EndlessRecyclerViewScrollListener(layoutManager, RoomDetailViewModel.PAGINATION_COUNT) { direction ->
roomDetailViewModel.process(RoomDetailActions.LoadMore(direction))
roomDetailViewModel.process(RoomDetailActions.LoadMoreTimelineEvents(direction))
})
recyclerView.setController(timelineEventController)
timelineEventController.callback = this

if (VectorPreferences.swipeToReplyIsEnabled(requireContext())) {
val swipeCallback = RoomMessageTouchHelperCallback(requireContext(),
R.drawable.ic_reply,
object : RoomMessageTouchHelperCallback.QuickReplayHandler {
override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
(model as? AbsMessageItem)?.informationData?.let {
val eventId = it.eventId
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId))
}
}
R.drawable.ic_reply,
object : RoomMessageTouchHelperCallback.QuickReplayHandler {
override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
(model as? AbsMessageItem)?.informationData?.let {
val eventId = it.eventId
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId))
}
}

override fun canSwipeModel(model: EpoxyModel<*>): Boolean {
return when (model) {
is MessageFileItem,
is MessageImageVideoItem,
is MessageTextItem -> {
return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED
}
else -> false
}
}
})
override fun canSwipeModel(model: EpoxyModel<*>): Boolean {
return when (model) {
is MessageFileItem,
is MessageImageVideoItem,
is MessageTextItem -> {
return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED
}
else -> false
}
}
})
val touchHelper = ItemTouchHelper(swipeCallback)
touchHelper.attachToRecyclerView(recyclerView)
}
@ -541,7 +574,6 @@ class RoomDetailFragment :
if (summary?.membership == Membership.JOIN) {
timelineEventController.setTimeline(state.timeline, state.eventId)
inviteView.visibility = View.GONE

val uid = session.myUserId
val meMember = session.getRoom(state.roomId)?.getRoomMember(uid)
avatarRenderer.render(meMember?.avatarUrl, uid, meMember?.displayName, composerLayout.composerAvatarImageView)
@ -555,7 +587,14 @@ class RoomDetailFragment :
} else if (state.asyncInviter.complete) {
vectorBaseActivity.finish()
}
composerLayout.setRoomEncrypted(state.isEncrypted)
if (state.tombstoneEvent == null) {
composerLayout.visibility = View.VISIBLE
composerLayout.setRoomEncrypted(state.isEncrypted)
notificationAreaView.render(NotificationAreaView.State.Hidden)
} else {
composerLayout.visibility = View.GONE
notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneEvent))
}
}

private fun renderRoomSummary(state: RoomDetailViewState) {
@ -575,6 +614,26 @@ class RoomDetailFragment :
autocompleteUserPresenter.render(state.asyncUsers)
}

private fun renderTombstoneEventHandling(async: Async<String>) {
when (async) {
is Loading -> {
// TODO Better handling progress
vectorBaseActivity.showWaitingView()
vectorBaseActivity.waiting_view_status_text.visibility = View.VISIBLE
vectorBaseActivity.waiting_view_status_text.text = getString(R.string.joining_room)
}
is Success -> {
navigator.openRoom(vectorBaseActivity, async())
vectorBaseActivity.finish()
}
is Fail -> {
vectorBaseActivity.hideWaitingView()
vectorBaseActivity.toast(errorFormatter.toHumanReadable(async.error))
}
}
}


private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
when (sendMessageResult) {
is SendMessageResult.MessageSent,
@ -608,7 +667,7 @@ class RoomDetailFragment :
.show()
}

// TimelineEventController.Callback ************************************************************
// TimelineEventController.Callback ************************************************************

override fun onUrlClicked(url: String): Boolean {
return permalinkHandler.launch(requireActivity(), url, object : NavigateToRoomInterceptor {
@ -651,7 +710,7 @@ class RoomDetailFragment :
val intent = ImageMediaViewerActivity.newIntent(vectorBaseActivity, mediaData, ViewCompat.getTransitionName(view))
val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(
requireActivity(), view, ViewCompat.getTransitionName(view)
?: "").toBundle()
?: "").toBundle()
startActivity(intent, bundle)
}

@ -731,6 +790,16 @@ class RoomDetailFragment :
ViewEditHistoryBottomSheet.newInstance(roomDetailArgs.roomId, informationData)
.show(requireActivity().supportFragmentManager, "DISPLAY_EDITS")
}

override fun onRoomCreateLinkClicked(url: String) {
permalinkHandler.launch(requireContext(), url, object : NavigateToRoomInterceptor {
override fun navToRoom(roomId: String, eventId: String?): Boolean {
requireActivity().finish()
return false
}
})
}

// AutocompleteUserPresenter.Callback

override fun onQueryUsers(query: CharSequence?) {
@ -745,7 +814,7 @@ class RoomDetailFragment :
}
MessageMenuViewModel.ACTION_VIEW_REACTIONS -> {
val messageInformationData = actionData.data as? MessageInformationData
?: return
?: return
ViewReactionBottomSheet.newInstance(roomDetailArgs.roomId, messageInformationData)
.show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS")
}
@ -841,13 +910,13 @@ class RoomDetailFragment :
}
}

//utils
//utils
/**
* Insert an user displayname in the message editor.
*
* @param text the text to insert.
*/
//TODO legacy, refactor
//TODO legacy, refactor
private fun insertUserDisplayNameInTextEditor(text: String?) {
//TODO move logic outside of fragment
if (null != text) {
@ -898,7 +967,7 @@ class RoomDetailFragment :
snack.show()
}

// VectorInviteView.Callback
// VectorInviteView.Callback

override fun onAcceptInvite() {
notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId)

View File

@ -29,8 +29,10 @@ import com.jakewharton.rxrelay2.BehaviorRelay
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.MatrixPatterns
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.isImageMessage
import im.vector.matrix.android.api.session.events.model.isTextMessage
import im.vector.matrix.android.api.session.events.model.toModel
@ -39,6 +41,7 @@ import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
@ -100,7 +103,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
init {
observeRoomSummary()
observeEventDisplayedActions()
observeInvitationState()
observeSummaryState()
room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear()
timeline.start()
setState { copy(timeline = this@RoomDetailViewModel.timeline) }
@ -111,7 +114,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.SendMedia -> handleSendMedia(action)
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
is RoomDetailActions.LoadMore -> handleLoadMore(action)
is RoomDetailActions.LoadMoreTimelineEvents -> handleLoadMore(action)
is RoomDetailActions.SendReaction -> handleSendReaction(action)
is RoomDetailActions.AcceptInvite -> handleAcceptInvite()
is RoomDetailActions.RejectInvite -> handleRejectInvite()
@ -123,6 +126,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is RoomDetailActions.EnterReplyMode -> handleReplyAction(action)
is RoomDetailActions.DownloadFile -> handleDownloadFile(action)
is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action)
is RoomDetailActions.HandleTombstoneEvent -> handleTombstoneEvent(action)
is RoomDetailActions.ResendMessage -> handleResendEvent(action)
is RoomDetailActions.RemoveFailedEcho -> handleRemove(action)
is RoomDetailActions.ClearSendQueue -> handleClearSendQueue()
@ -131,6 +135,32 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
}

private fun handleTombstoneEvent(action: RoomDetailActions.HandleTombstoneEvent) {
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>()
?: return

val roomId = tombstoneContent.replacementRoom ?: ""
val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN
if (isRoomJoined) {
setState { copy(tombstoneEventHandling = Success(roomId)) }
} else {
val viaServer = MatrixPatterns.extractServerNameFromId(action.event.senderId).let {
if (it.isNullOrBlank()) {
emptyList()
} else {
listOf(it)
}
}
session.rx()
.joinRoom(roomId, viaServer)
.map { roomId }
.execute {
copy(tombstoneEventHandling = it)
}
}

}

private fun enterEditMode(event: TimelineEvent) {
setState {
copy(
@ -151,7 +181,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
val nonBlockingPopAlert: LiveData<LiveEvent<Pair<Int, List<Any>>>>
get() = _nonBlockingPopAlert


private val _sendMessageResultLiveData = MutableLiveData<LiveEvent<SendMessageResult>>()
val sendMessageResultLiveData: LiveData<LiveEvent<SendMessageResult>>
get() = _sendMessageResultLiveData
@ -263,12 +292,12 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} else {
val messageContent: MessageContent? =
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val existingBody = messageContent?.body ?: ""
if (existingBody != action.text) {
room.editTextMessage(state.sendMode.timelineEvent.root.eventId
?: "", messageContent?.type
?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown)
?: "", messageContent?.type
?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown)
} else {
Timber.w("Same message content, do not send edition")
}
@ -283,7 +312,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is SendMode.QUOTE -> {
val messageContent: MessageContent? =
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val textMsg = messageContent?.body

val finalText = legacyRiotQuoteText(textMsg, action.text)
@ -423,7 +452,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
}

private fun handleLoadMore(action: RoomDetailActions.LoadMore) {
private fun handleLoadMore(action: RoomDetailActions.LoadMoreTimelineEvents) {
timeline.paginate(action.direction, PAGINATION_COUNT)
}

@ -432,7 +461,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}

private fun handleAcceptInvite() {
room.join(object : MatrixCallback<Unit> {})
room.join(callback = object : MatrixCallback<Unit> {})
}

private fun handleEditAction(action: RoomDetailActions.EnterEditMode) {
@ -611,7 +640,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
}

private fun observeInvitationState() {
private fun observeSummaryState() {
asyncSubscribe(RoomDetailViewState::asyncRoomSummary) { summary ->
if (summary.membership == Membership.INVITE) {
summary.latestEvent?.root?.senderId?.let { senderId ->
@ -620,6 +649,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
setState { copy(asyncInviter = Success(it)) }
}
}
room.getStateEvent(EventType.STATE_ROOM_TOMBSTONE)?.also {
setState { copy(tombstoneEvent = it) }
}
}
}


View File

@ -19,7 +19,9 @@ package im.vector.riotx.features.home.room.detail
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.user.model.User
@ -46,7 +48,9 @@ data class RoomDetailViewState(
val asyncInviter: Async<User> = Uninitialized,
val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
val sendMode: SendMode = SendMode.REGULAR,
val isEncrypted: Boolean = false
val isEncrypted: Boolean = false,
val tombstoneEvent: Event? = null,
val tombstoneEventHandling: Async<String> = Uninitialized
) : MvRxState {

constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId)

View File

@ -54,6 +54,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim

interface Callback : ReactionPillCallback, AvatarCallback, BaseCallback, UrlClickCallback {
fun onEventVisible(event: TimelineEvent)
fun onRoomCreateLinkClicked(url: String)
fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View)
fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View)
fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View)
@ -158,7 +159,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim
synchronized(modelCache) {
for (i in 0 until modelCache.size) {
if (modelCache[i]?.eventId == eventIdToHighlight
|| modelCache[i]?.eventId == this.eventIdToHighlight) {
|| modelCache[i]?.eventId == this.eventIdToHighlight) {
modelCache[i] = null
}
}
@ -219,8 +220,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim
// Should be build if not cached or if cached but contains mergedHeader or formattedDay
// We then are sure we always have items up to date.
if (modelCache[position] == null
|| modelCache[position]?.mergedHeaderModel != null
|| modelCache[position]?.formattedDayModel != null) {
|| modelCache[position]?.mergedHeaderModel != null
|| modelCache[position]?.formattedDayModel != null) {
modelCache[position] = buildItemModels(position, currentSnapshot)
}
}
@ -294,7 +295,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim
// => handle case where paginating from mergeable events and we get more
val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull()
val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey)
?: true
?: true
val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState }
if (isCollapsed) {
collapsedEventIds.addAll(mergedEventIds)

View File

@ -0,0 +1,53 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.riotx.features.home.room.detail.timeline.factory

import im.vector.matrix.android.api.permalinks.PermalinkFactory
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotx.R
import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.item.RoomCreateItem
import im.vector.riotx.features.home.room.detail.timeline.item.RoomCreateItem_
import me.gujun.android.span.span
import javax.inject.Inject

class RoomCreateItemFactory @Inject constructor(private val colorProvider: ColorProvider,
private val stringProvider: StringProvider) {

fun create(event: TimelineEvent, callback: TimelineEventController.Callback?): RoomCreateItem? {
val createRoomContent = event.root.getClearContent().toModel<RoomCreateContent>()
?: return null
val predecessorId = createRoomContent.predecessor?.roomId ?: return null
val roomLink = PermalinkFactory.createPermalink(predecessorId) ?: return null
val text = span {
+stringProvider.getString(R.string.room_tombstone_continuation_description)
+"\n"
span(stringProvider.getString(R.string.room_tombstone_predecessor_link)) {
textDecorationLine = "underline"
onClick = { callback?.onRoomCreateLinkClicked(roomLink) }
}
}
return RoomCreateItem_()
.text(text)
}


}

View File

@ -33,6 +33,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
private val encryptedItemFactory: EncryptedItemFactory,
private val noticeItemFactory: NoticeItemFactory,
private val defaultItemFactory: DefaultItemFactory,
private val roomCreateItemFactory: RoomCreateItemFactory,
private val avatarRenderer: AvatarRenderer) {

fun create(event: TimelineEvent,
@ -45,6 +46,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
when (event.root.getClearType()) {
EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, callback)
// State and call
EventType.STATE_ROOM_TOMBSTONE,
EventType.STATE_ROOM_NAME,
EventType.STATE_ROOM_TOPIC,
EventType.STATE_ROOM_MEMBER,
@ -52,7 +54,8 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> noticeItemFactory.create(event, highlight, callback)

// State room create
EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(event, callback)
// Crypto
EventType.ENCRYPTION -> encryptionItemFactory.create(event, highlight, callback)
EventType.ENCRYPTED -> {
@ -66,8 +69,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me

// Unhandled event types (yet)
EventType.STATE_ROOM_THIRD_PARTY_INVITE,
EventType.STICKER,
EventType.STATE_ROOM_CREATE -> defaultItemFactory.create(event, highlight)
EventType.STICKER -> defaultItemFactory.create(event, highlight)

else -> {
//These are just for debug to display hidden event, they should be filtered out in normal mode

View File

@ -22,6 +22,7 @@ import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.*
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotx.R
import im.vector.riotx.core.resources.StringProvider
@ -37,6 +38,7 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin
EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName())
EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(timelineEvent.root, timelineEvent.senderName())
EventType.STATE_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName())
EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName())
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> formatCallEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName())
@ -56,6 +58,7 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> formatCallEvent(event, senderName)
EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(event, senderName)
else -> {
Timber.v("Type $type not handled by this formatter")
null
@ -72,6 +75,10 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin
}
}

private fun formatRoomTombstoneEvent(event: Event, senderName: String?): CharSequence? {
return stringProvider.getString(R.string.notice_room_update, senderName)
}

private fun formatRoomTopicEvent(event: Event, senderName: String?): CharSequence? {
val content = event.getClearContent().toModel<RoomTopicContent>() ?: return null
return if (content.topic.isNullOrEmpty()) {

View File

@ -39,7 +39,8 @@ object TimelineDisplayableEvents {
EventType.ENCRYPTION,
EventType.STATE_ROOM_THIRD_PARTY_INVITE,
EventType.STICKER,
EventType.STATE_ROOM_CREATE
EventType.STATE_ROOM_CREATE,
EventType.STATE_ROOM_TOMBSTONE
)

val DEBUG_DISPLAYABLE_TYPES = DISPLAYABLE_TYPES + listOf(

View File

@ -0,0 +1,40 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.riotx.features.home.room.detail.timeline.item

import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel
import me.saket.bettermovementmethod.BetterLinkMovementMethod

@EpoxyModelClass(layout = R.layout.item_timeline_event_create)
abstract class RoomCreateItem : VectorEpoxyModel<RoomCreateItem.Holder>() {

@EpoxyAttribute lateinit var text: CharSequence

override fun bind(holder: Holder) {
holder.description.movementMethod = BetterLinkMovementMethod.getInstance()
holder.description.text = text
}

class Holder : VectorEpoxyHolder() {
val description by bind<TextView>(R.id.roomCreateItemDescription)
}
}

View File

@ -135,7 +135,7 @@ class RoomListViewModel @AssistedInject constructor(@Assisted initialState: Room
)
}

session.getRoom(roomId)?.join(object : MatrixCallback<Unit> {
session.getRoom(roomId)?.join(emptyList(), object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
// We do not update the joiningRoomsIds here, because, the room is not joined yet regarding the sync data.
// Instead, we wait for the room to be joined

View File

@ -75,7 +75,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
private fun handleJoinRoom(roomId: String) {
activeSessionHolder.getSafeActiveSession()?.let { session ->
session.getRoom(roomId)
?.join(object : MatrixCallback<Unit> {})
?.join(emptyList(), object : MatrixCallback<Unit> {})
}
}


View File

@ -200,7 +200,7 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:
)
}

session.joinRoom(publicRoom.roomId, object : MatrixCallback<Unit> {
session.joinRoom(publicRoom.roomId, emptyList(), object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
// We do not update the joiningRoomsIds here, because, the room is not joined yet regarding the sync data.
// Instead, we wait for the room to be joined

View File

@ -90,7 +90,7 @@ class RoomPreviewViewModel @AssistedInject constructor(@Assisted initialState: R
)
}

session.joinRoom(state.roomId, object : MatrixCallback<Unit> {
session.joinRoom(state.roomId, emptyList(), object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
// We do not update the joiningRoomsIds here, because, the room is not joined yet regarding the sync data.
// Instead, we wait for the room to be joined

View File

@ -1,8 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/roomDetailContainer"
android:layout_width="match_parent"
android:layout_height="match_parent">

<FrameLayout
android:id="@+id/roomDetailContainer"
android:layout_width="match_parent"
android:layout_height="match_parent" />

<include layout="@layout/merge_overlay_waiting_view" />

</FrameLayout>

View File

@ -74,12 +74,18 @@
android:id="@+id/recyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/composerLayout"
app:layout_constraintBottom_toTopOf="@+id/recyclerViewBarrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomToolbar"
tools:listitem="@layout/item_timeline_event_base" />

<androidx.constraintlayout.widget.Barrier
android:id="@+id/recyclerViewBarrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="composerLayout,notificationAreaView" />

<im.vector.riotx.features.home.room.detail.composer.TextComposerView
android:id="@+id/composerLayout"
@ -89,6 +95,16 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />

<im.vector.riotx.core.platform.NotificationAreaView
android:id="@+id/notificationAreaView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:paddingBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />

<im.vector.riotx.features.invite.VectorInviteView
android:id="@+id/inviteView"
android:layout_width="0dp"
@ -101,4 +117,5 @@
app:layout_constraintTop_toBottomOf="@+id/roomToolbar"
tools:visibility="visible" />


</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<TextView
android:id="@+id/roomCreateItemDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:background="?attr/riotx_keys_backup_banner_accent_color"
android:drawableStart="@drawable/error"
android:drawablePadding="16dp"
android:gravity="center|start"
android:minHeight="80dp"
android:padding="16dp"
tools:text="This room is continuation…" />

</FrameLayout>

View File

@ -7,4 +7,6 @@
<string name="direct_room_no_known_users">"No result found, use Add by matrix ID to search on server."</string>
<string name="direct_room_start_search">"Start typing to get results"</string>
<string name="direct_room_filter_hint">"Filter by username or ID…"</string>

<string name="joining_room">"Joining room…"</string>
</resources>