Merge pull request #109 from vector-im/feature/timeline_formatting

This commit is contained in:
ganfra 2019-05-06 15:01:08 +02:00 committed by GitHub
commit ceac06caf6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1444 additions and 350 deletions

View File

@ -20,7 +20,7 @@ import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.InstrumentedTest import im.vector.matrix.android.InstrumentedTest
import im.vector.matrix.android.api.session.room.timeline.Timeline 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.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.session.room.members.RoomMemberExtractor import im.vector.matrix.android.internal.session.room.members.SenderRoomMemberExtractor
import im.vector.matrix.android.internal.session.room.timeline.DefaultTimeline import im.vector.matrix.android.internal.session.room.timeline.DefaultTimeline
import im.vector.matrix.android.internal.session.room.timeline.TimelineEventFactory import im.vector.matrix.android.internal.session.room.timeline.TimelineEventFactory
import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEventPersistor import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEventPersistor
@ -57,7 +57,7 @@ internal class TimelineTest : InstrumentedTest {
val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy) val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy)
val paginationTask = FakePaginationTask(tokenChunkEventPersistor) val paginationTask = FakePaginationTask(tokenChunkEventPersistor)
val getContextOfEventTask = FakeGetContextOfEventTask(tokenChunkEventPersistor) val getContextOfEventTask = FakeGetContextOfEventTask(tokenChunkEventPersistor)
val roomMemberExtractor = RoomMemberExtractor(ROOM_ID) val roomMemberExtractor = SenderRoomMemberExtractor(ROOM_ID)
val timelineEventFactory = TimelineEventFactory(roomMemberExtractor) val timelineEventFactory = TimelineEventFactory(roomMemberExtractor)
return DefaultTimeline(ROOM_ID, initialEventId, monarchy.realmConfiguration, taskExecutor, getContextOfEventTask, timelineEventFactory, paginationTask, null) return DefaultTimeline(ROOM_ID, initialEventId, monarchy.realmConfiguration, taskExecutor, getContextOfEventTask, timelineEventFactory, paginationTask, null)
} }

View File

@ -27,8 +27,8 @@ data class ContentAttachmentData(
val height: Long? = 0, val height: Long? = 0,
val width: Long? = 0, val width: Long? = 0,
val name: String? = null, val name: String? = null,
val path: String? = null, val path: String,
val mimeType: String? = null, val mimeType: String,
val type: Type val type: Type
) : Parcelable { ) : Parcelable {



View File

@ -18,9 +18,15 @@ package im.vector.matrix.android.api.session.content


interface ContentUploadStateTracker { interface ContentUploadStateTracker {


fun track(eventId: String, updateListener: UpdateListener) fun track(key: String, updateListener: UpdateListener)


fun untrack(eventId: String, updateListener: UpdateListener) fun untrack(key: String, updateListener: UpdateListener)

fun setFailure(key: String)

fun setSuccess(key: String)

fun setProgress(key: String, current: Long, total: Long)


interface UpdateListener { interface UpdateListener {
fun onUpdate(state: State) fun onUpdate(state: State)

View File

@ -29,7 +29,8 @@ data class TimelineEvent(
val root: Event, val root: Event,
val localId: String, val localId: String,
val displayIndex: Int, val displayIndex: Int,
val roomMember: RoomMember?, val senderName: String?,
val senderAvatar: String?,
val sendState: SendState val sendState: SendState
) { ) {



View File

@ -56,14 +56,10 @@ internal fun ChunkEntity.merge(roomId: String,
if (direction == PaginationDirection.FORWARDS) { if (direction == PaginationDirection.FORWARDS) {
this.nextToken = chunkToMerge.nextToken this.nextToken = chunkToMerge.nextToken
this.isLastForward = chunkToMerge.isLastForward this.isLastForward = chunkToMerge.isLastForward
this.forwardsStateIndex = chunkToMerge.forwardsStateIndex
this.forwardsDisplayIndex = chunkToMerge.forwardsDisplayIndex
eventsToMerge = chunkToMerge.events.sort(EventEntityFields.DISPLAY_INDEX, Sort.ASCENDING) eventsToMerge = chunkToMerge.events.sort(EventEntityFields.DISPLAY_INDEX, Sort.ASCENDING)
} else { } else {
this.prevToken = chunkToMerge.prevToken this.prevToken = chunkToMerge.prevToken
this.isLastBackward = chunkToMerge.isLastBackward this.isLastBackward = chunkToMerge.isLastBackward
this.backwardsStateIndex = chunkToMerge.backwardsStateIndex
this.backwardsDisplayIndex = chunkToMerge.backwardsDisplayIndex
eventsToMerge = chunkToMerge.events.sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) eventsToMerge = chunkToMerge.events.sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
} }
eventsToMerge.forEach { eventsToMerge.forEach {
@ -119,20 +115,20 @@ internal fun ChunkEntity.add(roomId: String,
this.displayIndex = currentDisplayIndex this.displayIndex = currentDisplayIndex
this.sendState = SendState.SYNCED this.sendState = SendState.SYNCED
} }
// We are not using the order of the list, but will be sorting with displayIndex field val position = if (direction == PaginationDirection.FORWARDS) 0 else this.events.size
events.add(eventEntity) events.add(position, eventEntity)
} }


internal fun ChunkEntity.lastDisplayIndex(direction: PaginationDirection, defaultValue: Int = 0): Int { internal fun ChunkEntity.lastDisplayIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) { return when (direction) {
PaginationDirection.FORWARDS -> forwardsDisplayIndex PaginationDirection.FORWARDS -> forwardsDisplayIndex
PaginationDirection.BACKWARDS -> backwardsDisplayIndex PaginationDirection.BACKWARDS -> backwardsDisplayIndex
} ?: defaultValue } ?: defaultValue
} }


internal fun ChunkEntity.lastStateIndex(direction: PaginationDirection, defaultValue: Int = 0): Int { internal fun ChunkEntity.lastStateIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) { return when (direction) {
PaginationDirection.FORWARDS -> forwardsStateIndex PaginationDirection.FORWARDS -> forwardsStateIndex
PaginationDirection.BACKWARDS -> backwardsStateIndex PaginationDirection.BACKWARDS -> backwardsStateIndex
} ?: defaultValue } ?: defaultValue
} }

View File

@ -31,7 +31,7 @@ internal class ContentModule {
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
ContentUploader(get(), get(), get<ContentUploadStateTracker>() as DefaultContentUploadStateTracker) FileUploader(get(), get())
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {

View File

@ -23,42 +23,42 @@ import im.vector.matrix.android.api.session.content.ContentUploadStateTracker
internal class DefaultContentUploadStateTracker : ContentUploadStateTracker { internal class DefaultContentUploadStateTracker : ContentUploadStateTracker {


private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val progressByEvent = mutableMapOf<String, ContentUploadStateTracker.State>() private val states = mutableMapOf<String, ContentUploadStateTracker.State>()
private val listenersByEvent = mutableMapOf<String, MutableList<ContentUploadStateTracker.UpdateListener>>() private val listeners = mutableMapOf<String, MutableList<ContentUploadStateTracker.UpdateListener>>()


override fun track(eventId: String, updateListener: ContentUploadStateTracker.UpdateListener) { override fun track(key: String, updateListener: ContentUploadStateTracker.UpdateListener) {
val listeners = listenersByEvent[eventId] ?: ArrayList() val listeners = listeners[key] ?: ArrayList()
listeners.add(updateListener) listeners.add(updateListener)
listenersByEvent[eventId] = listeners this.listeners[key] = listeners
val currentState = progressByEvent[eventId] ?: ContentUploadStateTracker.State.Idle val currentState = states[key] ?: ContentUploadStateTracker.State.Idle
mainHandler.post { updateListener.onUpdate(currentState) } mainHandler.post { updateListener.onUpdate(currentState) }
} }


override fun untrack(eventId: String, updateListener: ContentUploadStateTracker.UpdateListener) { override fun untrack(key: String, updateListener: ContentUploadStateTracker.UpdateListener) {
listenersByEvent[eventId]?.apply { listeners[key]?.apply {
remove(updateListener) remove(updateListener)
} }
} }


internal fun setFailure(eventId: String) { override fun setFailure(key: String) {
val failure = ContentUploadStateTracker.State.Failure val failure = ContentUploadStateTracker.State.Failure
updateState(eventId, failure) updateState(key, failure)
} }


internal fun setSuccess(eventId: String) { override fun setSuccess(key: String) {
val success = ContentUploadStateTracker.State.Success val success = ContentUploadStateTracker.State.Success
updateState(eventId, success) updateState(key, success)
} }


internal fun setProgress(eventId: String, current: Long, total: Long) { override fun setProgress(key: String, current: Long, total: Long) {
val progressData = ContentUploadStateTracker.State.ProgressData(current, total) val progressData = ContentUploadStateTracker.State.ProgressData(current, total)
updateState(eventId, progressData) updateState(key, progressData)
} }


private fun updateState(eventId: String, state: ContentUploadStateTracker.State) { private fun updateState(key: String, state: ContentUploadStateTracker.State) {
progressByEvent[eventId] = state states[key] = state
mainHandler.post { mainHandler.post {
listenersByEvent[eventId]?.also { listeners -> listeners[key]?.also { listeners ->
listeners.forEach { it.onUpdate(state) } listeners.forEach { it.onUpdate(state) }
} }
} }

View File

@ -19,7 +19,6 @@ package im.vector.matrix.android.internal.session.content
import arrow.core.Try import arrow.core.Try
import arrow.core.Try.Companion.raise import arrow.core.Try.Companion.raise
import im.vector.matrix.android.api.auth.data.SessionParams import im.vector.matrix.android.api.auth.data.SessionParams
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.internal.di.MoshiProvider import im.vector.matrix.android.internal.di.MoshiProvider
import im.vector.matrix.android.internal.network.ProgressRequestBody import im.vector.matrix.android.internal.network.ProgressRequestBody
import okhttp3.HttpUrl import okhttp3.HttpUrl
@ -31,44 +30,51 @@ import java.io.File
import java.io.IOException import java.io.IOException




internal class ContentUploader(private val okHttpClient: OkHttpClient, internal class FileUploader(private val okHttpClient: OkHttpClient,
private val sessionParams: SessionParams, private val sessionParams: SessionParams) {
private val contentUploadProgressTracker: DefaultContentUploadStateTracker) {
private val uploadUrl = sessionParams.homeServerConnectionConfig.homeServerUri.toString() + URI_PREFIX_CONTENT_API + "upload"


private val moshi = MoshiProvider.providesMoshi() private val moshi = MoshiProvider.providesMoshi()
private val responseAdapter = moshi.adapter(ContentUploadResponse::class.java) private val responseAdapter = moshi.adapter(ContentUploadResponse::class.java)


fun uploadFile(eventId: String, attachment: ContentAttachmentData): Try<ContentUploadResponse> {
if (attachment.path == null || attachment.mimeType == null) {
return raise(RuntimeException())
}
val file = File(attachment.path)
val urlString = sessionParams.homeServerConnectionConfig.homeServerUri.toString() + URI_PREFIX_CONTENT_API + "upload"


val urlBuilder = HttpUrl.parse(urlString)?.newBuilder() fun uploadFile(file: File,
?: return raise(RuntimeException()) filename: String?,
mimeType: String,
progressListener: ProgressRequestBody.Listener? = null): Try<ContentUploadResponse> {

val uploadBody = RequestBody.create(MediaType.parse(mimeType), file)
return upload(uploadBody, filename, progressListener)

}

fun uploadByteArray(byteArray: ByteArray,
filename: String?,
mimeType: String,
progressListener: ProgressRequestBody.Listener? = null): Try<ContentUploadResponse> {

val uploadBody = RequestBody.create(MediaType.parse(mimeType), byteArray)
return upload(uploadBody, filename, progressListener)

}


private fun upload(uploadBody: RequestBody, filename: String?, progressListener: ProgressRequestBody.Listener?): Try<ContentUploadResponse> {
val urlBuilder = HttpUrl.parse(uploadUrl)?.newBuilder() ?: return raise(RuntimeException())


val httpUrl = urlBuilder val httpUrl = urlBuilder
.addQueryParameter( .addQueryParameter("filename", filename)
"filename", attachment.name .build()
).build()


val requestBody = RequestBody.create( val requestBody = if (progressListener != null) ProgressRequestBody(uploadBody, progressListener) else uploadBody
MediaType.parse(attachment.mimeType),
file
)
val progressRequestBody = ProgressRequestBody(requestBody, object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
contentUploadProgressTracker.setProgress(eventId, current, total)
}
})


val request = Request.Builder() val request = Request.Builder()
.url(httpUrl) .url(httpUrl)
.post(progressRequestBody) .post(requestBody)
.build() .build()


val result = Try { return Try {
okHttpClient.newCall(request).execute().use { response -> okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw IOException() throw IOException()
@ -80,11 +86,7 @@ internal class ContentUploader(private val okHttpClient: OkHttpClient,
} }
} }
} }
if (result.isFailure()) {
contentUploadProgressTracker.setFailure(eventId)
} else {
contentUploadProgressTracker.setSuccess(eventId)
}
return result
} }

} }

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.matrix.android.internal.session.content

import android.graphics.Bitmap
import android.media.ThumbnailUtils
import android.provider.MediaStore
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import java.io.ByteArrayOutputStream
import java.io.File

internal object ThumbnailExtractor {

class ThumbnailData(
val width: Int,
val height: Int,
val size: Long,
val bytes: ByteArray,
val mimeType: String
)

fun extractThumbnail(attachment: ContentAttachmentData): ThumbnailData? {
val file = File(attachment.path)
if (!file.exists() || !file.isFile) {
return null
}
return if (attachment.type == ContentAttachmentData.Type.VIDEO) {
extractVideoThumbnail(attachment)
} else {
null
}
}

private fun extractVideoThumbnail(attachment: ContentAttachmentData): ThumbnailData? {
val thumbnail = ThumbnailUtils.createVideoThumbnail(attachment.path, MediaStore.Video.Thumbnails.MINI_KIND)
val outputStream = ByteArrayOutputStream()
thumbnail.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
val thumbnailWidth = thumbnail.width
val thumbnailHeight = thumbnail.height
val thumbnailSize = outputStream.size()
val thumbnailData = ThumbnailData(
width = thumbnailWidth,
height = thumbnailHeight,
size = thumbnailSize.toLong(),
bytes = outputStream.toByteArray(),
mimeType = "image/jpeg"
)
thumbnail.recycle()
outputStream.reset()
return thumbnailData
}


}

View File

@ -21,20 +21,29 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.session.content.ContentAttachmentData import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.content.ContentUploadStateTracker
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.toContent import im.vector.matrix.android.api.session.events.model.toContent
import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.internal.di.MatrixKoinComponent import im.vector.matrix.android.internal.di.MatrixKoinComponent
import im.vector.matrix.android.internal.network.ProgressRequestBody
import im.vector.matrix.android.internal.session.room.send.SendEventWorker import im.vector.matrix.android.internal.session.room.send.SendEventWorker
import im.vector.matrix.android.internal.util.WorkerParamsFactory import im.vector.matrix.android.internal.util.WorkerParamsFactory
import org.koin.standalone.inject import org.koin.standalone.inject
import timber.log.Timber
import java.io.File



internal class UploadContentWorker(context: Context, params: WorkerParameters) internal class UploadContentWorker(context: Context, params: WorkerParameters)
: CoroutineWorker(context, params), MatrixKoinComponent { : CoroutineWorker(context, params), MatrixKoinComponent {


private val mediaUploader by inject<ContentUploader>() private val fileUploader by inject<FileUploader>()
private val contentUploadProgressTracker by inject<ContentUploadStateTracker>()


@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
internal data class Params( internal data class Params(
@ -47,28 +56,65 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters)
val params = WorkerParamsFactory.fromData<Params>(inputData) val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.failure() ?: return Result.failure()


if (params.event.eventId == null) { val eventId = params.event.eventId ?: return Result.failure()
return Result.failure() val attachment = params.attachment

val thumbnailData = ThumbnailExtractor.extractThumbnail(params.attachment)
val attachmentFile = createAttachmentFile(attachment) ?: return Result.failure()
var uploadedThumbnailUrl: String? = null

if (thumbnailData != null) {
fileUploader
.uploadByteArray(thumbnailData.bytes, "thumb_${attachment.name}", thumbnailData.mimeType)
.fold(
{ Timber.e(it) },
{ uploadedThumbnailUrl = it.contentUri }
)
} }
return mediaUploader
.uploadFile(params.event.eventId, params.attachment) val progressListener = object : ProgressRequestBody.Listener {
.fold({ handleFailure() }, { handleSuccess(params, it) }) override fun onProgress(current: Long, total: Long) {
contentUploadProgressTracker.setProgress(eventId, current, total)
}
}
return fileUploader
.uploadFile(attachmentFile, attachment.name, attachment.mimeType, progressListener)
.fold(
{ handleFailure(params) },
{ handleSuccess(params, it.contentUri, uploadedThumbnailUrl) }
)
} }


private fun handleFailure(): Result { private fun createAttachmentFile(attachment: ContentAttachmentData): File? {
return Result.retry() return try {
File(attachment.path)
} catch (e: Exception) {
Timber.e(e)
null
}
} }


private fun handleSuccess(params: Params, contentUploadResponse: ContentUploadResponse): Result { private fun handleFailure(params: Params): Result {
val event = updateEvent(params.event, contentUploadResponse.contentUri) contentUploadProgressTracker.setFailure(params.event.eventId!!)
return Result.failure()
}

private fun handleSuccess(params: Params,
attachmentUrl: String,
thumbnailUrl: String?): Result {
contentUploadProgressTracker.setFailure(params.event.eventId!!)
val event = updateEvent(params.event, attachmentUrl, thumbnailUrl)
val sendParams = SendEventWorker.Params(params.roomId, event) val sendParams = SendEventWorker.Params(params.roomId, event)
return Result.success(WorkerParamsFactory.toData(sendParams)) return Result.success(WorkerParamsFactory.toData(sendParams))
} }


private fun updateEvent(event: Event, url: String): Event { private fun updateEvent(event: Event, url: String, thumbnailUrl: String? = null): Event {
val messageContent: MessageContent = event.content.toModel() ?: return event val messageContent: MessageContent = event.content.toModel() ?: return event
val updatedContent = when (messageContent) { val updatedContent = when (messageContent) {
is MessageImageContent -> messageContent.update(url) is MessageImageContent -> messageContent.update(url)
is MessageVideoContent -> messageContent.update(url, thumbnailUrl)
is MessageFileContent -> messageContent.update(url)
is MessageAudioContent -> messageContent.update(url)
else -> messageContent else -> messageContent
} }
return event.copy(content = updatedContent.toContent()) return event.copy(content = updatedContent.toContent())
@ -78,6 +124,18 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters)
return copy(url = url) return copy(url = url)
} }


private fun MessageVideoContent.update(url: String, thumbnailUrl: String?): MessageVideoContent {
return copy(url = url, info = info?.copy(thumbnailUrl = thumbnailUrl))
}

private fun MessageFileContent.update(url: String): MessageFileContent {
return copy(url = url)
}

private fun MessageAudioContent.update(url: String): MessageAudioContent {
return copy(url = url)
}



} }



View File

@ -21,7 +21,7 @@ import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.internal.session.room.invite.InviteTask import im.vector.matrix.android.internal.session.room.invite.InviteTask
import im.vector.matrix.android.internal.session.room.members.DefaultRoomMembersService import im.vector.matrix.android.internal.session.room.members.DefaultRoomMembersService
import im.vector.matrix.android.internal.session.room.members.LoadRoomMembersTask import im.vector.matrix.android.internal.session.room.members.LoadRoomMembersTask
import im.vector.matrix.android.internal.session.room.members.RoomMemberExtractor import im.vector.matrix.android.internal.session.room.members.SenderRoomMemberExtractor
import im.vector.matrix.android.internal.session.room.read.DefaultReadService import im.vector.matrix.android.internal.session.room.read.DefaultReadService
import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask
import im.vector.matrix.android.internal.session.room.send.DefaultSendService import im.vector.matrix.android.internal.session.room.send.DefaultSendService
@ -45,7 +45,7 @@ internal class RoomFactory(private val loadRoomMembersTask: LoadRoomMembersTask,
private val taskExecutor: TaskExecutor) { private val taskExecutor: TaskExecutor) {


fun instantiate(roomId: String): Room { fun instantiate(roomId: String): Room {
val roomMemberExtractor = RoomMemberExtractor(roomId) val roomMemberExtractor = SenderRoomMemberExtractor(roomId)
val timelineEventFactory = TimelineEventFactory(roomMemberExtractor) val timelineEventFactory = TimelineEventFactory(roomMemberExtractor)
val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask) val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask)
val sendService = DefaultSendService(roomId, eventFactory, monarchy) val sendService = DefaultSendService(roomId, eventFactory, monarchy)

View File

@ -48,6 +48,18 @@ internal class RoomMembers(private val realm: Realm,
} }
} }


