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.api.session.room.timeline.Timeline
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.TimelineEventFactory
import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEventPersistor
@ -57,7 +57,7 @@ internal class TimelineTest : InstrumentedTest {
val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy)
val paginationTask = FakePaginationTask(tokenChunkEventPersistor)
val getContextOfEventTask = FakeGetContextOfEventTask(tokenChunkEventPersistor)
val roomMemberExtractor = RoomMemberExtractor(ROOM_ID)
val roomMemberExtractor = SenderRoomMemberExtractor(ROOM_ID)
val timelineEventFactory = TimelineEventFactory(roomMemberExtractor)
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 width: Long? = 0,
val name: String? = null,
val path: String? = null,
val mimeType: String? = null,
val path: String,
val mimeType: String,
val type: Type
) : Parcelable {


View File

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

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 {
fun onUpdate(state: State)

View File

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


View File

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

internal fun ChunkEntity.lastDisplayIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {

View File

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

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

scope(DefaultSession.SCOPE) {

View File

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

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

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

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

internal fun setFailure(eventId: String) {
override fun setFailure(key: String) {
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
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)
updateState(eventId, progressData)
updateState(key, progressData)
}

private fun updateState(eventId: String, state: ContentUploadStateTracker.State) {
progressByEvent[eventId] = state
private fun updateState(key: String, state: ContentUploadStateTracker.State) {
states[key] = state
mainHandler.post {
listenersByEvent[eventId]?.also { listeners ->
listeners[key]?.also { listeners ->
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.Companion.raise
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.network.ProgressRequestBody
import okhttp3.HttpUrl
@ -31,44 +30,51 @@ import java.io.File
import java.io.IOException


internal class ContentUploader(private val okHttpClient: OkHttpClient,
private val sessionParams: SessionParams,
private val contentUploadProgressTracker: DefaultContentUploadStateTracker) {
internal class FileUploader(private val okHttpClient: OkHttpClient,
private val sessionParams: SessionParams) {

private val uploadUrl = sessionParams.homeServerConnectionConfig.homeServerUri.toString() + URI_PREFIX_CONTENT_API + "upload"

private val moshi = MoshiProvider.providesMoshi()
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()
?: return raise(RuntimeException())
fun uploadFile(file: File,
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
.addQueryParameter(
"filename", attachment.name
).build()
.addQueryParameter("filename", filename)
.build()

val requestBody = RequestBody.create(
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 requestBody = if (progressListener != null) ProgressRequestBody(uploadBody, progressListener) else uploadBody

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

val result = Try {
return Try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
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 com.squareup.moshi.JsonClass
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.toContent
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.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.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.util.WorkerParamsFactory
import org.koin.standalone.inject
import timber.log.Timber
import java.io.File


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

if (params.event.eventId == null) {
val eventId = params.event.eventId ?: 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 }
)
}

val progressListener = object : ProgressRequestBody.Listener {
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 createAttachmentFile(attachment: ContentAttachmentData): File? {
return try {
File(attachment.path)
} catch (e: Exception) {
Timber.e(e)
null
}
}

private fun handleFailure(params: Params): Result {
contentUploadProgressTracker.setFailure(params.event.eventId!!)
return Result.failure()
}
return mediaUploader
.uploadFile(params.event.eventId, params.attachment)
.fold({ handleFailure() }, { handleSuccess(params, it) })
}

private fun handleFailure(): Result {
return Result.retry()
}

private fun handleSuccess(params: Params, contentUploadResponse: ContentUploadResponse): Result {
val event = updateEvent(params.event, contentUploadResponse.contentUri)
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)
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 updatedContent = when (messageContent) {
is MessageImageContent -> messageContent.update(url)
is MessageVideoContent -> messageContent.update(url, thumbnailUrl)
is MessageFileContent -> messageContent.update(url)
is MessageAudioContent -> messageContent.update(url)
else -> messageContent
}
return event.copy(content = updatedContent.toContent())
@ -78,6 +124,18 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters)
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.members.DefaultRoomMembersService
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.SetReadMarkersTask
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) {

fun instantiate(roomId: String): Room {
val roomMemberExtractor = RoomMemberExtractor(roomId)
val roomMemberExtractor = SenderRoomMemberExtractor(roomId)
val timelineEventFactory = TimelineEventFactory(roomMemberExtractor)
val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask)
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> {
return EventEntity
.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.room.model.RoomMember
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.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.prev
import im.vector.matrix.android.internal.database.query.where
import io.realm.Realm
import io.realm.RealmList
import io.realm.RealmQuery

internal class RoomMemberExtractor(private val roomId: String) {

private val cached = HashMap<String, RoomMember?>()
internal class SenderRoomMemberExtractor(private val roomId: String) {

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

private fun baseQuery(realm: Realm,
roomId: String,
val fallbackContent = content
?: baseQuery(roomEntity.untimelinedStateEvents, sender, unlinked).prev(since = event.stateIndex)?.content

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

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

val filterMode = if (isUnlinked) EventEntity.LinkFilterMode.UNLINKED_ONLY else EventEntity.LinkFilterMode.LINKED_ONLY
return EventEntity
.where(realm, roomId = roomId, type = EventType.STATE_ROOM_MEMBER, linkFilterMode = filterMode)
return list
.where()
.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

import android.media.MediaMetadataRetriever
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.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.MessageType
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.internal.session.content.ThumbnailExtractor

internal class LocalEchoEventFactory(private val credentials: Credentials) {

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

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(
type = MessageType.MSGTYPE_VIDEO,
body = attachment.name ?: "video",
info = VideoInfo(
mimeType = attachment.mimeType ?: "video/mpeg",
width = attachment.width?.toInt() ?: 0,
height = attachment.height?.toInt() ?: 0,
mimeType = attachment.mimeType,
width = width,
height = height,
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
)

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.util.CancelableBag
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.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
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 java.util.*
import java.util.concurrent.atomic.AtomicBoolean
@ -87,9 +97,16 @@ internal class DefaultTimeline(


private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<EventEntity>> { _, changeSet ->
if (changeSet.state == OrderedCollectionChangeSet.State.INITIAL) {
if (changeSet.state == OrderedCollectionChangeSet.State.INITIAL ) {
handleInitialLoad()
} 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 ->
val (startDisplayIndex, direction) = if (range.startIndex == 0) {
Pair(liveEvents[range.length - 1]!!.displayIndex, Timeline.Direction.FORWARDS)
@ -108,6 +125,7 @@ internal class DefaultTimeline(
buildTimelineEvents(startDisplayIndex, direction, range.length.toLong())
postSnapshot()
}

}
}
}

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.internal.database.mapper.asDomain
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 {
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(
eventEntity.asDomain(),
eventEntity.localId,
eventEntity.displayIndex,
roomMember,
senderData.senderName,
senderData.senderAvatar,
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.bumptech.glide:glide:$glide_version"
kapt "com.github.bumptech.glide:compiler:$glide_version"
implementation 'com.danikula:videocache:2.7.1'

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

View File

@ -29,7 +29,7 @@

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

<service
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 im.vector.riotredesign.BuildConfig
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.BugReporter
import im.vector.riotredesign.features.rageshake.RageShake
@ -284,9 +285,9 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
* PUBLIC METHODS
* ========================================================================================== */

protected fun showSnackbar(message: String) {
fun showSnackbar(message: String) {
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
* ========================================================================================== */

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

}

View File

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

View File

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

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

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

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

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.MatrixPatterns
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.riotredesign.R
import im.vector.riotredesign.core.glide.GlideApp
@ -44,39 +43,41 @@ object AvatarRenderer {

private const val THUMBNAIL_SIZE = 250

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

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

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

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

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


// 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> {
val resolvedUrl = Matrix.getInstance().currentSession!!.contentUrlResolver()
.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
groupSummaryItem {
id(groupSummary.groupId)
groupId(groupSummary.groupId)
groupName(groupSummary.displayName)
selected(isSelected)
avatarUrl(groupSummary.avatarUrl)

View File

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

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

class Holder : VectorEpoxyHolder() {

View File

@ -37,6 +37,10 @@ import com.otaliastudios.autocomplete.Autocomplete
import com.otaliastudios.autocomplete.AutocompleteCallback
import com.otaliastudios.autocomplete.CharPolicy
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.user.model.User
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.helper.EndlessRecyclerViewScrollListener
import im.vector.riotredesign.features.html.PillImageSpan
import im.vector.riotredesign.features.media.MediaContentRenderer
import im.vector.riotredesign.features.media.MediaViewerActivity
import im.vector.riotredesign.features.media.ImageContentRenderer
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.synthetic.main.fragment_room_detail.*
import org.koin.android.ext.android.inject
@ -367,7 +373,7 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
.show()
}

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

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

override fun onMediaClicked(mediaData: MediaContentRenderer.Data, view: View) {
val intent = MediaViewerActivity.newIntent(vectorBaseActivity, mediaData)
override fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View) {
val intent = ImageMediaViewerActivity.newIntent(vectorBaseActivity, mediaData)
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?) {
textComposerViewModel.process(TextComposerActions.QueryUsers(query))

View File

@ -24,16 +24,32 @@ import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyController
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.TimelineEvent
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.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.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,
private val timelineItemFactory: TimelineItemFactory,
@ -44,10 +60,16 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
interface Callback {
fun onEventVisible(event: TimelineEvent)
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 inSubmitList: Boolean = false
private var timeline: Timeline? = null
@ -60,7 +82,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
override fun onChanged(position: Int, count: Int, payload: Any?) {
assertUpdateCallbacksAllowed()
(position until (position + count)).forEach {
modelCache[it] = emptyList()
modelCache[it] = null
}
requestModelBuild()
}
@ -76,11 +98,8 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
@Synchronized
override fun onInserted(position: Int, count: Int) {
assertUpdateCallbacksAllowed()
if (modelCache.isNotEmpty() && position == modelCache.size) {
modelCache[position - 1] = emptyList()
}
(0 until count).forEach {
modelCache.add(position, emptyList())
modelCache.add(position, null)
}
requestModelBuild()
}
@ -116,7 +135,6 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
.id("forward_loading_item")
.addWhen(Timeline.Direction.FORWARDS)


val timelineModels = getModels()
add(timelineModels)

@ -149,53 +167,110 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
@Synchronized
private fun getModels(): List<EpoxyModel<*>> {
(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)
}
}
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 nextEvent = items.nextDisplayableEvent(currentPosition)

val date = event.root.localDateTime()
val nextDate = nextEvent?.root?.localDateTime()
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.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 daySeparatorItem = DaySeparatorItem_().formattedDay(formattedDay).id(formattedDay)
epoxyModels.add(daySeparatorItem)
DaySeparatorItem_().formattedDay(formattedDay).id(formattedDay)
} 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) {
val shouldAdd = timeline?.let {
it.hasMoreToLoad(direction)
} ?: false
val shouldAdd = timeline?.hasMoreToLoad(direction) ?: false
addIf(shouldAdd, this@TimelineEventController)
}

}

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


}
private data class CacheItemData(
val localId: String,
val eventModel: EpoxyModel<*>? = null,
val mergedHeaderModel: MergedHeaderItem? = null,
val formattedDayModel: DaySeparatorItem? = null
)

View File

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

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

private fun buildNoticeText(event: Event, roomMember: RoomMember): CharSequence? {
private fun buildNoticeText(event: Event, senderName: String?): CharSequence? {
return when {
EventType.CALL_INVITE == event.type -> {
val content = event.content.toModel<CallInviteContent>() ?: return null
val isVideoCall = content.offer.sdp == CallInviteContent.Offer.SDP_VIDEO
return if (isVideoCall) {
stringProvider.getString(R.string.notice_placed_video_call, roomMember.displayName)
stringProvider.getString(R.string.notice_placed_video_call, senderName)
} 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_HANGUP == event.type -> stringProvider.getString(R.string.notice_ended_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, senderName)
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.SpannableStringBuilder
import androidx.annotation.ColorRes
import im.vector.matrix.android.api.permalinks.MatrixLinkify
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.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.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.MessageNoticeContent
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.riotredesign.R
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.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.MessageImageItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageFileItem
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.MessageTextItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem_
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

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

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

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

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

val messageContent: MessageContent = event.root.content.toModel() ?: return null
val time = timelineDateFormatter.formatMessageHour(date)
val avatarUrl = roomMember?.avatarUrl
val memberName = roomMember?.displayName ?: event.root.sender
val informationData = MessageInformationData(time, avatarUrl, memberName, showInformation)
val avatarUrl = event.senderAvatar
val memberName = event.senderName ?: event.root.sender ?: ""
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) {
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageTextContent -> buildTextMessageItem(event.sendState, messageContent, informationData, callback)
is MessageImageContent -> buildImageMessageItem(eventId, messageContent, informationData, callback)
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageTextContent -> buildTextMessageItem(messageContent, informationData, callback)
is MessageImageContent -> buildImageMessageItem(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)
}
}

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? {
val text = "${messageContent.type} message events are not yet handled"
return DefaultItem_().text(text)
}

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

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

private fun buildTextMessageItem(sendState: SendState,
messageContent: MessageTextContent,
private fun buildVideoMessageItem(messageContent: MessageVideoContent,
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,
callback: TimelineEventController.Callback?): MessageTextItem? {

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

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

fun create(event: TimelineEvent): NoticeItem? {
val roomMember = event.roomMember ?: return null
val noticeText = buildNoticeText(event.root, roomMember) ?: return null
val noticeText = buildNoticeText(event.root, event.senderName) ?: return null
return NoticeItem_()
.noticeText(noticeText)
.avatarUrl(roomMember.avatarUrl)
.memberName(roomMember.displayName)
.avatarUrl(event.senderAvatar)
.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 formattedVisibility = when (content.historyVisibility) {
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.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.riotredesign.R
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_

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

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 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
return if (isMembershipEvent) {
buildMembershipNotice(event, eventContent, prevEventContent)
@ -71,7 +75,7 @@ class RoomMemberItemFactory(private val stringProvider: StringProvider) {
displayText.append(" ")
stringProvider.getString(R.string.notice_avatar_changed_too)
} 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)
}
@ -79,12 +83,12 @@ class RoomMemberItemFactory(private val stringProvider: StringProvider) {
}

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
return when {
Membership.INVITE == eventContent?.membership -> {
// TODO get userId
val selfUserId: String = ""
val selfUserId = ""
when {
eventContent.thirdPartyInvite != null ->
stringProvider.getString(R.string.notice_room_third_party_registered_invite,
@ -105,7 +109,8 @@ class RoomMemberItemFactory(private val stringProvider: StringProvider) {
if (prevEventContent?.membership == Membership.INVITE) {
stringProvider.getString(R.string.notice_room_reject, senderDisplayName)
} 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) {
stringProvider.getString(R.string.notice_room_withdraw, senderDisplayName, targetDisplayName)

View File

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

fun create(event: TimelineEvent): NoticeItem? {

val content: RoomNameContent? = event.root.content.toModel()
val roomMember = event.roomMember
if (content == null || roomMember == null) {
return null
}
val content: RoomNameContent = event.root.content.toModel() ?: return null
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 {
stringProvider.getString(R.string.notice_room_name_removed, roomMember.displayName)
stringProvider.getString(R.string.notice_room_name_removed, event.senderName)
}
return NoticeItem_()
.noticeText(text)
.avatarUrl(roomMember.avatarUrl)
.memberName(roomMember.displayName)
.avatarUrl(event.senderAvatar)
.memberName(event.senderName)
}



View File

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

fun create(event: TimelineEvent): NoticeItem? {

val content: RoomTopicContent? = event.root.content.toModel()
val roomMember = event.roomMember
if (content == null || roomMember == null) {
return null
}
val content: RoomTopicContent = event.root.content.toModel() ?: return null
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 {
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_()
.noticeText(text)
.avatarUrl(roomMember.avatarUrl)
.memberName(roomMember.displayName)
.avatarUrl(event.senderAvatar)
.memberName(event.senderName)
}



View File

@ -57,7 +57,7 @@ class TimelineItemFactory(private val messageItemFactory: MessageItemFactory,
} catch (e: Exception) {
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.session.content.ContentUploadStateTracker
import im.vector.riotredesign.R
import im.vector.riotredesign.features.media.MediaContentRenderer
import im.vector.riotredesign.features.media.ImageContentRenderer
import java.io.File

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

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

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

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) {
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.
// 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.

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

// The minimum amount of items to have below your current scroll position
// 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
// We check to see if the dataset count has
// changed, if so we conclude it has finished loading
if (totalItemCount > previousTotalItemCount) {
if (totalItemCount != previousTotalItemCount) {
previousTotalItemCount = totalItemCount
loadingBackwards = false
loadingForwards = false
previousTotalItemCount = totalItemCount
}
// If it isnt currently loading, we check to see if we have reached
// 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.room.timeline.TimelineEvent
import im.vector.riotredesign.core.extensions.localDateTime

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? {
return if (index == size - 1) {
return if (index >= size - 1) {
null
} else {
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.text = informationData.time
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 {
holder.avatarImageView.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 val avatarImageView: ImageView
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 im.vector.riotredesign.R
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)
abstract class MessageImageItem : AbsMessageItem<MessageImageItem.Holder>() {
@EpoxyModelClass(layout = R.layout.item_timeline_event_image_video_message)
abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Holder>() {

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

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

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

@ -52,8 +52,9 @@ abstract class MessageImageItem : AbsMessageItem<MessageImageItem.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 progressLayout by bind<ViewGroup>(R.id.messageImageUploadProgressLayout)
val imageView by bind<ImageView>(R.id.messageImageView)
val progressLayout by bind<ViewGroup>(R.id.messageMediaUploadProgressLayout)
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

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

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

View File

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


View File

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

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

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

class Holder : VectorEpoxyHolder() {

View File

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

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

View File

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

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

class Holder : VectorEpoxyHolder() {

View File

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

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

View File

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

object MediaContentRenderer {
object ImageContentRenderer {

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

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

private fun processSize(data: Data, mode: Mode): Pair<Int, Int> {
val maxImageWidth = data.maxWidth

View File

@ -25,26 +25,26 @@ import androidx.appcompat.widget.Toolbar
import com.github.piasy.biv.indicator.progresspie.ProgressPieIndicator
import com.github.piasy.biv.view.GlideImageViewFactory
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?) {
super.onCreate(savedInstanceState)
setContentView(im.vector.riotredesign.R.layout.activity_media_viewer)
val mediaData = intent.getParcelableExtra<MediaContentRenderer.Data>(EXTRA_MEDIA_DATA)
setContentView(im.vector.riotredesign.R.layout.activity_image_media_viewer)
val mediaData = intent.getParcelableExtra<ImageContentRenderer.Data>(EXTRA_MEDIA_DATA)
if (mediaData.url.isNullOrEmpty()) {
finish()
} else {
configureToolbar(mediaViewerToolbar, mediaData)
mediaViewerImageView.setImageViewFactory(GlideImageViewFactory())
mediaViewerImageView.setProgressIndicator(ProgressPieIndicator())
MediaContentRenderer.render(mediaData, mediaViewerImageView)
configureToolbar(imageMediaViewerToolbar, mediaData)
imageMediaViewerImageView.setImageViewFactory(GlideImageViewFactory())
imageMediaViewerImageView.setProgressIndicator(ProgressPieIndicator())
ImageContentRenderer.render(mediaData, imageMediaViewerImageView)
}
}

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

private const val EXTRA_MEDIA_DATA = "EXTRA_MEDIA_DATA"

fun newIntent(context: Context, mediaData: MediaContentRenderer.Data): Intent {
return Intent(context, MediaViewerActivity::class.java).apply {
fun newIntent(context: Context, mediaData: ImageContentRenderer.Data): Intent {
return Intent(context, ImageMediaViewerActivity::class.java).apply {
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">

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

<com.github.piasy.biv.view.BigImageView
android:id="@+id/mediaViewerImageView"
android:id="@+id/imageMediaViewerImageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
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"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
@ -43,6 +58,7 @@
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"
@ -50,7 +66,7 @@
tools:text="@tools:sample/date/hhmm" />

<ImageView
android:id="@+id/messageImageView"
android:id="@+id/messageThumbnailView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="64dp"
@ -59,13 +75,28 @@
android:layout_marginEnd="32dp"
android:layout_marginRight="32dp"
android:layout_marginBottom="8dp"
android:duplicateParentState="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
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
android:id="@+id/messageImageUploadProgressLayout"
android:id="@+id/messageMediaUploadProgressLayout"
layout="@layout/media_upload_download_progress_layout"
android:layout_width="0dp"
android:layout_height="46dp"
@ -78,7 +109,7 @@
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageImageView"
app:layout_constraintTop_toBottomOf="@+id/messageThumbnailView"
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_marginLeft="8dp"
android:textColor="@color/brown_grey"
android:duplicateParentState="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintTop_toTopOf="@id/messageMemberNameView"
@ -56,6 +57,7 @@
android:layout_marginStart="64dp"
android:layout_marginLeft="64dp"
android:layout_marginBottom="8dp"
android:duplicateParentState="true"
android:textColor="@color/dark_grey"
android:textSize="14sp"
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>