fun isUniqueDisplayName(displayName: String?): Boolean {
if(displayName.isNullOrEmpty()){
return true
}
return EventEntity
.where(realm, roomId, EventType.STATE_ROOM_MEMBER)
.contains(EventEntityFields.CONTENT, displayName)
.distinct(EventEntityFields.STATE_KEY)
.findAll()
.size == 1
}

fun queryRoomMembersEvent(): RealmQuery<EventEntity> { fun queryRoomMembersEvent(): RealmQuery<EventEntity> {
return EventEntity return EventEntity
.where(realm, roomId, EventType.STATE_ROOM_MEMBER) .where(realm, roomId, EventType.STATE_ROOM_MEMBER)

View File

@ -20,48 +20,45 @@ 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.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomMember import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.internal.database.mapper.ContentMapper import im.vector.matrix.android.internal.database.mapper.ContentMapper
import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.EventEntityFields import im.vector.matrix.android.internal.database.model.EventEntityFields
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.query.findIncludingEvent
import im.vector.matrix.android.internal.database.query.next import im.vector.matrix.android.internal.database.query.next
import im.vector.matrix.android.internal.database.query.prev import im.vector.matrix.android.internal.database.query.prev
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import io.realm.Realm import io.realm.RealmList
import io.realm.RealmQuery import io.realm.RealmQuery


internal class RoomMemberExtractor(private val roomId: String) { internal class SenderRoomMemberExtractor(private val roomId: String) {

private val cached = HashMap<String, RoomMember?>()


fun extractFrom(event: EventEntity): RoomMember? { fun extractFrom(event: EventEntity): RoomMember? {
val sender = event.sender ?: return null val sender = event.sender ?: return null
val cacheKey = sender + event.stateIndex
if (cached.containsKey(cacheKey)) {
return cached[cacheKey]
}
// If the event is unlinked we want to fetch unlinked state events // If the event is unlinked we want to fetch unlinked state events
val unlinked = event.isUnlinked val unlinked = event.isUnlinked
// When stateIndex is negative, we try to get the next stateEvent prevContent() val roomEntity = RoomEntity.where(event.realm, roomId = roomId).findFirst() ?: return null
// If prevContent is null we fallback to the Int.MIN state events content() val chunkEntity = ChunkEntity.findIncludingEvent(event.realm, event.eventId)
val content = if (event.stateIndex <= 0) { val content = when {
baseQuery(event.realm, roomId, sender, unlinked).next(from = event.stateIndex)?.prevContent chunkEntity == null -> null
?: baseQuery(event.realm, roomId, sender, unlinked).prev(since = event.stateIndex)?.content event.stateIndex <= 0 -> baseQuery(chunkEntity.events, sender, unlinked).next(from = event.stateIndex)?.prevContent
} else { else -> baseQuery(chunkEntity.events, sender, unlinked).prev(since = event.stateIndex)?.content
baseQuery(event.realm, roomId, sender, unlinked).prev(since = event.stateIndex)?.content
} }
val roomMember: RoomMember? = ContentMapper.map(content).toModel()
cached[cacheKey] = roomMember val fallbackContent = content
return roomMember ?: baseQuery(roomEntity.untimelinedStateEvents, sender, unlinked).prev(since = event.stateIndex)?.content

return ContentMapper.map(fallbackContent).toModel()
} }


private fun baseQuery(realm: Realm, private fun baseQuery(list: RealmList<EventEntity>,
roomId: String,
sender: String, sender: String,
isUnlinked: Boolean): RealmQuery<EventEntity> { isUnlinked: Boolean): RealmQuery<EventEntity> {

return list
val filterMode = if (isUnlinked) EventEntity.LinkFilterMode.UNLINKED_ONLY else EventEntity.LinkFilterMode.LINKED_ONLY .where()
return EventEntity
.where(realm, roomId = roomId, type = EventType.STATE_ROOM_MEMBER, linkFilterMode = filterMode)
.equalTo(EventEntityFields.STATE_KEY, sender) .equalTo(EventEntityFields.STATE_KEY, sender)
.equalTo(EventEntityFields.TYPE, EventType.STATE_ROOM_MEMBER)
.equalTo(EventEntityFields.IS_UNLINKED, isUnlinked)
} }


} }

View File

@ -16,6 +16,7 @@


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


import android.media.MediaMetadataRetriever
import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.content.ContentAttachmentData import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
@ -30,7 +31,9 @@ import im.vector.matrix.android.api.session.room.model.message.MessageImageConte
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.model.message.ThumbnailInfo
import im.vector.matrix.android.api.session.room.model.message.VideoInfo import im.vector.matrix.android.api.session.room.model.message.VideoInfo
import im.vector.matrix.android.internal.session.content.ThumbnailExtractor


internal class LocalEchoEventFactory(private val credentials: Credentials) { internal class LocalEchoEventFactory(private val credentials: Credentials) {


@ -53,7 +56,7 @@ internal class LocalEchoEventFactory(private val credentials: Credentials) {
type = MessageType.MSGTYPE_IMAGE, type = MessageType.MSGTYPE_IMAGE,
body = attachment.name ?: "image", body = attachment.name ?: "image",
info = ImageInfo( info = ImageInfo(
mimeType = attachment.mimeType ?: "image/png", mimeType = attachment.mimeType,
width = attachment.width?.toInt() ?: 0, width = attachment.width?.toInt() ?: 0,
height = attachment.height?.toInt() ?: 0, height = attachment.height?.toInt() ?: 0,
size = attachment.size.toInt() size = attachment.size.toInt()
@ -64,15 +67,35 @@ internal class LocalEchoEventFactory(private val credentials: Credentials) {
} }


private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData): Event { private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData): Event {
val mediaDataRetriever = MediaMetadataRetriever()
mediaDataRetriever.setDataSource(attachment.path)

// Use frame to calculate height and width as we are sure to get the right ones
val firstFrame = mediaDataRetriever.frameAtTime
val height = firstFrame.height
val width = firstFrame.width
mediaDataRetriever.release()

val thumbnailInfo = ThumbnailExtractor.extractThumbnail(attachment)?.let {
ThumbnailInfo(
width = it.width,
height = it.height,
size = it.size,
mimeType = it.mimeType
)
}
val content = MessageVideoContent( val content = MessageVideoContent(
type = MessageType.MSGTYPE_VIDEO, type = MessageType.MSGTYPE_VIDEO,
body = attachment.name ?: "video", body = attachment.name ?: "video",
info = VideoInfo( info = VideoInfo(
mimeType = attachment.mimeType ?: "video/mpeg", mimeType = attachment.mimeType,
width = attachment.width?.toInt() ?: 0, width = width,
height = attachment.height?.toInt() ?: 0, height = height,
size = attachment.size, size = attachment.size,
duration = attachment.duration?.toInt() ?: 0 duration = attachment.duration?.toInt() ?: 0,
// Glide will be able to use the local path and extract a thumbnail.
thumbnailUrl = attachment.path,
thumbnailInfo = thumbnailInfo
), ),
url = attachment.path url = attachment.path
) )

View File

@ -27,14 +27,24 @@ 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.room.timeline.TimelineEvent
import im.vector.matrix.android.api.util.CancelableBag import im.vector.matrix.android.api.util.CancelableBag
import im.vector.matrix.android.api.util.addTo import im.vector.matrix.android.api.util.addTo
import im.vector.matrix.android.internal.database.model.* import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.ChunkEntityFields
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.EventEntityFields
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.query.findIncludingEvent import im.vector.matrix.android.internal.database.query.findIncludingEvent
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.util.Debouncer import im.vector.matrix.android.internal.util.Debouncer
import io.realm.* import io.realm.OrderedCollectionChangeSet
import io.realm.OrderedRealmCollectionChangeListener
import io.realm.Realm
import io.realm.RealmConfiguration
import io.realm.RealmQuery
import io.realm.RealmResults
import io.realm.Sort
import timber.log.Timber import timber.log.Timber
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -87,9 +97,16 @@ internal class DefaultTimeline(




private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<EventEntity>> { _, changeSet -> private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<EventEntity>> { _, changeSet ->
if (changeSet.state == OrderedCollectionChangeSet.State.INITIAL) { if (changeSet.state == OrderedCollectionChangeSet.State.INITIAL ) {
handleInitialLoad() handleInitialLoad()
} else { } else {
// If changeSet has deletion we are having a gap, so we clear everything
if(changeSet.deletionRanges.isNotEmpty()){
prevDisplayIndex = DISPLAY_INDEX_UNKNOWN
nextDisplayIndex = DISPLAY_INDEX_UNKNOWN
builtEvents.clear()
timelineEventFactory.clear()
}
changeSet.insertionRanges.forEach { range -> changeSet.insertionRanges.forEach { range ->
val (startDisplayIndex, direction) = if (range.startIndex == 0) { val (startDisplayIndex, direction) = if (range.startIndex == 0) {
Pair(liveEvents[range.length - 1]!!.displayIndex, Timeline.Direction.FORWARDS) Pair(liveEvents[range.length - 1]!!.displayIndex, Timeline.Direction.FORWARDS)
@ -108,6 +125,7 @@ internal class DefaultTimeline(
buildTimelineEvents(startDisplayIndex, direction, range.length.toLong()) buildTimelineEvents(startDisplayIndex, direction, range.length.toLong())
postSnapshot() postSnapshot()
} }

} }
} }
} }
@ -298,9 +316,9 @@ internal class DefaultTimeline(
private fun executePaginationTask(direction: Timeline.Direction, limit: Int) { private fun executePaginationTask(direction: Timeline.Direction, limit: Int) {
val token = getTokenLive(direction) ?: return val token = getTokenLive(direction) ?: return
val params = PaginationTask.Params(roomId = roomId, val params = PaginationTask.Params(roomId = roomId,
from = token, from = token,
direction = direction.toPaginationDirection(), direction = direction.toPaginationDirection(),
limit = limit) limit = limit)


Timber.v("Should fetch $limit items $direction") Timber.v("Should fetch $limit items $direction")
paginationTask.configureWith(params) paginationTask.configureWith(params)

View File

@ -19,19 +19,36 @@ package im.vector.matrix.android.internal.session.room.timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.database.mapper.asDomain 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.EventEntity
import im.vector.matrix.android.internal.session.room.members.RoomMemberExtractor import im.vector.matrix.android.internal.session.room.members.SenderRoomMemberExtractor


internal class TimelineEventFactory(private val roomMemberExtractor: RoomMemberExtractor) { internal class TimelineEventFactory(private val roomMemberExtractor: SenderRoomMemberExtractor) {

private val cached = mutableMapOf<String, SenderData>()


fun create(eventEntity: EventEntity): TimelineEvent { fun create(eventEntity: EventEntity): TimelineEvent {
val roomMember = roomMemberExtractor.extractFrom(eventEntity) val sender = eventEntity.sender
val cacheKey = sender + eventEntity.stateIndex
val senderData = cached.getOrPut(cacheKey) {
val senderRoomMember = roomMemberExtractor.extractFrom(eventEntity)
SenderData(senderRoomMember?.displayName, senderRoomMember?.avatarUrl)
}
return TimelineEvent( return TimelineEvent(
eventEntity.asDomain(), eventEntity.asDomain(),
eventEntity.localId, eventEntity.localId,
eventEntity.displayIndex, eventEntity.displayIndex,
roomMember, senderData.senderName,
senderData.senderAvatar,
eventEntity.sendState eventEntity.sendState
) )
} }


fun clear(){
cached.clear()
}

private data class SenderData(
val senderName: String?,
val senderAvatar: String?
)

} }

View File

@ -187,6 +187,7 @@ dependencies {
implementation "com.github.piasy:GlideImageViewFactory:$big_image_viewer_version" implementation "com.github.piasy:GlideImageViewFactory:$big_image_viewer_version"
implementation "com.github.bumptech.glide:glide:$glide_version" implementation "com.github.bumptech.glide:glide:$glide_version"
kapt "com.github.bumptech.glide:compiler:$glide_version" kapt "com.github.bumptech.glide:compiler:$glide_version"
implementation 'com.danikula:videocache:2.7.1'


// Badge for compatibility // Badge for compatibility
implementation 'me.leolin:ShortcutBadger:1.1.2@aar' implementation 'me.leolin:ShortcutBadger:1.1.2@aar'

View File

@ -29,7 +29,7 @@


<activity android:name=".features.home.HomeActivity" /> <activity android:name=".features.home.HomeActivity" />
<activity android:name=".features.login.LoginActivity" /> <activity android:name=".features.login.LoginActivity" />
<activity android:name=".features.media.MediaViewerActivity" /> <activity android:name=".features.media.ImageMediaViewerActivity" />
<activity <activity
android:name=".features.rageshake.BugReportActivity" android:name=".features.rageshake.BugReportActivity"
android:label="@string/title_activity_bug_report" /> android:label="@string/title_activity_bug_report" />
@ -37,6 +37,7 @@
android:name=".features.settings.VectorSettingsActivity" android:name=".features.settings.VectorSettingsActivity"
android:label="@string/title_activity_settings" android:label="@string/title_activity_settings"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity android:name=".features.media.VideoMediaViewerActivity" />


<service <service
android:name=".core.services.CallService" android:name=".core.services.CallService"

View File

@ -32,6 +32,7 @@ import com.bumptech.glide.util.Util
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import im.vector.riotredesign.BuildConfig import im.vector.riotredesign.BuildConfig
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.core.utils.toast
import im.vector.riotredesign.features.rageshake.BugReportActivity import im.vector.riotredesign.features.rageshake.BugReportActivity
import im.vector.riotredesign.features.rageshake.BugReporter import im.vector.riotredesign.features.rageshake.BugReporter
import im.vector.riotredesign.features.rageshake.RageShake import im.vector.riotredesign.features.rageshake.RageShake
@ -284,9 +285,9 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
* PUBLIC METHODS * PUBLIC METHODS
* ========================================================================================== */ * ========================================================================================== */


protected fun showSnackbar(message: String) { fun showSnackbar(message: String) {
coordinatorLayout?.let { coordinatorLayout?.let {
Snackbar.make(it, message, Snackbar.LENGTH_SHORT) Snackbar.make(it, message, Snackbar.LENGTH_SHORT).show()
} }
} }


@ -294,8 +295,8 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
* Temporary method * Temporary method
* ========================================================================================== */ * ========================================================================================== */


protected fun notImplemented() { fun notImplemented() {
showSnackbar(getString(R.string.not_implemented)) toast(getString(R.string.not_implemented))
} }


} }

View File

@ -31,6 +31,7 @@ class AutocompleteUserController : TypedEpoxyController<List<User>>() {
data.forEach { user -> data.forEach { user ->
autocompleteUserItem { autocompleteUserItem {
id(user.userId) id(user.userId)
userId(user.userId)
name(user.displayName) name(user.displayName)
avatarUrl(user.avatarUrl) avatarUrl(user.avatarUrl)
clickListener { _ -> clickListener { _ ->

View File

@ -29,18 +29,15 @@ import im.vector.riotredesign.features.home.AvatarRenderer
@EpoxyModelClass(layout = R.layout.item_autocomplete_user) @EpoxyModelClass(layout = R.layout.item_autocomplete_user)
abstract class AutocompleteUserItem : VectorEpoxyModel<AutocompleteUserItem.Holder>() { abstract class AutocompleteUserItem : VectorEpoxyModel<AutocompleteUserItem.Holder>() {


@EpoxyAttribute @EpoxyAttribute var name: String? = null
var name: String? = null @EpoxyAttribute var userId: String = ""
@EpoxyAttribute @EpoxyAttribute var avatarUrl: String? = null
var avatarUrl: String? = null @EpoxyAttribute var clickListener: View.OnClickListener? = null
@EpoxyAttribute
var clickListener: View.OnClickListener? = null


override fun bind(holder: Holder) { override fun bind(holder: Holder) {
holder.view.setOnClickListener(clickListener) holder.view.setOnClickListener(clickListener)

holder.nameView.text = name holder.nameView.text = name
AvatarRenderer.render(avatarUrl, name, holder.avatarImageView) AvatarRenderer.render(avatarUrl, userId, name, holder.avatarImageView)
} }


class Holder : VectorEpoxyHolder() { class Holder : VectorEpoxyHolder() {

View File

@ -29,7 +29,6 @@ import com.bumptech.glide.request.target.Target
import im.vector.matrix.android.api.Matrix import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.MatrixPatterns import im.vector.matrix.android.api.MatrixPatterns
import im.vector.matrix.android.api.session.content.ContentUrlResolver import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.core.glide.GlideApp import im.vector.riotredesign.core.glide.GlideApp
@ -44,39 +43,41 @@ object AvatarRenderer {


private const val THUMBNAIL_SIZE = 250 private const val THUMBNAIL_SIZE = 250


@UiThread private val AVATAR_COLOR_LIST = listOf(
fun render(roomMember: RoomMember, imageView: ImageView) { R.color.avatar_color_1,
render(roomMember.avatarUrl, roomMember.displayName, imageView) R.color.avatar_color_2,
} R.color.avatar_color_3
)


@UiThread @UiThread
fun render(roomSummary: RoomSummary, imageView: ImageView) { fun render(roomSummary: RoomSummary, imageView: ImageView) {
render(roomSummary.avatarUrl, roomSummary.displayName, imageView) render(roomSummary.avatarUrl, roomSummary.roomId, roomSummary.displayName, imageView)
} }


@UiThread @UiThread
fun render(avatarUrl: String?, name: String?, imageView: ImageView) { fun render(avatarUrl: String?, identifier: String, name: String?, imageView: ImageView) {
render(imageView.context, GlideApp.with(imageView), avatarUrl, name, DrawableImageViewTarget(imageView)) render(imageView.context, GlideApp.with(imageView), avatarUrl, identifier, name, DrawableImageViewTarget(imageView))
} }


@UiThread @UiThread
fun render(context: Context, fun render(context: Context,
glideRequest: GlideRequests, glideRequest: GlideRequests,
avatarUrl: String?, avatarUrl: String?,
identifier: String,
name: String?, name: String?,
target: Target<Drawable>) { target: Target<Drawable>) {
if (name.isNullOrEmpty()) { if (name.isNullOrEmpty()) {
return return
} }
val placeholder = getPlaceholderDrawable(context, name) val placeholder = getPlaceholderDrawable(context, identifier, name)
buildGlideRequest(glideRequest, avatarUrl) buildGlideRequest(glideRequest, avatarUrl)
.placeholder(placeholder) .placeholder(placeholder)
.into(target) .into(target)
} }


@AnyThread @AnyThread
fun getPlaceholderDrawable(context: Context, text: String): Drawable { fun getPlaceholderDrawable(context: Context, identifier: String, text: String): Drawable {
val avatarColor = ContextCompat.getColor(context, R.color.pale_teal) val avatarColor = ContextCompat.getColor(context, getAvatarColor(identifier))
return if (text.isEmpty()) { return if (text.isEmpty()) {
TextDrawable.builder().buildRound("", avatarColor) TextDrawable.builder().buildRound("", avatarColor)
} else { } else {
@ -87,9 +88,21 @@ object AvatarRenderer {
} }
} }



// PRIVATE API ********************************************************************************* // PRIVATE API *********************************************************************************



private fun getAvatarColor(text: String? = null): Int {
var colorIndex: Long = 0
if (!text.isNullOrEmpty()) {
var sum: Long = 0
for (i in 0 until text.length) {
sum += text[i].toLong()
}
colorIndex = sum % AVATAR_COLOR_LIST.size
}
return AVATAR_COLOR_LIST[colorIndex.toInt()]
}

private fun buildGlideRequest(glideRequest: GlideRequests, avatarUrl: String?): GlideRequest<Drawable> { private fun buildGlideRequest(glideRequest: GlideRequests, avatarUrl: String?): GlideRequest<Drawable> {
val resolvedUrl = Matrix.getInstance().currentSession!!.contentUrlResolver() val resolvedUrl = Matrix.getInstance().currentSession!!.contentUrlResolver()
.resolveThumbnail(avatarUrl, THUMBNAIL_SIZE, THUMBNAIL_SIZE, ContentUrlResolver.ThumbnailMethod.SCALE) .resolveThumbnail(avatarUrl, THUMBNAIL_SIZE, THUMBNAIL_SIZE, ContentUrlResolver.ThumbnailMethod.SCALE)

View File

@ -35,6 +35,7 @@ class GroupSummaryController : TypedEpoxyController<GroupListViewState>() {
val isSelected = groupSummary.groupId == selected?.groupId val isSelected = groupSummary.groupId == selected?.groupId
groupSummaryItem { groupSummaryItem {
id(groupSummary.groupId) id(groupSummary.groupId)
groupId(groupSummary.groupId)
groupName(groupSummary.displayName) groupName(groupSummary.displayName)
selected(isSelected) selected(isSelected)
avatarUrl(groupSummary.avatarUrl) avatarUrl(groupSummary.avatarUrl)

View File

@ -29,6 +29,7 @@ import im.vector.riotredesign.features.home.AvatarRenderer
abstract class GroupSummaryItem : VectorEpoxyModel<GroupSummaryItem.Holder>() { abstract class GroupSummaryItem : VectorEpoxyModel<GroupSummaryItem.Holder>() {


@EpoxyAttribute lateinit var groupName: CharSequence @EpoxyAttribute lateinit var groupName: CharSequence
@EpoxyAttribute lateinit var groupId: String
@EpoxyAttribute var avatarUrl: String? = null @EpoxyAttribute var avatarUrl: String? = null
@EpoxyAttribute var selected: Boolean = false @EpoxyAttribute var selected: Boolean = false
@EpoxyAttribute var listener: (() -> Unit)? = null @EpoxyAttribute var listener: (() -> Unit)? = null
@ -37,7 +38,7 @@ abstract class GroupSummaryItem : VectorEpoxyModel<GroupSummaryItem.Holder>() {
super.bind(holder) super.bind(holder)
holder.rootView.isSelected = selected holder.rootView.isSelected = selected
holder.rootView.setOnClickListener { listener?.invoke() } holder.rootView.setOnClickListener { listener?.invoke() }
AvatarRenderer.render(avatarUrl, groupName.toString(), holder.avatarImageView) AvatarRenderer.render(avatarUrl, groupId, groupName.toString(), holder.avatarImageView)
} }


class Holder : VectorEpoxyHolder() { class Holder : VectorEpoxyHolder() {

View File

@ -37,6 +37,10 @@ import com.otaliastudios.autocomplete.Autocomplete
import com.otaliastudios.autocomplete.AutocompleteCallback import com.otaliastudios.autocomplete.AutocompleteCallback
import com.otaliastudios.autocomplete.CharPolicy import com.otaliastudios.autocomplete.CharPolicy
import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.user.model.User import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.R import im.vector.riotredesign.R
@ -60,8 +64,10 @@ import im.vector.riotredesign.features.home.room.detail.composer.TextComposerVie
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener import im.vector.riotredesign.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
import im.vector.riotredesign.features.html.PillImageSpan import im.vector.riotredesign.features.html.PillImageSpan
import im.vector.riotredesign.features.media.MediaContentRenderer import im.vector.riotredesign.features.media.ImageContentRenderer
import im.vector.riotredesign.features.media.MediaViewerActivity import im.vector.riotredesign.features.media.ImageMediaViewerActivity
import im.vector.riotredesign.features.media.VideoContentRenderer
import im.vector.riotredesign.features.media.VideoMediaViewerActivity
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_room_detail.* import kotlinx.android.synthetic.main.fragment_room_detail.*
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@ -367,7 +373,7 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
.show() .show()
} }


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


override fun onUrlClicked(url: String) { override fun onUrlClicked(url: String) {
homePermalinkHandler.launch(url) homePermalinkHandler.launch(url)
@ -377,12 +383,25 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
roomDetailViewModel.process(RoomDetailActions.EventDisplayed(event)) roomDetailViewModel.process(RoomDetailActions.EventDisplayed(event))
} }


override fun onMediaClicked(mediaData: MediaContentRenderer.Data, view: View) { override fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View) {
val intent = MediaViewerActivity.newIntent(vectorBaseActivity, mediaData) val intent = ImageMediaViewerActivity.newIntent(vectorBaseActivity, mediaData)
startActivity(intent) startActivity(intent)
} }


// AutocompleteUserPresenter.Callback override fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View) {
val intent = VideoMediaViewerActivity.newIntent(vectorBaseActivity, mediaData)
startActivity(intent)
}

override fun onFileMessageClicked(messageFileContent: MessageFileContent) {
vectorBaseActivity.notImplemented()
}

override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) {
vectorBaseActivity.notImplemented()
}

// AutocompleteUserPresenter.Callback


override fun onQueryUsers(query: CharSequence?) { override fun onQueryUsers(query: CharSequence?) {
textComposerViewModel.process(TextComposerActions.QueryUsers(query)) textComposerViewModel.process(TextComposerActions.QueryUsers(query))

View File

@ -24,16 +24,32 @@ import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.VisibilityState import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.timeline.Timeline 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.room.timeline.TimelineEvent
import im.vector.riotredesign.core.epoxy.LoadingItemModel_ import im.vector.riotredesign.core.epoxy.LoadingItemModel_
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.core.extensions.localDateTime import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.features.home.room.detail.timeline.factory.TimelineItemFactory import im.vector.riotredesign.features.home.room.detail.timeline.factory.TimelineItemFactory
import im.vector.riotredesign.features.home.room.detail.timeline.helper.* import im.vector.riotredesign.features.home.room.detail.timeline.helper.RoomMemberEventHelper
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineAsyncHelper
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.detail.timeline.helper.canBeMerged
import im.vector.riotredesign.features.home.room.detail.timeline.helper.nextDisplayableEvent
import im.vector.riotredesign.features.home.room.detail.timeline.helper.prevSameTypeEvents
import im.vector.riotredesign.features.home.room.detail.timeline.item.DaySeparatorItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.DaySeparatorItem_ import im.vector.riotredesign.features.home.room.detail.timeline.item.DaySeparatorItem_
import im.vector.riotredesign.features.media.MediaContentRenderer import im.vector.riotredesign.features.home.room.detail.timeline.item.MergedHeaderItem
import im.vector.riotredesign.features.media.ImageContentRenderer
import im.vector.riotredesign.features.media.VideoContentRenderer
import org.threeten.bp.LocalDateTime


class TimelineEventController(private val dateFormatter: TimelineDateFormatter, class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
private val timelineItemFactory: TimelineItemFactory, private val timelineItemFactory: TimelineItemFactory,
@ -44,10 +60,16 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
interface Callback { interface Callback {
fun onEventVisible(event: TimelineEvent) fun onEventVisible(event: TimelineEvent)
fun onUrlClicked(url: String) fun onUrlClicked(url: String)
fun onMediaClicked(mediaData: MediaContentRenderer.Data, view: View) fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View)
fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View)
fun onFileMessageClicked(messageFileContent: MessageFileContent)
fun onAudioMessageClicked(messageAudioContent: MessageAudioContent)
} }


private val modelCache = arrayListOf<List<EpoxyModel<*>>>() private val collapsedEventIds = linkedSetOf<String>()
private val mergeItemCollapseStates = HashMap<String, Boolean>()
private val modelCache = arrayListOf<CacheItemData?>()

private var currentSnapshot: List<TimelineEvent> = emptyList() private var currentSnapshot: List<TimelineEvent> = emptyList()
private var inSubmitList: Boolean = false private var inSubmitList: Boolean = false
private var timeline: Timeline? = null private var timeline: Timeline? = null
@ -60,7 +82,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
override fun onChanged(position: Int, count: Int, payload: Any?) { override fun onChanged(position: Int, count: Int, payload: Any?) {
assertUpdateCallbacksAllowed() assertUpdateCallbacksAllowed()
(position until (position + count)).forEach { (position until (position + count)).forEach {
modelCache[it] = emptyList() modelCache[it] = null
} }
requestModelBuild() requestModelBuild()
} }
@ -76,11 +98,8 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
@Synchronized @Synchronized
override fun onInserted(position: Int, count: Int) { override fun onInserted(position: Int, count: Int) {
assertUpdateCallbacksAllowed() assertUpdateCallbacksAllowed()
if (modelCache.isNotEmpty() && position == modelCache.size) {
modelCache[position - 1] = emptyList()
}
(0 until count).forEach { (0 until count).forEach {
modelCache.add(position, emptyList()) modelCache.add(position, null)
} }
requestModelBuild() requestModelBuild()
} }
@ -116,7 +135,6 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
.id("forward_loading_item") .id("forward_loading_item")
.addWhen(Timeline.Direction.FORWARDS) .addWhen(Timeline.Direction.FORWARDS)



val timelineModels = getModels() val timelineModels = getModels()
add(timelineModels) add(timelineModels)


@ -149,53 +167,110 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
@Synchronized @Synchronized
private fun getModels(): List<EpoxyModel<*>> { private fun getModels(): List<EpoxyModel<*>> {
(0 until modelCache.size).forEach { position -> (0 until modelCache.size).forEach { position ->
if (modelCache[position].isEmpty()) { // 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] = buildItemModels(position, currentSnapshot) modelCache[position] = buildItemModels(position, currentSnapshot)
} }
} }
return modelCache.flatten() return modelCache
.map {
val eventModel = if (it == null || collapsedEventIds.contains(it.localId)) {
null
} else {
it.eventModel
}
listOf(eventModel, it?.mergedHeaderModel, it?.formattedDayModel)
}
.flatten()
.filterNotNull()
} }


private fun buildItemModels(currentPosition: Int, items: List<TimelineEvent>): List<EpoxyModel<*>> {
val epoxyModels = ArrayList<EpoxyModel<*>>() private fun buildItemModels(currentPosition: Int, items: List<TimelineEvent>): CacheItemData {
val event = items[currentPosition] val event = items[currentPosition]
val nextEvent = items.nextDisplayableEvent(currentPosition) val nextEvent = items.nextDisplayableEvent(currentPosition)

val date = event.root.localDateTime() val date = event.root.localDateTime()
val nextDate = nextEvent?.root?.localDateTime() val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate() val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()


timelineItemFactory.create(event, nextEvent, callback).also { val eventModel = timelineItemFactory.create(event, nextEvent, callback).also {
it.id(event.localId) it.id(event.localId)
it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event))
epoxyModels.add(it)
} }
if (addDaySeparator) { val mergedHeaderModel = buildMergedHeaderItem(event, nextEvent, items, addDaySeparator, currentPosition)
val daySeparatorItem = buildDaySeparatorItem(addDaySeparator, date)

return CacheItemData(event.localId, eventModel, mergedHeaderModel, daySeparatorItem)
}

private fun buildDaySeparatorItem(addDaySeparator: Boolean, date: LocalDateTime): DaySeparatorItem? {
return if (addDaySeparator) {
val formattedDay = dateFormatter.formatMessageDay(date) val formattedDay = dateFormatter.formatMessageDay(date)
val daySeparatorItem = DaySeparatorItem_().formattedDay(formattedDay).id(formattedDay) DaySeparatorItem_().formattedDay(formattedDay).id(formattedDay)
epoxyModels.add(daySeparatorItem) } else {
null
}
}

private fun buildMergedHeaderItem(event: TimelineEvent,
nextEvent: TimelineEvent?,
items: List<TimelineEvent>,
addDaySeparator: Boolean,
currentPosition: Int): MergedHeaderItem? {
return if (!event.canBeMerged() || (nextEvent?.root?.type == event.root.type && !addDaySeparator)) {
null
} else {
val prevSameTypeEvents = items.prevSameTypeEvents(currentPosition, 2)
if (prevSameTypeEvents.isEmpty()) {
null
} else {
val mergedEvents = (prevSameTypeEvents + listOf(event)).asReversed()
val mergedData = mergedEvents.map { mergedEvent ->
val eventContent: RoomMember? = mergedEvent.root.content.toModel()
val prevEventContent: RoomMember? = mergedEvent.root.prevContent.toModel()
val senderAvatar = RoomMemberEventHelper.senderAvatar(eventContent, prevEventContent, mergedEvent)
val senderName = RoomMemberEventHelper.senderName(eventContent, prevEventContent, mergedEvent)
MergedHeaderItem.Data(
userId = mergedEvent.root.sender ?: "",
avatarUrl = senderAvatar,
memberName = senderName ?: "",
eventId = mergedEvent.localId
)
}
val mergedEventIds = mergedEvents.map { it.localId }
// We try to find if one of the item id were used as mergeItemCollapseStates key
// => handle case where paginating from mergeable events and we get more
val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull()
val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey)
?: true
val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState }
if (isCollapsed) {
collapsedEventIds.addAll(mergedEventIds)
} else {
collapsedEventIds.removeAll(mergedEventIds)
}
val mergeId = mergedEventIds.joinToString(separator = "_") { it }
MergedHeaderItem(isCollapsed, mergeId, mergedData) {
mergeItemCollapseStates[event.localId] = it
requestModelBuild()
}
}
} }
return epoxyModels
} }


private fun LoadingItemModel_.addWhen(direction: Timeline.Direction) { private fun LoadingItemModel_.addWhen(direction: Timeline.Direction) {
val shouldAdd = timeline?.let { val shouldAdd = timeline?.hasMoreToLoad(direction) ?: false
it.hasMoreToLoad(direction)
} ?: false
addIf(shouldAdd, this@TimelineEventController) addIf(shouldAdd, this@TimelineEventController)
} }


} }


private class TimelineEventVisibilityStateChangedListener(private val callback: TimelineEventController.Callback?, private data class CacheItemData(
private val event: TimelineEvent) val localId: String,
: VectorEpoxyModel.OnVisibilityStateChangedListener { val eventModel: EpoxyModel<*>? = null,

val mergedHeaderModel: MergedHeaderItem? = null,
override fun onVisibilityStateChanged(visibilityState: Int) { val formattedDayModel: DaySeparatorItem? = null
if (visibilityState == VisibilityState.VISIBLE) { )
callback?.onEventVisible(event)
}
}


}

View File

@ -30,27 +30,26 @@ import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem
class CallItemFactory(private val stringProvider: StringProvider) { class CallItemFactory(private val stringProvider: StringProvider) {


fun create(event: TimelineEvent): NoticeItem? { fun create(event: TimelineEvent): NoticeItem? {
val roomMember = event.roomMember ?: return null val text = buildNoticeText(event.root, event.senderName) ?: return null
val text = buildNoticeText(event.root, roomMember) ?: return null
return NoticeItem_() return NoticeItem_()
.noticeText(text) .noticeText(text)
.avatarUrl(roomMember.avatarUrl) .avatarUrl(event.senderAvatar)
.memberName(roomMember.displayName) .memberName(event.senderName)
} }


private fun buildNoticeText(event: Event, roomMember: RoomMember): CharSequence? { private fun buildNoticeText(event: Event, senderName: String?): CharSequence? {
return when { return when {
EventType.CALL_INVITE == event.type -> { EventType.CALL_INVITE == event.type -> {
val content = event.content.toModel<CallInviteContent>() ?: return null val content = event.content.toModel<CallInviteContent>() ?: return null
val isVideoCall = content.offer.sdp == CallInviteContent.Offer.SDP_VIDEO val isVideoCall = content.offer.sdp == CallInviteContent.Offer.SDP_VIDEO
return if (isVideoCall) { return if (isVideoCall) {
stringProvider.getString(R.string.notice_placed_video_call, roomMember.displayName) stringProvider.getString(R.string.notice_placed_video_call, senderName)
} else { } else {
stringProvider.getString(R.string.notice_placed_voice_call, roomMember.displayName) stringProvider.getString(R.string.notice_placed_voice_call, senderName)
} }
} }
EventType.CALL_ANSWER == event.type -> stringProvider.getString(R.string.notice_answered_call, roomMember.displayName) EventType.CALL_ANSWER == event.type -> stringProvider.getString(R.string.notice_answered_call, senderName)
EventType.CALL_HANGUP == event.type -> stringProvider.getString(R.string.notice_ended_call, roomMember.displayName) EventType.CALL_HANGUP == event.type -> stringProvider.getString(R.string.notice_ended_call, senderName)
else -> null else -> null
} }



View File

@ -18,16 +18,19 @@ package im.vector.riotredesign.features.home.room.detail.timeline.factory


import android.text.Spannable import android.text.Spannable
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import androidx.annotation.ColorRes
import im.vector.matrix.android.api.permalinks.MatrixLinkify import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
import im.vector.matrix.android.api.session.events.model.EventType 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.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageEmoteContent import im.vector.matrix.android.api.session.room.model.message.MessageEmoteContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageNoticeContent import im.vector.matrix.android.api.session.room.model.message.MessageNoticeContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
@ -39,13 +42,16 @@ import im.vector.riotredesign.features.home.room.detail.timeline.helper.Timeline
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem_ import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageImageItem import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageFileItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageImageItem_ import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageFileItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageImageVideoItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageImageVideoItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem_ import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem_
import im.vector.riotredesign.features.html.EventHtmlRenderer import im.vector.riotredesign.features.html.EventHtmlRenderer
import im.vector.riotredesign.features.media.MediaContentRenderer import im.vector.riotredesign.features.media.ImageContentRenderer
import im.vector.riotredesign.features.media.VideoContentRenderer
import me.gujun.android.span.span import me.gujun.android.span.span


class MessageItemFactory(private val colorProvider: ColorProvider, class MessageItemFactory(private val colorProvider: ColorProvider,
@ -59,8 +65,6 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
): VectorEpoxyModel<*>? { ): VectorEpoxyModel<*>? {


val eventId = event.root.eventId ?: return null val eventId = event.root.eventId ?: return null
val roomMember = event.roomMember
val nextRoomMember = nextEvent?.roomMember


val date = event.root.localDateTime() val date = event.root.localDateTime()
val nextDate = nextEvent?.root?.localDateTime() val nextDate = nextEvent?.root?.localDateTime()
@ -69,56 +73,113 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
?: false ?: false


val showInformation = addDaySeparator val showInformation = addDaySeparator
|| nextRoomMember != roomMember || event.senderAvatar != nextEvent?.senderAvatar
|| event.senderName != nextEvent?.senderName
|| nextEvent?.root?.type != EventType.MESSAGE || nextEvent?.root?.type != EventType.MESSAGE
|| isNextMessageReceivedMoreThanOneHourAgo || isNextMessageReceivedMoreThanOneHourAgo


val messageContent: MessageContent = event.root.content.toModel() ?: return null val messageContent: MessageContent = event.root.content.toModel() ?: return null
val time = timelineDateFormatter.formatMessageHour(date) val time = timelineDateFormatter.formatMessageHour(date)
val avatarUrl = roomMember?.avatarUrl val avatarUrl = event.senderAvatar
val memberName = roomMember?.displayName ?: event.root.sender val memberName = event.senderName ?: event.root.sender ?: ""
val informationData = MessageInformationData(time, avatarUrl, memberName, showInformation) val formattedMemberName = span(memberName) {
textColor = colorProvider.getColor(getColorFor(event.root.sender ?: ""))
}
val informationData = MessageInformationData(eventId = eventId,
senderId = event.root.sender ?: "",
sendState = event.sendState,
time = time,
avatarUrl = avatarUrl,
memberName = formattedMemberName,
showInformation = showInformation)


return when (messageContent) { return when (messageContent) {
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback) is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageTextContent -> buildTextMessageItem(event.sendState, messageContent, informationData, callback) is MessageTextContent -> buildTextMessageItem(messageContent, informationData, callback)
is MessageImageContent -> buildImageMessageItem(eventId, messageContent, informationData, callback) is MessageImageContent -> buildImageMessageItem(messageContent, informationData, callback)
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, callback) is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, callback)
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, callback)
is MessageFileContent -> buildFileMessageItem(messageContent, informationData, callback)
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, callback)
else -> buildNotHandledMessageItem(messageContent) else -> buildNotHandledMessageItem(messageContent)
} }
} }


private fun buildAudioMessageItem(messageContent: MessageAudioContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageFileItem? {
return MessageFileItem_()
.informationData(informationData)
.filename(messageContent.body)
.iconRes(R.drawable.filetype_audio)
.clickListener { _ -> callback?.onAudioMessageClicked(messageContent) }
}

private fun buildFileMessageItem(messageContent: MessageFileContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageFileItem? {
return MessageFileItem_()
.informationData(informationData)
.filename(messageContent.body)
.iconRes(R.drawable.filetype_attachment)
.clickListener { _ -> callback?.onFileMessageClicked(messageContent) }
}

private fun buildNotHandledMessageItem(messageContent: MessageContent): DefaultItem? { private fun buildNotHandledMessageItem(messageContent: MessageContent): DefaultItem? {
val text = "${messageContent.type} message events are not yet handled" val text = "${messageContent.type} message events are not yet handled"
return DefaultItem_().text(text) return DefaultItem_().text(text)
} }


private fun buildImageMessageItem(eventId: String, private fun buildImageMessageItem(messageContent: MessageImageContent,
messageContent: MessageImageContent,
informationData: MessageInformationData, informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageImageItem? { callback: TimelineEventController.Callback?): MessageImageVideoItem? {


val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize() val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize()
val data = MediaContentRenderer.Data( val data = ImageContentRenderer.Data(
messageContent.body, filename = messageContent.body,
url = messageContent.url, url = messageContent.url,
height = messageContent.info?.height, height = messageContent.info?.height,
maxHeight = maxHeight, maxHeight = maxHeight,
width = messageContent.info?.width, width = messageContent.info?.width,
maxWidth = maxWidth, maxWidth = maxWidth,
rotation = messageContent.info?.rotation, orientation = messageContent.info?.orientation,
orientation = messageContent.info?.orientation rotation = messageContent.info?.rotation
) )
return MessageImageItem_() return MessageImageVideoItem_()
.eventId(eventId) .playable(messageContent.info?.mimeType == "image/gif")
.informationData(informationData) .informationData(informationData)
.mediaData(data) .mediaData(data)
.clickListener { view -> callback?.onMediaClicked(data, view) } .clickListener { view -> callback?.onImageMessageClicked(messageContent, data, view) }
} }


private fun buildTextMessageItem(sendState: SendState, private fun buildVideoMessageItem(messageContent: MessageVideoContent,
messageContent: MessageTextContent, informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageImageVideoItem? {

val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize()
val thumbnailData = ImageContentRenderer.Data(
filename = messageContent.body,
url = messageContent.info?.thumbnailUrl,
height = messageContent.info?.height,
maxHeight = maxHeight,
width = messageContent.info?.width,
maxWidth = maxWidth
)

val videoData = VideoContentRenderer.Data(
filename = messageContent.body,
videoUrl = messageContent.url,
thumbnailMediaData = thumbnailData
)

return MessageImageVideoItem_()
.playable(true)
.informationData(informationData)
.mediaData(thumbnailData)
.clickListener { view -> callback?.onVideoMessageClicked(messageContent, videoData, view) }
}

private fun buildTextMessageItem(messageContent: MessageTextContent,
informationData: MessageInformationData, informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageTextItem? { callback: TimelineEventController.Callback?): MessageTextItem? {


@ -126,15 +187,7 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
htmlRenderer.render(it) htmlRenderer.render(it)
} ?: messageContent.body } ?: messageContent.body


val textColor = if (sendState.isSent()) { val linkifiedBody = linkifyBody(bodyToUse, callback)
R.color.dark_grey
} else {
R.color.brown_grey
}
val formattedBody = span(bodyToUse) {
this.textColor = colorProvider.getColor(textColor)
}
val linkifiedBody = linkifyBody(formattedBody, callback)
return MessageTextItem_() return MessageTextItem_()
.message(linkifiedBody) .message(linkifiedBody)
.informationData(informationData) .informationData(informationData)
@ -181,4 +234,31 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return spannable return spannable
} }


//Based on riot-web implementation
@ColorRes
private fun getColorFor(sender: String): Int {
var hash = 0
var i = 0
var chr: Char
if (sender.isEmpty()) {
return R.color.username_1
}
while (i < sender.length) {
chr = sender[i]
hash = (hash shl 5) - hash + chr.toInt()
hash = hash or 0
i++
}
val cI = Math.abs(hash) % 8 + 1
return when (cI) {
1 -> R.color.username_1
2 -> R.color.username_2
3 -> R.color.username_3
4 -> R.color.username_4
5 -> R.color.username_5
6 -> R.color.username_6
7 -> R.color.username_7
else -> R.color.username_8
}
}
} }

View File

@ -31,15 +31,14 @@ import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem
class RoomHistoryVisibilityItemFactory(private val stringProvider: StringProvider) { class RoomHistoryVisibilityItemFactory(private val stringProvider: StringProvider) {


fun create(event: TimelineEvent): NoticeItem? { fun create(event: TimelineEvent): NoticeItem? {
val roomMember = event.roomMember ?: return null val noticeText = buildNoticeText(event.root, event.senderName) ?: return null
val noticeText = buildNoticeText(event.root, roomMember) ?: return null
return NoticeItem_() return NoticeItem_()
.noticeText(noticeText) .noticeText(noticeText)
.avatarUrl(roomMember.avatarUrl) .avatarUrl(event.senderAvatar)
.memberName(roomMember.displayName) .memberName(event.senderName)
} }


private fun buildNoticeText(event: Event, roomMember: RoomMember): CharSequence? { private fun buildNoticeText(event: Event, senderName: String?): CharSequence? {
val content = event.content.toModel<RoomHistoryVisibilityContent>() ?: return null val content = event.content.toModel<RoomHistoryVisibilityContent>() ?: return null
val formattedVisibility = when (content.historyVisibility) { val formattedVisibility = when (content.historyVisibility) {
RoomHistoryVisibility.SHARED -> stringProvider.getString(R.string.notice_room_visibility_shared) RoomHistoryVisibility.SHARED -> stringProvider.getString(R.string.notice_room_visibility_shared)
@ -47,7 +46,7 @@ class RoomHistoryVisibilityItemFactory(private val stringProvider: StringProvide
RoomHistoryVisibility.JOINED -> stringProvider.getString(R.string.notice_room_visibility_joined) RoomHistoryVisibility.JOINED -> stringProvider.getString(R.string.notice_room_visibility_joined)
RoomHistoryVisibility.WORLD_READABLE -> stringProvider.getString(R.string.notice_room_visibility_world_readable) RoomHistoryVisibility.WORLD_READABLE -> stringProvider.getString(R.string.notice_room_visibility_world_readable)
} }
return stringProvider.getString(R.string.notice_made_future_room_visibility, roomMember.displayName, formattedVisibility) return stringProvider.getString(R.string.notice_made_future_room_visibility, senderName, formattedVisibility)
} }





View File

@ -23,6 +23,7 @@ import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.core.resources.StringProvider import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.home.room.detail.timeline.helper.RoomMemberEventHelper
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem_ import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem_


@ -31,17 +32,20 @@ import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem
class RoomMemberItemFactory(private val stringProvider: StringProvider) { class RoomMemberItemFactory(private val stringProvider: StringProvider) {


fun create(event: TimelineEvent): NoticeItem? { fun create(event: TimelineEvent): NoticeItem? {
val roomMember = event.roomMember ?: return null
val noticeText = buildRoomMemberNotice(event) ?: return null
return NoticeItem_()
.noticeText(noticeText)
.avatarUrl(roomMember.avatarUrl)
.memberName(roomMember.displayName)
}

private fun buildRoomMemberNotice(event: TimelineEvent): String? {
val eventContent: RoomMember? = event.root.content.toModel() val eventContent: RoomMember? = event.root.content.toModel()
val prevEventContent: RoomMember? = event.root.prevContent.toModel() val prevEventContent: RoomMember? = event.root.prevContent.toModel()
val noticeText = buildRoomMemberNotice(event, eventContent, prevEventContent) ?: return null
val senderAvatar = RoomMemberEventHelper.senderAvatar(eventContent, prevEventContent, event)
val senderName = RoomMemberEventHelper.senderName(eventContent, prevEventContent, event)

return NoticeItem_()
.userId(event.root.sender ?: "")
.noticeText(noticeText)
.avatarUrl(senderAvatar)
.memberName(senderName)
}

private fun buildRoomMemberNotice(event: TimelineEvent, eventContent: RoomMember?, prevEventContent: RoomMember?): String? {
val isMembershipEvent = prevEventContent?.membership != eventContent?.membership val isMembershipEvent = prevEventContent?.membership != eventContent?.membership
return if (isMembershipEvent) { return if (isMembershipEvent) {
buildMembershipNotice(event, eventContent, prevEventContent) buildMembershipNotice(event, eventContent, prevEventContent)
@ -57,11 +61,11 @@ class RoomMemberItemFactory(private val stringProvider: StringProvider) {
val displayNameText = when { val displayNameText = when {
prevEventContent?.displayName.isNullOrEmpty() -> prevEventContent?.displayName.isNullOrEmpty() ->
stringProvider.getString(R.string.notice_display_name_set, event.root.sender, eventContent?.displayName) stringProvider.getString(R.string.notice_display_name_set, event.root.sender, eventContent?.displayName)
eventContent?.displayName.isNullOrEmpty() -> eventContent?.displayName.isNullOrEmpty() ->
stringProvider.getString(R.string.notice_display_name_removed, event.root.sender, prevEventContent?.displayName) stringProvider.getString(R.string.notice_display_name_removed, event.root.sender, prevEventContent?.displayName)
else -> else ->
stringProvider.getString(R.string.notice_display_name_changed_from, stringProvider.getString(R.string.notice_display_name_changed_from,
event.root.sender, prevEventContent?.displayName, eventContent?.displayName) event.root.sender, prevEventContent?.displayName, eventContent?.displayName)
} }
displayText.append(displayNameText) displayText.append(displayNameText)
} }
@ -71,7 +75,7 @@ class RoomMemberItemFactory(private val stringProvider: StringProvider) {
displayText.append(" ") displayText.append(" ")
stringProvider.getString(R.string.notice_avatar_changed_too) stringProvider.getString(R.string.notice_avatar_changed_too)
} else { } else {
stringProvider.getString(R.string.notice_avatar_url_changed, event.roomMember?.displayName) stringProvider.getString(R.string.notice_avatar_url_changed, event.senderName)
} }
displayText.append(displayAvatarText) displayText.append(displayAvatarText)
} }
@ -79,33 +83,34 @@ class RoomMemberItemFactory(private val stringProvider: StringProvider) {
} }


private fun buildMembershipNotice(event: TimelineEvent, eventContent: RoomMember?, prevEventContent: RoomMember?): String? { private fun buildMembershipNotice(event: TimelineEvent, eventContent: RoomMember?, prevEventContent: RoomMember?): String? {
val senderDisplayName = event.roomMember?.displayName ?: return null val senderDisplayName = event.senderName ?: event.root.sender
val targetDisplayName = eventContent?.displayName ?: event.root.sender val targetDisplayName = eventContent?.displayName ?: event.root.sender
return when { return when {
Membership.INVITE == eventContent?.membership -> { Membership.INVITE == eventContent?.membership -> {
// TODO get userId // TODO get userId
val selfUserId: String = "" val selfUserId = ""
when { when {
eventContent.thirdPartyInvite != null -> eventContent.thirdPartyInvite != null ->
stringProvider.getString(R.string.notice_room_third_party_registered_invite, stringProvider.getString(R.string.notice_room_third_party_registered_invite,
targetDisplayName, eventContent.thirdPartyInvite?.displayName) targetDisplayName, eventContent.thirdPartyInvite?.displayName)
TextUtils.equals(event.root.stateKey, selfUserId) -> TextUtils.equals(event.root.stateKey, selfUserId) ->
stringProvider.getString(R.string.notice_room_invite_you, senderDisplayName) stringProvider.getString(R.string.notice_room_invite_you, senderDisplayName)
event.root.stateKey.isNullOrEmpty() -> event.root.stateKey.isNullOrEmpty() ->
stringProvider.getString(R.string.notice_room_invite_no_invitee, senderDisplayName) stringProvider.getString(R.string.notice_room_invite_no_invitee, senderDisplayName)
else -> else ->
stringProvider.getString(R.string.notice_room_invite, senderDisplayName, targetDisplayName) stringProvider.getString(R.string.notice_room_invite, senderDisplayName, targetDisplayName)
} }
} }
Membership.JOIN == eventContent?.membership -> Membership.JOIN == eventContent?.membership ->
stringProvider.getString(R.string.notice_room_join, senderDisplayName) stringProvider.getString(R.string.notice_room_join, senderDisplayName)
Membership.LEAVE == eventContent?.membership -> Membership.LEAVE == eventContent?.membership ->
// 2 cases here: this member may have left voluntarily or they may have been "left" by someone else ie. kicked // 2 cases here: this member may have left voluntarily or they may have been "left" by someone else ie. kicked
return if (TextUtils.equals(event.root.sender, event.root.stateKey)) { return if (TextUtils.equals(event.root.sender, event.root.stateKey)) {
if (prevEventContent?.membership == Membership.INVITE) { if (prevEventContent?.membership == Membership.INVITE) {
stringProvider.getString(R.string.notice_room_reject, senderDisplayName) stringProvider.getString(R.string.notice_room_reject, senderDisplayName)
} else { } else {
stringProvider.getString(R.string.notice_room_leave, senderDisplayName) val leftDisplayName = RoomMemberEventHelper.senderName(eventContent, prevEventContent, event)
stringProvider.getString(R.string.notice_room_leave, leftDisplayName)
} }
} else if (prevEventContent?.membership == Membership.INVITE) { } else if (prevEventContent?.membership == Membership.INVITE) {
stringProvider.getString(R.string.notice_room_withdraw, senderDisplayName, targetDisplayName) stringProvider.getString(R.string.notice_room_withdraw, senderDisplayName, targetDisplayName)
@ -116,11 +121,11 @@ class RoomMemberItemFactory(private val stringProvider: StringProvider) {
} else { } else {
null null
} }
Membership.BAN == eventContent?.membership -> Membership.BAN == eventContent?.membership ->
stringProvider.getString(R.string.notice_room_ban, senderDisplayName, targetDisplayName) stringProvider.getString(R.string.notice_room_ban, senderDisplayName, targetDisplayName)
Membership.KNOCK == eventContent?.membership -> Membership.KNOCK == eventContent?.membership ->
stringProvider.getString(R.string.notice_room_kick, senderDisplayName, targetDisplayName) stringProvider.getString(R.string.notice_room_kick, senderDisplayName, targetDisplayName)
else -> null else -> null
} }
} }



View File

@ -29,20 +29,16 @@ class RoomNameItemFactory(private val stringProvider: StringProvider) {


fun create(event: TimelineEvent): NoticeItem? { fun create(event: TimelineEvent): NoticeItem? {


val content: RoomNameContent? = event.root.content.toModel() val content: RoomNameContent = event.root.content.toModel() ?: return null
val roomMember = event.roomMember
if (content == null || roomMember == null) {
return null
}
val text = if (!TextUtils.isEmpty(content.name)) { val text = if (!TextUtils.isEmpty(content.name)) {
stringProvider.getString(R.string.notice_room_name_changed, roomMember.displayName, content.name) stringProvider.getString(R.string.notice_room_name_changed, event.senderName, content.name)
} else { } else {
stringProvider.getString(R.string.notice_room_name_removed, roomMember.displayName) stringProvider.getString(R.string.notice_room_name_removed, event.senderName)
} }
return NoticeItem_() return NoticeItem_()
.noticeText(text) .noticeText(text)
.avatarUrl(roomMember.avatarUrl) .avatarUrl(event.senderAvatar)
.memberName(roomMember.displayName) .memberName(event.senderName)
} }





View File

@ -28,20 +28,16 @@ class RoomTopicItemFactory(private val stringProvider: StringProvider) {


fun create(event: TimelineEvent): NoticeItem? { fun create(event: TimelineEvent): NoticeItem? {


val content: RoomTopicContent? = event.root.content.toModel() val content: RoomTopicContent = event.root.content.toModel() ?: return null
val roomMember = event.roomMember
if (content == null || roomMember == null) {
return null
}
val text = if (content.topic.isNullOrEmpty()) { val text = if (content.topic.isNullOrEmpty()) {
stringProvider.getString(R.string.notice_room_topic_removed, roomMember.displayName) stringProvider.getString(R.string.notice_room_topic_removed, event.senderName)
} else { } else {
stringProvider.getString(R.string.notice_room_topic_changed, roomMember.displayName, content.topic) stringProvider.getString(R.string.notice_room_topic_changed, event.senderName, content.topic)
} }
return NoticeItem_() return NoticeItem_()
.noticeText(text) .noticeText(text)
.avatarUrl(roomMember.avatarUrl) .avatarUrl(event.senderAvatar)
.memberName(roomMember.displayName) .memberName(event.senderName)
} }





View File

@ -57,7 +57,7 @@ class TimelineItemFactory(private val messageItemFactory: MessageItemFactory,
} catch (e: Exception) { } catch (e: Exception) {
defaultItemFactory.create(event, e) defaultItemFactory.create(event, e)
} }
return computedModel ?: EmptyItem_() return (computedModel ?: EmptyItem_())
} }


} }

View File

@ -25,7 +25,7 @@ import android.widget.TextView
import im.vector.matrix.android.api.Matrix import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.content.ContentUploadStateTracker import im.vector.matrix.android.api.session.content.ContentUploadStateTracker
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.features.media.MediaContentRenderer import im.vector.riotredesign.features.media.ImageContentRenderer
import java.io.File import java.io.File


object ContentUploadStateTrackerBinder { object ContentUploadStateTrackerBinder {
@ -33,7 +33,7 @@ object ContentUploadStateTrackerBinder {
private val updateListeners = mutableMapOf<String, ContentUploadStateTracker.UpdateListener>() private val updateListeners = mutableMapOf<String, ContentUploadStateTracker.UpdateListener>()


fun bind(eventId: String, fun bind(eventId: String,
mediaData: MediaContentRenderer.Data, mediaData: ImageContentRenderer.Data,
progressLayout: ViewGroup) { progressLayout: ViewGroup) {


Matrix.getInstance().currentSession?.also { session -> Matrix.getInstance().currentSession?.also { session ->
@ -56,7 +56,7 @@ object ContentUploadStateTrackerBinder {
} }


private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup, private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
private val mediaData: MediaContentRenderer.Data) : ContentUploadStateTracker.UpdateListener { private val mediaData: ImageContentRenderer.Data) : ContentUploadStateTracker.UpdateListener {


override fun onUpdate(state: ContentUploadStateTracker.State) { override fun onUpdate(state: ContentUploadStateTracker.State) {
when (state) { when (state) {

View File

@ -34,28 +34,18 @@ class EndlessRecyclerViewScrollListener(private val layoutManager: LinearLayoutM
// This happens many times a second during a scroll, so be wary of the code you place here. // This happens many times a second during a scroll, so be wary of the code you place here.
// We are given a few useful parameters to help us work out if we need to load some more data, // We are given a few useful parameters to help us work out if we need to load some more data,
// but first we check if we are waiting for the previous load to finish. // but first we check if we are waiting for the previous load to finish.

override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
val totalItemCount = layoutManager.itemCount val totalItemCount = layoutManager.itemCount


// The minimum amount of items to have below your current scroll position // We check to see if the dataset count has
// before loading more.
// If the total item count is zero and the previous isn't, assume the
// list is invalidated and should be reset back to initial state
if (totalItemCount < previousTotalItemCount) {
previousTotalItemCount = totalItemCount
if (totalItemCount == 0) {
loadingForwards = true
loadingBackwards = true
}
}
// If its still loading, we check to see if the dataset count has
// changed, if so we conclude it has finished loading // changed, if so we conclude it has finished loading
if (totalItemCount > previousTotalItemCount) { if (totalItemCount != previousTotalItemCount) {
previousTotalItemCount = totalItemCount
loadingBackwards = false loadingBackwards = false
loadingForwards = false loadingForwards = false
previousTotalItemCount = totalItemCount
} }
// If it isnt currently loading, we check to see if we have reached // If it isnt currently loading, we check to see if we have reached
// the visibleThreshold and need to reload more data. // the visibleThreshold and need to reload more data.

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.riotredesign.features.home.room.detail.timeline.helper

import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent

object RoomMemberEventHelper {

fun senderAvatar(eventContent: RoomMember?, prevEventContent: RoomMember?, event: TimelineEvent): String? {
return if (eventContent?.membership == Membership.LEAVE && eventContent.avatarUrl == null && prevEventContent?.avatarUrl != null) {
prevEventContent.avatarUrl
} else {
event.senderAvatar
}
}

fun senderName(eventContent: RoomMember?, prevEventContent: RoomMember?, event: TimelineEvent): String? {
return if (eventContent?.membership == Membership.LEAVE && eventContent.displayName == null && prevEventContent?.displayName != null) {
prevEventContent.displayName
} else {
event.senderName
}
}
}

View File

@ -18,6 +18,7 @@ package im.vector.riotredesign.features.home.room.detail.timeline.helper


import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.core.extensions.localDateTime


object TimelineDisplayableEvents { object TimelineDisplayableEvents {


@ -48,8 +49,48 @@ fun List<TimelineEvent>.filterDisplayableEvents(): List<TimelineEvent> {
} }
} }


fun TimelineEvent.canBeMerged(): Boolean {
return root.type == EventType.STATE_ROOM_MEMBER
}

fun List<TimelineEvent>.nextSameTypeEvents(index: Int, minSize: Int): List<TimelineEvent> {
if (index >= size - 1) {
return emptyList()
}
val timelineEvent = this[index]
val nextSubList = subList(index + 1, size)
val indexOfNextDay = nextSubList.indexOfFirst {
val date = it.root.localDateTime()
val nextDate = timelineEvent.root.localDateTime()
date.toLocalDate() != nextDate.toLocalDate()
}
val nextSameDayEvents = if (indexOfNextDay == -1) {
nextSubList
} else {
nextSubList.subList(0, indexOfNextDay)
}
val indexOfFirstDifferentEventType = nextSameDayEvents.indexOfFirst { it.root.type != timelineEvent.root.type }
val sameTypeEvents = if (indexOfFirstDifferentEventType == -1) {
nextSameDayEvents
} else {
nextSameDayEvents.subList(0, indexOfFirstDifferentEventType)
}
if (sameTypeEvents.size < minSize) {
return emptyList()
}
return sameTypeEvents
}

fun List<TimelineEvent>.prevSameTypeEvents(index: Int, minSize: Int): List<TimelineEvent> {
val prevSub = subList(0, index + 1)
return prevSub
.reversed()
.nextSameTypeEvents(0, minSize)
.reversed()
}

fun List<TimelineEvent>.nextDisplayableEvent(index: Int): TimelineEvent? { fun List<TimelineEvent>.nextDisplayableEvent(index: Int): TimelineEvent? {
return if (index == size - 1) { return if (index >= size - 1) {
null null
} else { } else {
subList(index + 1, this.size).firstOrNull { it.isDisplayable() } subList(index + 1, this.size).firstOrNull { it.isDisplayable() }

View File

@ -0,0 +1,36 @@
/*
*
* * 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.riotredesign.features.home.room.detail.timeline.helper

import com.airbnb.epoxy.VisibilityState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController

class TimelineEventVisibilityStateChangedListener(private val callback: TimelineEventController.Callback?,
private val event: TimelineEvent)
: VectorEpoxyModel.OnVisibilityStateChangedListener {

override fun onVisibilityStateChanged(visibilityState: Int) {
if (visibilityState == VisibilityState.VISIBLE) {
callback?.onEventVisible(event)
}
}

}

View File

@ -35,7 +35,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : VectorEpoxyModel<H>()
holder.timeView.visibility = View.VISIBLE holder.timeView.visibility = View.VISIBLE
holder.timeView.text = informationData.time holder.timeView.text = informationData.time
holder.memberNameView.text = informationData.memberName holder.memberNameView.text = informationData.memberName
AvatarRenderer.render(informationData.avatarUrl, informationData.memberName?.toString(), holder.avatarImageView) AvatarRenderer.render(informationData.avatarUrl, informationData.senderId, informationData.memberName?.toString(), holder.avatarImageView)
} else { } else {
holder.avatarImageView.visibility = View.GONE holder.avatarImageView.visibility = View.GONE
holder.memberNameView.visibility = View.GONE holder.memberNameView.visibility = View.GONE
@ -43,6 +43,11 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : VectorEpoxyModel<H>()
} }
} }


protected fun View.renderSendState() {
isClickable = informationData.sendState.isSent()
alpha = if (informationData.sendState.isSent()) 1f else 0.5f
}

abstract class Holder : VectorEpoxyHolder() { abstract class Holder : VectorEpoxyHolder() {
abstract val avatarImageView: ImageView abstract val avatarImageView: ImageView
abstract val memberNameView: TextView abstract val memberNameView: TextView

View File

@ -0,0 +1,94 @@
/*
*
* * 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.riotredesign.features.home.room.detail.timeline.item

import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.children
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyHolder
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.features.home.AvatarRenderer

data class MergedHeaderItem(private val isCollapsed: Boolean,
private val mergeId: String,
private val mergeData: List<Data>,
private val onCollapsedStateChanged: (Boolean) -> Unit
) : VectorEpoxyModel<MergedHeaderItem.Holder>() {

private val distinctMergeData = mergeData.distinctBy { it.userId }

init {
id(mergeId)
}

override fun getDefaultLayout(): Int {
return R.layout.item_timeline_event_merged_header
}

override fun createNewHolder(): Holder {
return Holder()
}

override fun bind(holder: Holder) {
super.bind(holder)
holder.expandView.setOnClickListener {
onCollapsedStateChanged(!isCollapsed)
}
if (isCollapsed) {
val summary = holder.expandView.resources.getQuantityString(R.plurals.membership_changes, mergeData.size, mergeData.size)
holder.summaryView.text = summary
holder.summaryView.visibility = View.VISIBLE
holder.avatarListView.visibility = View.VISIBLE
holder.avatarListView.children.forEachIndexed { index, view ->
val data = distinctMergeData.getOrNull(index)
if (data != null && view is ImageView) {
view.visibility = View.VISIBLE
AvatarRenderer.render(data.avatarUrl, data.userId, data.memberName, view)
} else {
view.visibility = View.GONE
}
}
holder.separatorView.visibility = View.GONE
holder.expandView.setText(R.string.merged_events_expand)
} else {
holder.avatarListView.visibility = View.INVISIBLE
holder.summaryView.visibility = View.GONE
holder.separatorView.visibility = View.VISIBLE
holder.expandView.setText(R.string.merged_events_collapse)
}
}

data class Data(
val eventId: String,
val userId: String,
val memberName: String,
val avatarUrl: String?
)

class Holder : VectorEpoxyHolder() {
val expandView by bind<TextView>(R.id.itemMergedExpandTextView)
val summaryView by bind<TextView>(R.id.itemMergedSummaryTextView)
val separatorView by bind<View>(R.id.itemMergedSeparatorView)
val avatarListView by bind<ViewGroup>(R.id.itemMergedAvatarListView)

}
}

View File

@ -0,0 +1,57 @@
/*
* 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.riotredesign.features.home.room.detail.timeline.item

import android.graphics.Paint
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.DrawableRes
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R

@EpoxyModelClass(layout = R.layout.item_timeline_event_file_message)
abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {

@EpoxyAttribute var filename: CharSequence = ""
@EpoxyAttribute @DrawableRes var iconRes: Int = 0
@EpoxyAttribute override lateinit var informationData: MessageInformationData
@EpoxyAttribute var clickListener: View.OnClickListener? = null

override fun bind(holder: Holder) {
super.bind(holder)
holder.fileLayout.renderSendState()
holder.filenameView.text = filename
holder.fileImageView.setImageResource(iconRes)
holder.filenameView.setOnClickListener(clickListener)
holder.filenameView.paintFlags = (holder.filenameView.paintFlags or Paint.UNDERLINE_TEXT_FLAG)
}


class Holder : AbsMessageItem.Holder() {
override val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
override val memberNameView by bind<TextView>(R.id.messageMemberNameView)
override val timeView by bind<TextView>(R.id.messageTimeView)
val fileLayout by bind<ViewGroup>(R.id.messageFileLayout)
val fileImageView by bind<ImageView>(R.id.messageFileImageView)
val filenameView by bind<TextView>(R.id.messageFilenameView)
}


}

View File

@ -24,27 +24,27 @@ import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder import im.vector.riotredesign.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.riotredesign.features.media.MediaContentRenderer import im.vector.riotredesign.features.media.ImageContentRenderer


@EpoxyModelClass(layout = R.layout.item_timeline_event_image_message) @EpoxyModelClass(layout = R.layout.item_timeline_event_image_video_message)
abstract class MessageImageItem : AbsMessageItem<MessageImageItem.Holder>() { abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Holder>() {


@EpoxyAttribute lateinit var mediaData: MediaContentRenderer.Data @EpoxyAttribute lateinit var mediaData: ImageContentRenderer.Data
@EpoxyAttribute lateinit var eventId: String
@EpoxyAttribute override lateinit var informationData: MessageInformationData @EpoxyAttribute override lateinit var informationData: MessageInformationData
@EpoxyAttribute var playable: Boolean = false
@EpoxyAttribute var clickListener: View.OnClickListener? = null @EpoxyAttribute var clickListener: View.OnClickListener? = null


override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
MediaContentRenderer.render(mediaData, MediaContentRenderer.Mode.THUMBNAIL, holder.imageView) ImageContentRenderer.render(mediaData, ImageContentRenderer.Mode.THUMBNAIL, holder.imageView)
ContentUploadStateTrackerBinder.bind(eventId, mediaData, holder.progressLayout) ContentUploadStateTrackerBinder.bind(informationData.eventId, mediaData, holder.progressLayout)
holder.imageView.setOnClickListener(clickListener) holder.imageView.setOnClickListener(clickListener)
holder.imageView.isEnabled = !mediaData.isLocalFile() holder.imageView.renderSendState()
holder.imageView.alpha = if (mediaData.isLocalFile()) 0.5f else 1f holder.playContentView.visibility = if (playable) View.VISIBLE else View.GONE
} }


override fun unbind(holder: Holder) { override fun unbind(holder: Holder) {
ContentUploadStateTrackerBinder.unbind(eventId) ContentUploadStateTrackerBinder.unbind(informationData.eventId)
super.unbind(holder) super.unbind(holder)
} }


@ -52,8 +52,9 @@ abstract class MessageImageItem : AbsMessageItem<MessageImageItem.Holder>() {
override val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView) override val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
override val memberNameView by bind<TextView>(R.id.messageMemberNameView) override val memberNameView by bind<TextView>(R.id.messageMemberNameView)
override val timeView by bind<TextView>(R.id.messageTimeView) override val timeView by bind<TextView>(R.id.messageTimeView)
val progressLayout by bind<ViewGroup>(R.id.messageImageUploadProgressLayout) val progressLayout by bind<ViewGroup>(R.id.messageMediaUploadProgressLayout)
val imageView by bind<ImageView>(R.id.messageImageView) val imageView by bind<ImageView>(R.id.messageThumbnailView)
val playContentView by bind<ImageView>(R.id.messageMediaPlayView)
} }


} }

View File

@ -16,7 +16,12 @@


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


import im.vector.matrix.android.api.session.room.send.SendState

data class MessageInformationData( data class MessageInformationData(
val eventId: String,
val senderId: String,
val sendState: SendState,
val time: CharSequence? = null, val time: CharSequence? = null,
val avatarUrl: String?, val avatarUrl: String?,
val memberName: CharSequence? = null, val memberName: CharSequence? = null,

View File

@ -45,6 +45,7 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
TextViewCompat.getTextMetricsParams(holder.messageView), TextViewCompat.getTextMetricsParams(holder.messageView),
null) null)
holder.messageView.setTextFuture(textFuture) holder.messageView.setTextFuture(textFuture)
holder.messageView.renderSendState()
findPillsAndProcess { it.bind(holder.messageView) } findPillsAndProcess { it.bind(holder.messageView) }
} }



View File

@ -30,11 +30,12 @@ abstract class NoticeItem : VectorEpoxyModel<NoticeItem.Holder>() {


@EpoxyAttribute var noticeText: CharSequence? = null @EpoxyAttribute var noticeText: CharSequence? = null
@EpoxyAttribute var avatarUrl: String? = null @EpoxyAttribute var avatarUrl: String? = null
@EpoxyAttribute var userId: String = ""
@EpoxyAttribute var memberName: CharSequence? = null @EpoxyAttribute var memberName: CharSequence? = null


override fun bind(holder: Holder) { override fun bind(holder: Holder) {
holder.noticeTextView.text = noticeText holder.noticeTextView.text = noticeText
AvatarRenderer.render(avatarUrl, memberName?.toString(), holder.avatarImageView) AvatarRenderer.render(avatarUrl, userId, memberName?.toString(), holder.avatarImageView)
} }


class Holder : VectorEpoxyHolder() { class Holder : VectorEpoxyHolder() {

View File

@ -79,6 +79,7 @@ class RoomSummaryController(private val stringProvider: StringProvider


roomSummaryItem { roomSummaryItem {
id(roomSummary.roomId) id(roomSummary.roomId)
roomId(roomSummary.roomId)
roomName(roomSummary.displayName) roomName(roomSummary.displayName)
avatarUrl(roomSummary.avatarUrl) avatarUrl(roomSummary.avatarUrl)
selected(isSelected) selected(isSelected)

View File

@ -31,6 +31,7 @@ import im.vector.riotredesign.features.home.AvatarRenderer
abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() { abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {


@EpoxyAttribute lateinit var roomName: CharSequence @EpoxyAttribute lateinit var roomName: CharSequence
@EpoxyAttribute lateinit var roomId: String
@EpoxyAttribute var avatarUrl: String? = null @EpoxyAttribute var avatarUrl: String? = null
@EpoxyAttribute var selected: Boolean = false @EpoxyAttribute var selected: Boolean = false
@EpoxyAttribute var unreadCount: Int = 0 @EpoxyAttribute var unreadCount: Int = 0
@ -44,7 +45,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
holder.rootView.isChecked = selected holder.rootView.isChecked = selected
holder.rootView.setOnClickListener { listener?.invoke() } holder.rootView.setOnClickListener { listener?.invoke() }
holder.titleView.text = roomName holder.titleView.text = roomName
AvatarRenderer.render(avatarUrl, roomName.toString(), holder.avatarImageView) AvatarRenderer.render(avatarUrl, roomId, roomName.toString(), holder.avatarImageView)
} }


class Holder : VectorEpoxyHolder() { class Holder : VectorEpoxyHolder() {

View File

@ -52,7 +52,7 @@ class PillImageSpan(private val glideRequests: GlideRequests,
@UiThread @UiThread
fun bind(textView: TextView) { fun bind(textView: TextView) {
tv = WeakReference(textView) tv = WeakReference(textView)
AvatarRenderer.render(context, glideRequests, user?.avatarUrl, displayName, target) AvatarRenderer.render(context, glideRequests, user?.avatarUrl, userId, displayName, target)
} }


// ReplacementSpan ***************************************************************************** // ReplacementSpan *****************************************************************************
@ -105,7 +105,7 @@ class PillImageSpan(private val glideRequests: GlideRequests,
textStartPadding = textPadding textStartPadding = textPadding
setChipMinHeightResource(R.dimen.pill_min_height) setChipMinHeightResource(R.dimen.pill_min_height)
setChipIconSizeResource(R.dimen.pill_avatar_size) setChipIconSizeResource(R.dimen.pill_avatar_size)
chipIcon = AvatarRenderer.getPlaceholderDrawable(context, displayName) chipIcon = AvatarRenderer.getPlaceholderDrawable(context, userId, displayName)
setBounds(0, 0, intrinsicWidth, intrinsicHeight) setBounds(0, 0, intrinsicWidth, intrinsicHeight)
} }
} }

View File

@ -27,7 +27,7 @@ import im.vector.riotredesign.core.glide.GlideApp
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import java.io.File import java.io.File


object MediaContentRenderer { object ImageContentRenderer {


@Parcelize @Parcelize
data class Data( data class Data(
@ -37,8 +37,8 @@ object MediaContentRenderer {
val maxHeight: Int, val maxHeight: Int,
val width: Int?, val width: Int?,
val maxWidth: Int, val maxWidth: Int,
val orientation: Int?, val orientation: Int? = null,
val rotation: Int? val rotation: Int? = null
) : Parcelable { ) : Parcelable {


fun isLocalFile(): Boolean { fun isLocalFile(): Boolean {
@ -66,6 +66,7 @@ object MediaContentRenderer {
GlideApp GlideApp
.with(imageView) .with(imageView)
.load(resolvedUrl) .load(resolvedUrl)
.dontAnimate()
.thumbnail(0.3f) .thumbnail(0.3f)
.into(imageView) .into(imageView)
} }
@ -73,16 +74,12 @@ object MediaContentRenderer {
fun render(data: Data, imageView: BigImageView) { fun render(data: Data, imageView: BigImageView) {
val (width, height) = processSize(data, Mode.THUMBNAIL) val (width, height) = processSize(data, Mode.THUMBNAIL)
val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver() val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver()
if (data.isLocalFile()) { val fullSize = contentUrlResolver.resolveFullSize(data.url)
imageView.showImage(Uri.parse(data.url)) val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
} else { imageView.showImage(
val fullSize = contentUrlResolver.resolveFullSize(data.url) Uri.parse(thumbnail),
val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE) Uri.parse(fullSize)
imageView.showImage( )
Uri.parse(thumbnail),
Uri.parse(fullSize)
)
}
} }


private fun processSize(data: Data, mode: Mode): Pair<Int, Int> { private fun processSize(data: Data, mode: Mode): Pair<Int, Int> {

View File

@ -25,26 +25,26 @@ import androidx.appcompat.widget.Toolbar
import com.github.piasy.biv.indicator.progresspie.ProgressPieIndicator import com.github.piasy.biv.indicator.progresspie.ProgressPieIndicator
import com.github.piasy.biv.view.GlideImageViewFactory import com.github.piasy.biv.view.GlideImageViewFactory
import im.vector.riotredesign.core.platform.VectorBaseActivity import im.vector.riotredesign.core.platform.VectorBaseActivity
import kotlinx.android.synthetic.main.activity_media_viewer.* import kotlinx.android.synthetic.main.activity_image_media_viewer.*




class MediaViewerActivity : VectorBaseActivity() { class ImageMediaViewerActivity : VectorBaseActivity() {


override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(im.vector.riotredesign.R.layout.activity_media_viewer) setContentView(im.vector.riotredesign.R.layout.activity_image_media_viewer)
val mediaData = intent.getParcelableExtra<MediaContentRenderer.Data>(EXTRA_MEDIA_DATA) val mediaData = intent.getParcelableExtra<ImageContentRenderer.Data>(EXTRA_MEDIA_DATA)
if (mediaData.url.isNullOrEmpty()) { if (mediaData.url.isNullOrEmpty()) {
finish() finish()
} else { } else {
configureToolbar(mediaViewerToolbar, mediaData) configureToolbar(imageMediaViewerToolbar, mediaData)
mediaViewerImageView.setImageViewFactory(GlideImageViewFactory()) imageMediaViewerImageView.setImageViewFactory(GlideImageViewFactory())
mediaViewerImageView.setProgressIndicator(ProgressPieIndicator()) imageMediaViewerImageView.setProgressIndicator(ProgressPieIndicator())
MediaContentRenderer.render(mediaData, mediaViewerImageView) ImageContentRenderer.render(mediaData, imageMediaViewerImageView)
} }
} }


private fun configureToolbar(toolbar: Toolbar, mediaData: MediaContentRenderer.Data) { private fun configureToolbar(toolbar: Toolbar, mediaData: ImageContentRenderer.Data) {
setSupportActionBar(toolbar) setSupportActionBar(toolbar)
supportActionBar?.apply { supportActionBar?.apply {
title = mediaData.filename title = mediaData.filename
@ -57,8 +57,8 @@ class MediaViewerActivity : VectorBaseActivity() {


private const val EXTRA_MEDIA_DATA = "EXTRA_MEDIA_DATA" private const val EXTRA_MEDIA_DATA = "EXTRA_MEDIA_DATA"


fun newIntent(context: Context, mediaData: MediaContentRenderer.Data): Intent { fun newIntent(context: Context, mediaData: ImageContentRenderer.Data): Intent {
return Intent(context, MediaViewerActivity::class.java).apply { return Intent(context, ImageMediaViewerActivity::class.java).apply {
putExtra(EXTRA_MEDIA_DATA, mediaData) putExtra(EXTRA_MEDIA_DATA, mediaData)
} }
} }

View File

@ -0,0 +1,41 @@
/*
* 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.riotredesign.features.media

import android.os.Parcelable
import android.widget.ImageView
import android.widget.VideoView
import im.vector.matrix.android.api.Matrix
import kotlinx.android.parcel.Parcelize

object VideoContentRenderer {

@Parcelize
data class Data(
val filename: String,
val videoUrl: String?,
val thumbnailMediaData: ImageContentRenderer.Data
) : Parcelable

fun render(data: Data, thumbnailView: ImageView, videoView: VideoView) {
val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver()
val resolvedUrl = contentUrlResolver.resolveFullSize(data.videoUrl)
videoView.setVideoPath(resolvedUrl)
videoView.start()
}

}

View File

@ -0,0 +1,62 @@
/*
* 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.riotredesign.features.media

import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.widget.Toolbar
import im.vector.riotredesign.core.platform.VectorBaseActivity
import kotlinx.android.synthetic.main.activity_video_media_viewer.*


class VideoMediaViewerActivity : VectorBaseActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(im.vector.riotredesign.R.layout.activity_video_media_viewer)
val mediaData = intent.getParcelableExtra<VideoContentRenderer.Data>(EXTRA_MEDIA_DATA)
if (mediaData.videoUrl.isNullOrEmpty()) {
finish()
} else {
configureToolbar(videoMediaViewerToolbar, mediaData)
VideoContentRenderer.render(mediaData, videoMediaViewerThumbnailView, videoMediaViewerVideoView)
}
}

private fun configureToolbar(toolbar: Toolbar, mediaData: VideoContentRenderer.Data) {
setSupportActionBar(toolbar)
supportActionBar?.apply {
title = mediaData.filename
setHomeButtonEnabled(true)
setDisplayHomeAsUpEnabled(true)
}
}

companion object {

private const val EXTRA_MEDIA_DATA = "EXTRA_MEDIA_DATA"

fun newIntent(context: Context, mediaData: VideoContentRenderer.Data): Intent {
return Intent(context, VideoMediaViewerActivity::class.java).apply {
putExtra(EXTRA_MEDIA_DATA, mediaData)
}
}
}


}

View File

@ -6,7 +6,7 @@
android:orientation="vertical"> android:orientation="vertical">


<androidx.appcompat.widget.Toolbar <androidx.appcompat.widget.Toolbar
android:id="@+id/mediaViewerToolbar" android:id="@+id/imageMediaViewerToolbar"
style="@style/VectorToolbarStyle" style="@style/VectorToolbarStyle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:layout_height="?attr/actionBarSize"
@ -15,7 +15,7 @@
app:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" /> app:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" />


<com.github.piasy.biv.view.BigImageView <com.github.piasy.biv.view.BigImageView
android:id="@+id/mediaViewerImageView" android:id="@+id/imageMediaViewerImageView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:failureImageInitScaleType="center" app:failureImageInitScaleType="center"

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<androidx.appcompat.widget.Toolbar
android:id="@+id/videoMediaViewerToolbar"
style="@style/VectorToolbarStyle"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
app:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" />

<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<ImageView
android:id="@+id/videoMediaViewerThumbnailView"
android:layout_width="match_parent"
android:layout_height="match_parent" />

<VideoView
android:id="@+id/videoMediaViewerVideoView"
android:layout_width="match_parent"
android:layout_height="match_parent" />

</FrameLayout>

</LinearLayout>

View File

@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->

<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:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="16dp"
android:paddingRight="16dp">


<ImageView
android:id="@+id/messageAvatarImageView"
android:layout_width="@dimen/chat_avatar_size"
android:layout_height="@dimen/chat_avatar_size"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />

<TextView
android:id="@+id/messageMemberNameView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:layout_marginLeft="64dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textSize="15sp"
app:layout_constraintEnd_toStartOf="@+id/messageTimeView"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/full_names" />


<TextView
android:id="@+id/messageTimeView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:duplicateParentState="true"
android:textColor="@color/brown_grey"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintTop_toTopOf="@id/messageMemberNameView"
tools:text="@tools:sample/date/hhmm" />


<LinearLayout
android:id="@+id/messageFileLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:layout_marginLeft="64dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="32dp"
android:layout_marginRight="32dp"
android:layout_marginBottom="8dp"
android:duplicateParentState="true"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageMemberNameView">

<ImageView
android:id="@+id/messageFilee2eIcon"
android:layout_width="14dp"
android:layout_height="14dp"
android:src="@drawable/e2e_verified"
android:visibility="gone" />

<!-- the media type -->
<ImageView
android:id="@+id/messageFileImageView"
android:layout_width="@dimen/chat_avatar_size"
android:layout_height="@dimen/chat_avatar_size"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:src="@drawable/filetype_image" />

<!-- the media -->
<TextView
android:id="@+id/messageFilenameView"
android:layout_width="0dp"
android:layout_height="@dimen/chat_avatar_size"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:layout_weight="1"
android:autoLink="none"
android:gravity="center_vertical"
tools:text="A filename here" />

</LinearLayout>


<include
android:id="@+id/messageMediaUploadProgressLayout"
layout="@layout/media_upload_download_progress_layout"
android:layout_width="0dp"
android:layout_height="46dp"
android:layout_marginStart="64dp"
android:layout_marginLeft="64dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="32dp"
android:layout_marginRight="32dp"
android:layout_marginBottom="8dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageFileLayout"
tools:visibility="visible" />


</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,4 +1,19 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->

<androidx.constraintlayout.widget.ConstraintLayout 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:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
@ -43,6 +58,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:duplicateParentState="true"
android:textColor="@color/brown_grey" android:textColor="@color/brown_grey"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0" app:layout_constraintHorizontal_bias="0.0"
@ -50,7 +66,7 @@
tools:text="@tools:sample/date/hhmm" /> tools:text="@tools:sample/date/hhmm" />


<ImageView <ImageView
android:id="@+id/messageImageView" android:id="@+id/messageThumbnailView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginStart="64dp" android:layout_marginStart="64dp"
@ -59,13 +75,28 @@
android:layout_marginEnd="32dp" android:layout_marginEnd="32dp"
android:layout_marginRight="32dp" android:layout_marginRight="32dp"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:duplicateParentState="true"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0" app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageMemberNameView" /> app:layout_constraintTop_toBottomOf="@+id/messageMemberNameView"
tools:layout_height="300dp" />

<ImageView
android:id="@+id/messageMediaPlayView"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_material_play_circle"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/messageThumbnailView"
app:layout_constraintEnd_toEndOf="@id/messageThumbnailView"
app:layout_constraintStart_toStartOf="@id/messageThumbnailView"
app:layout_constraintTop_toTopOf="@id/messageThumbnailView"
tools:visibility="visible" />



<include <include
android:id="@+id/messageImageUploadProgressLayout" android:id="@+id/messageMediaUploadProgressLayout"
layout="@layout/media_upload_download_progress_layout" layout="@layout/media_upload_download_progress_layout"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="46dp" android:layout_height="46dp"
@ -78,7 +109,7 @@
android:visibility="gone" android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageImageView" app:layout_constraintTop_toBottomOf="@+id/messageThumbnailView"
tools:visibility="visible" /> tools:visibility="visible" />




View File

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

<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:paddingLeft="16dp"
android:paddingRight="16dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<include
android:id="@+id/itemMergedAvatarListView"
layout="@layout/vector_message_merge_avatar_list"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:layout_marginLeft="64dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
app:layout_constraintEnd_toStartOf="@+id/itemMergedExpandTextView"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<TextView
android:id="@+id/itemMergedExpandTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingRight="8dp"
android:layout_marginTop="2dp"
android:paddingLeft="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:text="@string/merged_events_expand"
android:textColor="?attr/colorAccent"
android:textSize="14sp"
android:textStyle="italic"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<View
android:id="@+id/itemMergedSeparatorView"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginTop="4dp"
android:background="?attr/colorAccent"
app:layout_constraintEnd_toEndOf="@id/itemMergedExpandTextView"
app:layout_constraintStart_toStartOf="@id/itemMergedAvatarListView"
app:layout_constraintTop_toBottomOf="@id/itemMergedExpandTextView" />

<TextView
android:id="@+id/itemMergedSummaryTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="?android:textColorSecondary"
android:textIsSelectable="false"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/itemMergedAvatarListView"
app:layout_constraintTop_toBottomOf="@id/itemMergedSeparatorView"
tools:text="3 membership changes" />

</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -44,6 +44,7 @@
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:textColor="@color/brown_grey" android:textColor="@color/brown_grey"
android:duplicateParentState="true"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0" app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintTop_toTopOf="@id/messageMemberNameView" app:layout_constraintTop_toTopOf="@id/messageMemberNameView"
@ -56,6 +57,7 @@
android:layout_marginStart="64dp" android:layout_marginStart="64dp"
android:layout_marginLeft="64dp" android:layout_marginLeft="64dp"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:duplicateParentState="true"
android:textColor="@color/dark_grey" android:textColor="@color/dark_grey"
android:textSize="14sp" android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"

View File

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

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">

<ImageView
android:id="@+id/mels_list_avatar_1"
android:layout_width="20dp"
android:layout_height="20dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />

<ImageView
android:id="@+id/mels_list_avatar_2"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="-5dp"
android:layout_marginLeft="-5dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />

<ImageView
android:id="@+id/mels_list_avatar_3"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="-5dp"
android:layout_marginLeft="-5dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />

<ImageView
android:id="@+id/mels_list_avatar_4"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="-5dp"
android:layout_marginLeft="-5dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />

<ImageView
android:id="@+id/mels_list_avatar_5"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="-5dp"
android:layout_marginLeft="-5dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />

</LinearLayout>