Design update

+ Reply 
+ Better preview in action menu
This commit is contained in:
Valere 2019-05-27 11:55:20 +02:00
parent b45cc0e63f
commit 0e06908a48
26 changed files with 350 additions and 59 deletions

View File

@ -18,8 +18,10 @@ package im.vector.matrix.android.api.session.events.model
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Types import com.squareup.moshi.Types
import im.vector.matrix.android.internal.di.MoshiProvider import im.vector.matrix.android.internal.di.MoshiProvider
import timber.log.Timber
import java.lang.reflect.ParameterizedType import java.lang.reflect.ParameterizedType
typealias Content = Map<String, @JvmSuppressWildcards Any> typealias Content = Map<String, @JvmSuppressWildcards Any>
@ -31,7 +33,12 @@ inline fun <reified T> Content?.toModel(): T? {
return this?.let { return this?.let {
val moshi = MoshiProvider.providesMoshi() val moshi = MoshiProvider.providesMoshi()
val moshiAdapter = moshi.adapter(T::class.java) val moshiAdapter = moshi.adapter(T::class.java)
return moshiAdapter.fromJsonValue(it) try {
return moshiAdapter.fromJsonValue(it)
} catch (e: JsonDataException) {
Timber.e(e, "Failed to parse content")
return null
}
} }
} }

View File

@ -19,7 +19,7 @@ package im.vector.matrix.android.api.session.room
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.session.room.members.MembershipService import im.vector.matrix.android.api.session.room.members.MembershipService
import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.annotation.ReactionService import im.vector.matrix.android.api.session.room.model.annotation.RelationService
import im.vector.matrix.android.api.session.room.read.ReadService import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.api.session.room.send.SendService import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.session.room.state.StateService import im.vector.matrix.android.api.session.room.state.StateService
@ -28,7 +28,7 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineService
/** /**
* This interface defines methods to interact within a room. * This interface defines methods to interact within a room.
*/ */
interface Room : TimelineService, SendService, ReadService, MembershipService, StateService , ReactionService{ interface Room : TimelineService, SendService, ReadService, MembershipService, StateService , RelationService{
/** /**
* The roomId of this room * The roomId of this room

View File

@ -7,5 +7,7 @@ import com.squareup.moshi.JsonClass
data class ReactionInfo( data class ReactionInfo(
@Json(name = "rel_type") override val type: String?, @Json(name = "rel_type") override val type: String?,
@Json(name = "event_id") override val eventId: String, @Json(name = "event_id") override val eventId: String,
val key: String val key: String,
//always null for reaction
@Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null
) : RelationContent ) : RelationContent

View File

@ -3,4 +3,5 @@ package im.vector.matrix.android.api.session.room.model.annotation
interface RelationContent { interface RelationContent {
val type: String? val type: String?
val eventId: String? val eventId: String?
val inReplyTo: ReplyToContent?
} }

View File

@ -21,5 +21,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class RelationDefaultContent( data class RelationDefaultContent(
@Json(name = "rel_type") override val type: String?, @Json(name = "rel_type") override val type: String?,
@Json(name = "event_id") override val eventId: String? @Json(name = "event_id") override val eventId: String?,
@Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null
) : RelationContent ) : RelationContent

View File

@ -15,10 +15,13 @@
*/ */
package im.vector.matrix.android.api.session.room.model.annotation package im.vector.matrix.android.api.session.room.model.annotation
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.Cancelable
//TODO rename in relationService? //TODO rename in relationService?
interface ReactionService { interface RelationService {
/** /**
@ -59,4 +62,7 @@ interface ReactionService {
*/ */
fun editTextMessage(targetEventId: String, newBodyText: String, compatibilityBodyText: String = "* $newBodyText"): Cancelable fun editTextMessage(targetEventId: String, newBodyText: String, compatibilityBodyText: String = "* $newBodyText"): Cancelable
fun replyToMessage(eventReplied: Event, replyText: String) : Cancelable?
} }

View File

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

View File

@ -20,6 +20,7 @@ import android.content.Context
import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.util.BackgroundDetectionObserver import im.vector.matrix.android.internal.util.BackgroundDetectionObserver
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
import im.vector.matrix.android.internal.util.StringProvider
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.koin.dsl.module.module import org.koin.dsl.module.module
@ -39,6 +40,9 @@ class MatrixModule(private val context: Context) {
single { single {
TaskExecutor(get()) TaskExecutor(get())
} }
single {
StringProvider(context.resources)
}
single { single {
BackgroundDetectionObserver() BackgroundDetectionObserver()

View File

@ -22,7 +22,7 @@ import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.room.Room import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.api.session.room.members.MembershipService import im.vector.matrix.android.api.session.room.members.MembershipService
import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.annotation.ReactionService import im.vector.matrix.android.api.session.room.model.annotation.RelationService
import im.vector.matrix.android.api.session.room.read.ReadService import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.api.session.room.send.SendService import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.session.room.state.StateService import im.vector.matrix.android.api.session.room.state.StateService
@ -40,14 +40,14 @@ internal class DefaultRoom(
private val sendService: SendService, private val sendService: SendService,
private val stateService: StateService, private val stateService: StateService,
private val readService: ReadService, private val readService: ReadService,
private val reactionService: ReactionService, private val relationService: RelationService,
private val roomMembersService: MembershipService private val roomMembersService: MembershipService
) : Room, ) : Room,
TimelineService by timelineService, TimelineService by timelineService,
SendService by sendService, SendService by sendService,
StateService by stateService, StateService by stateService,
ReadService by readService, ReadService by readService,
ReactionService by reactionService, RelationService by relationService,
MembershipService by roomMembersService { MembershipService by roomMembersService {
override val roomSummary: LiveData<RoomSummary> by lazy { override val roomSummary: LiveData<RoomSummary> by lazy {

View File

@ -18,10 +18,10 @@ package im.vector.matrix.android.internal.session.room
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.room.Room import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.internal.session.room.membership.DefaultMembershipService import im.vector.matrix.android.internal.session.room.annotation.DefaultRelationService
import im.vector.matrix.android.internal.session.room.annotation.FindReactionEventForUndoTask import im.vector.matrix.android.internal.session.room.annotation.FindReactionEventForUndoTask
import im.vector.matrix.android.internal.session.room.annotation.DefaultReactionService
import im.vector.matrix.android.internal.session.room.annotation.UpdateQuickReactionTask import im.vector.matrix.android.internal.session.room.annotation.UpdateQuickReactionTask
import im.vector.matrix.android.internal.session.room.membership.DefaultMembershipService
import im.vector.matrix.android.internal.session.room.membership.LoadRoomMembersTask import im.vector.matrix.android.internal.session.room.membership.LoadRoomMembersTask
import im.vector.matrix.android.internal.session.room.membership.SenderRoomMemberExtractor import im.vector.matrix.android.internal.session.room.membership.SenderRoomMemberExtractor
import im.vector.matrix.android.internal.session.room.membership.joining.InviteTask import im.vector.matrix.android.internal.session.room.membership.joining.InviteTask
@ -58,7 +58,7 @@ internal class RoomFactory(private val monarchy: Monarchy,
val timelineEventFactory = TimelineEventFactory(roomMemberExtractor, EventRelationExtractor()) val timelineEventFactory = TimelineEventFactory(roomMemberExtractor, EventRelationExtractor())
val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, timelineEventFactory, contextOfEventTask, paginationTask) val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, timelineEventFactory, contextOfEventTask, paginationTask)
val sendService = DefaultSendService(roomId, eventFactory, monarchy) val sendService = DefaultSendService(roomId, eventFactory, monarchy)
val reactionService = DefaultReactionService(roomId, eventFactory, findReactionEventForUndoTask, updateQuickReactionTask, taskExecutor) val reactionService = DefaultRelationService(roomId, eventFactory, findReactionEventForUndoTask, updateQuickReactionTask, monarchy, taskExecutor)
val stateService = DefaultStateService(roomId, taskExecutor, sendStateTask) val stateService = DefaultStateService(roomId, taskExecutor, sendStateTask)
val roomMembersService = DefaultMembershipService(roomId, monarchy, taskExecutor, loadRoomMembersTask, inviteTask, joinRoomTask, leaveRoomTask) val roomMembersService = DefaultMembershipService(roomId, monarchy, taskExecutor, loadRoomMembersTask, inviteTask, joinRoomTask, leaveRoomTask)
val readService = DefaultReadService(roomId, monarchy, taskExecutor, setReadMarkersTask) val readService = DefaultReadService(roomId, monarchy, taskExecutor, setReadMarkersTask)

View File

@ -39,6 +39,7 @@ import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory
import im.vector.matrix.android.internal.session.room.state.DefaultSendStateTask import im.vector.matrix.android.internal.session.room.state.DefaultSendStateTask
import im.vector.matrix.android.internal.session.room.state.SendStateTask import im.vector.matrix.android.internal.session.room.state.SendStateTask
import im.vector.matrix.android.internal.session.room.timeline.* import im.vector.matrix.android.internal.session.room.timeline.*
import im.vector.matrix.android.internal.util.StringProvider
import org.koin.dsl.module.module import org.koin.dsl.module.module
import retrofit2.Retrofit import retrofit2.Retrofit
@ -73,7 +74,7 @@ class RoomModule {
} }
scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
LocalEchoEventFactory(get()) LocalEchoEventFactory(get(), get())
} }
scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
@ -109,7 +110,7 @@ class RoomModule {
} }
scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
DefaultPruneEventTask(get(),get()) as PruneEventTask DefaultPruneEventTask(get(), get()) as PruneEventTask
} }
} }

View File

@ -16,11 +16,17 @@
package im.vector.matrix.android.internal.session.room.annotation package im.vector.matrix.android.internal.session.room.annotation
import androidx.work.* import androidx.work.*
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.annotation.ReactionService import im.vector.matrix.android.api.session.room.model.annotation.RelationService
import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.database.helper.addSendingEvent
import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory
import im.vector.matrix.android.internal.session.room.send.RedactEventWorker import im.vector.matrix.android.internal.session.room.send.RedactEventWorker
import im.vector.matrix.android.internal.session.room.send.SendEventWorker import im.vector.matrix.android.internal.session.room.send.SendEventWorker
@ -28,6 +34,7 @@ import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.util.CancelableWork import im.vector.matrix.android.internal.util.CancelableWork
import im.vector.matrix.android.internal.util.WorkerParamsFactory import im.vector.matrix.android.internal.util.WorkerParamsFactory
import im.vector.matrix.android.internal.util.tryTransactionAsync
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
private const val REACTION_WORK = "REACTION_WORK" private const val REACTION_WORK = "REACTION_WORK"
@ -37,12 +44,13 @@ private val WORK_CONSTRAINTS = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) .setRequiredNetworkType(NetworkType.CONNECTED)
.build() .build()
internal class DefaultReactionService(private val roomId: String, internal class DefaultRelationService(private val roomId: String,
private val eventFactory: LocalEchoEventFactory, private val eventFactory: LocalEchoEventFactory,
private val findReactionEventForUndoTask: FindReactionEventForUndoTask, private val findReactionEventForUndoTask: FindReactionEventForUndoTask,
private val updateQuickReactionTask: UpdateQuickReactionTask, private val updateQuickReactionTask: UpdateQuickReactionTask,
private val monarchy: Monarchy,
private val taskExecutor: TaskExecutor) private val taskExecutor: TaskExecutor)
: ReactionService { : RelationService {
override fun sendReaction(reaction: String, targetEventId: String): Cancelable { override fun sendReaction(reaction: String, targetEventId: String): Cancelable {
@ -170,4 +178,38 @@ internal class DefaultReactionService(private val roomId: String,
return CancelableWork(workRequest.id) return CancelableWork(workRequest.id)
} }
/**
* Reply to an event in the timeline
* Users may wish to reference another message when forming their own message
* https://matrix.org/docs/spec/client_server/r0.4.0.html#id350
*/
override fun replyToMessage(eventReplied: Event, replyText: String): Cancelable? {
val event = eventFactory.createReplyTextEvent(roomId, eventReplied, replyText)?.also {
saveLocalEcho(it)
} ?: return null
val sendContentWorkerParams = SendEventWorker.Params(roomId, event)
val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)
val workRequest = OneTimeWorkRequestBuilder<SendEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(sendWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(REACTION_WORK), ExistingWorkPolicy.APPEND, workRequest)
.enqueue()
return CancelableWork(workRequest.id)
}
private fun saveLocalEcho(event: Event) {
monarchy.tryTransactionAsync { realm ->
val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst()
?: return@tryTransactionAsync
val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId = roomId)
?: return@tryTransactionAsync
roomEntity.addSendingEvent(event, liveChunk.forwardsStateIndex ?: 0)
}
}
} }

View File

@ -17,19 +17,20 @@
package im.vector.matrix.android.internal.session.room.send package im.vector.matrix.android.internal.session.room.send
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import im.vector.matrix.android.R
import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.permalinks.PermalinkFactory
import im.vector.matrix.android.api.session.content.ContentAttachmentData import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.*
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.RelationType
import im.vector.matrix.android.api.session.events.model.toContent
import im.vector.matrix.android.api.session.room.model.annotation.ReactionContent import im.vector.matrix.android.api.session.room.model.annotation.ReactionContent
import im.vector.matrix.android.api.session.room.model.annotation.ReactionInfo import im.vector.matrix.android.api.session.room.model.annotation.ReactionInfo
import im.vector.matrix.android.api.session.room.model.annotation.RelationDefaultContent import im.vector.matrix.android.api.session.room.model.annotation.RelationDefaultContent
import im.vector.matrix.android.api.session.room.model.annotation.ReplyToContent
import im.vector.matrix.android.api.session.room.model.message.* import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.internal.session.content.ThumbnailExtractor import im.vector.matrix.android.internal.session.content.ThumbnailExtractor
import im.vector.matrix.android.internal.util.StringProvider
internal class LocalEchoEventFactory(private val credentials: Credentials) { internal class LocalEchoEventFactory(private val credentials: Credentials, private val stringProvider: StringProvider) {
fun createTextEvent(roomId: String, msgType: String, text: String): Event { fun createTextEvent(roomId: String, msgType: String, text: String): Event {
val content = MessageTextContent(type = msgType, body = text) val content = MessageTextContent(type = msgType, body = text)
@ -183,4 +184,80 @@ internal class LocalEchoEventFactory(private val credentials: Credentials) {
private fun dummyEventId(roomId: String): String { private fun dummyEventId(roomId: String): String {
return roomId + "-" + dummyOriginServerTs() return roomId + "-" + dummyOriginServerTs()
} }
fun createReplyTextEvent(roomId: String, eventReplied: Event, replyText: String): Event? {
//Fallbacks and event representation
//TODO Add error/warning logs when any of this is null
val permalink = PermalinkFactory.createPermalink(eventReplied) ?: return null
val userId = eventReplied.sender ?: return null
val userLink = PermalinkFactory.createPermalink(userId) ?: return null
// <mx-reply>
// <blockquote>
// <a href="https://matrix.to/#/!somewhere:domain.com/$event:domain.com">In reply to</a>
// <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a>
// <br />
// <!-- This is where the related event's HTML would be. -->
// </blockquote>
// </mx-reply>
// This is where the reply goes.
val body = bodyForReply(eventReplied.content.toModel<MessageContent>())
val replyFallbackTemplateFormatted = """
<mx-reply>
<blockquote>
<a href="%s">${stringProvider.getString(R.string.in_reply_to)}</a>
<a href="%s">%s</a>
<br />
%s
</blockquote>
</mx-reply>
%s
""".trim().format(permalink, userLink, userId, body.second ?: body.first, replyText)
//
// > <@alice:example.org> This is the original body
//
// This is where the reply goes
val lines = body.first.split("\n")
val plainTextBody = StringBuffer("><${userId}>")
lines.firstOrNull()?.also { plainTextBody.append(" $it") }
lines.forEachIndexed { index, s ->
if (index > 0) {
plainTextBody.append("\n>$s")
}
}
plainTextBody.append("\n\n").append(replyText)
val eventId = eventReplied.eventId ?: return null
val content = MessageTextContent(
type = MessageType.MSGTYPE_TEXT,
format = MessageType.FORMAT_MATRIX_HTML,
body = plainTextBody.toString(),
formattedBody = replyFallbackTemplateFormatted,
relatesTo = RelationDefaultContent(null, null, ReplyToContent(eventId))
)
return createEvent(roomId, content)
}
private fun bodyForReply(content: MessageContent?): Pair<String, String?> {
when (content?.type) {
MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE -> {
//If we already have formatted body, return it?
var formattedText: String? = null
if (content is MessageTextContent) {
if (content.format == MessageType.FORMAT_MATRIX_HTML) {
formattedText = content.formattedBody
}
}
return content.body to formattedText
}
MessageType.MSGTYPE_FILE -> return stringProvider.getString(R.string.sent_a_file) to null
MessageType.MSGTYPE_AUDIO -> return stringProvider.getString(R.string.sent_an_audio_file) to null
MessageType.MSGTYPE_IMAGE -> return stringProvider.getString(R.string.sent_an_image) to null
MessageType.MSGTYPE_VIDEO -> return stringProvider.getString(R.string.sent_a_video) to null
else -> return (content?.body ?: "") to null
}
}
} }

View File

@ -20,6 +20,7 @@ import android.content.Context
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.internal.di.MatrixKoinComponent import im.vector.matrix.android.internal.di.MatrixKoinComponent
import im.vector.matrix.android.internal.network.executeRequest import im.vector.matrix.android.internal.network.executeRequest
@ -57,6 +58,11 @@ internal class SendEventWorker(context: Context, params: WorkerParameters)
localEvent.content localEvent.content
) )
} }
return result.fold({ Result.retry() }, { Result.success() }) return result.fold({
when (it) {
is Failure.NetworkConnection -> Result.retry()
else -> Result.failure()
}
}, { Result.success() })
} }
} }

View File

@ -0,0 +1,55 @@
/*
* 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.util
import android.content.res.Resources
import androidx.annotation.NonNull
import androidx.annotation.StringRes
class StringProvider(private val resources: Resources) {
/**
* Returns a localized string from the application's package's
* default string table.
*
* @param resId Resource id for the string
* @return The string data associated with the resource, stripped of styled
* text information.
*/
@NonNull
fun getString(@StringRes resId: Int): String {
return resources.getString(resId)
}
/**
* Returns a localized formatted string from the application's package's
* default string table, substituting the format arguments as defined in
* [java.util.Formatter] and [java.lang.String.format].
*
* @param resId Resource id for the format string
* @param formatArgs The format arguments that will be used for
* substitution.
* @return The string data associated with the resource, formatted and
* stripped of styled text information.
*/
@NonNull
fun getString(@StringRes resId: Int, vararg formatArgs: Any?): String {
return resources.getString(resId, *formatArgs)
}
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Strings not defined in Riot -->
<string name="in_reply_to">In reply to</string>
<string name="sent_a_file">sent a file.</string>
<string name="sent_an_image">sent an image.</string>
<string name="sent_a_video">sent a video.</string>
<string name="sent_an_audio_file">sent an audio file.</string>
</resources>

View File

@ -60,7 +60,7 @@ class HomeModule {
val timelineDateFormatter = TimelineDateFormatter(get()) val timelineDateFormatter = TimelineDateFormatter(get())
val timelineMediaSizeProvider = TimelineMediaSizeProvider() val timelineMediaSizeProvider = TimelineMediaSizeProvider()
val colorProvider = ColorProvider(fragment.requireContext()) val colorProvider = ColorProvider(fragment.requireContext())
val messageItemFactory = MessageItemFactory(colorProvider, timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer) val messageItemFactory = MessageItemFactory(colorProvider, timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer,get())
val timelineItemFactory = TimelineItemFactory(messageItemFactory = messageItemFactory, val timelineItemFactory = TimelineItemFactory(messageItemFactory = messageItemFactory,
roomNameItemFactory = RoomNameItemFactory(get()), roomNameItemFactory = RoomNameItemFactory(get()),

View File

@ -38,6 +38,7 @@ sealed class RoomDetailActions {
data class EnterEditMode(val eventId: String) : RoomDetailActions() data class EnterEditMode(val eventId: String) : RoomDetailActions()
data class EnterQuoteMode(val eventId: String) : RoomDetailActions() data class EnterQuoteMode(val eventId: String) : RoomDetailActions()
data class EnterReplyMode(val eventId: String) : RoomDetailActions()
} }

View File

@ -208,7 +208,8 @@ class RoomDetailFragment :
composerLayout.collapse() composerLayout.collapse()
} }
SendMode.EDIT, SendMode.EDIT,
SendMode.QUOTE -> { SendMode.QUOTE,
SendMode.REPLY -> {
commandAutocompletePolicy.enabled = false commandAutocompletePolicy.enabled = false
if (event == null) { if (event == null) {
//we should ignore? can this happen? //we should ignore? can this happen?
@ -233,9 +234,12 @@ class RoomDetailFragment :
if (mode == SendMode.EDIT) { if (mode == SendMode.EDIT) {
composerLayout.composerEditText.setText(eventTextBody) composerLayout.composerEditText.setText(eventTextBody)
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_edit)) composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_edit))
} else { } else if (mode == SendMode.QUOTE) {
composerLayout.composerEditText.setText("") composerLayout.composerEditText.setText("")
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_quote)) composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_quote))
} else if (mode == SendMode.REPLY) {
composerLayout.composerEditText.setText("")
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_reply))
} }
AvatarRenderer.render(event.senderAvatar, event.root.sender AvatarRenderer.render(event.senderAvatar, event.root.sender
@ -673,13 +677,17 @@ class RoomDetailFragment :
} }
} }
MessageMenuViewModel.ACTION_EDIT -> { MessageMenuViewModel.ACTION_EDIT -> {
val eventId = actionData.data.toString() ?: return@let val eventId = actionData.data.toString()
roomDetailViewModel.process(RoomDetailActions.EnterEditMode(eventId)) roomDetailViewModel.process(RoomDetailActions.EnterEditMode(eventId))
} }
MessageMenuViewModel.ACTION_QUOTE -> { MessageMenuViewModel.ACTION_QUOTE -> {
val eventId = actionData.data.toString() ?: return@let val eventId = actionData.data.toString()
roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(eventId)) roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(eventId))
} }
MessageMenuViewModel.ACTION_REPLY -> {
val eventId = actionData.data.toString()
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId))
}
else -> { else -> {
Toast.makeText(context, "Action ${actionData.actionId} not implemented", Toast.LENGTH_LONG).show() Toast.makeText(context, "Action ${actionData.actionId} not implemented", Toast.LENGTH_LONG).show()
} }

View File

@ -96,6 +96,7 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
is RoomDetailActions.ShowEditHistoryAction -> handleShowEditHistoryReaction(action) is RoomDetailActions.ShowEditHistoryAction -> handleShowEditHistoryReaction(action)
is RoomDetailActions.EnterEditMode -> handleEditAction(action) is RoomDetailActions.EnterEditMode -> handleEditAction(action)
is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action) is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action)
is RoomDetailActions.EnterReplyMode -> handleReplyAction(action)
} }
} }
@ -208,24 +209,34 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent)) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
} }
SendMode.QUOTE -> { SendMode.QUOTE -> {
withState { state -> val messageContent: MessageContent? =
val messageContent: MessageContent? = state.selectedEvent?.annotations?.editSummary?.aggregatedContent?.toModel()
state.selectedEvent?.annotations?.editSummary?.aggregatedContent?.toModel() ?: state.selectedEvent?.root?.content.toModel()
?: state.selectedEvent?.root?.content.toModel() val textMsg = messageContent?.body
val textMsg = messageContent?.body
val finalText = legacyRiotQuoteText(textMsg, action.text) val finalText = legacyRiotQuoteText(textMsg, action.text)
//TODO Refactor this, just temporary for quotes //TODO Refactor this, just temporary for quotes
val parser = Parser.builder().build() val parser = Parser.builder().build()
val document = parser.parse(finalText) val document = parser.parse(finalText)
val renderer = HtmlRenderer.builder().build() val renderer = HtmlRenderer.builder().build()
val htmlText = renderer.render(document) val htmlText = renderer.render(document)
if (TextUtils.equals(finalText, htmlText)) { if (TextUtils.equals(finalText, htmlText)) {
room.sendTextMessage(finalText) room.sendTextMessage(finalText)
} else { } else {
room.sendFormattedTextMessage(finalText, htmlText) room.sendFormattedTextMessage(finalText, htmlText)
} }
setState {
copy(
sendMode = SendMode.REGULAR,
selectedEvent = null
)
}
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
}
SendMode.REPLY -> {
state.selectedEvent?.let {
room.replyToMessage(it.root, action.text)
setState { setState {
copy( copy(
sendMode = SendMode.REGULAR, sendMode = SendMode.REGULAR,
@ -377,6 +388,17 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
} }
} }
} }
private fun handleReplyAction(action: RoomDetailActions.EnterReplyMode) {
room.getTimeLineEvent(action.eventId)?.let {
setState {
copy(
sendMode = SendMode.REPLY,
selectedEvent = it
)
}
}
}
private fun observeEventDisplayedActions() { private fun observeEventDisplayedActions() {

View File

@ -36,7 +36,8 @@ import im.vector.matrix.android.api.session.user.model.User
enum class SendMode { enum class SendMode {
REGULAR, REGULAR,
QUOTE, QUOTE,
EDIT EDIT,
REPLY
} }
data class RoomDetailViewState( data class RoomDetailViewState(

View File

@ -21,8 +21,14 @@ import com.airbnb.mvrx.ViewModelContext
import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageContent
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.riotredesign.core.platform.VectorViewModel import im.vector.riotredesign.core.platform.VectorViewModel
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import ru.noties.markwon.Markwon
import ru.noties.markwon.html.HtmlPlugin
import timber.log.Timber import timber.log.Timber
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@ -31,7 +37,7 @@ import java.util.*
data class MessageActionState( data class MessageActionState(
val userId: String, val userId: String,
val senderName: String, val senderName: String,
val messageBody: String, val messageBody: CharSequence,
val ts: String?, val ts: String?,
val senderAvatarPath: String? = null) val senderAvatarPath: String? = null)
: MvRxState : MvRxState
@ -54,10 +60,19 @@ class MessageActionsViewModel(initialState: MessageActionState) : VectorViewMode
val messageContent: MessageContent? = event.annotations?.editSummary?.aggregatedContent?.toModel() val messageContent: MessageContent? = event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.content.toModel() ?: event.root.content.toModel()
val originTs = event.root.originServerTs val originTs = event.root.originServerTs
var body: CharSequence = messageContent?.body ?: ""
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
val parser = Parser.builder().build()
val document = parser.parse(messageContent.formattedBody ?: messageContent.body)
// val renderer = HtmlRenderer.builder().build()
body = Markwon.builder(viewModelContext.activity)
.usePlugin(HtmlPlugin.create()).build().render(document)
// body = renderer.render(document)
}
MessageActionState( MessageActionState(
event.root.sender ?: "", event.root.sender ?: "",
parcel.informationData.memberName.toString(), parcel.informationData.memberName.toString(),
messageContent?.body ?: "", body,
dateFormat.format(Date(originTs ?: 0)), dateFormat.format(Date(originTs ?: 0)),
currentSession.contentUrlResolver().resolveFullSize(parcel.informationData.avatarUrl) currentSession.contentUrlResolver().resolveFullSize(parcel.informationData.avatarUrl)
) )

View File

@ -57,7 +57,7 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
//Resend and Delete //Resend and Delete
return MessageMenuState( return MessageMenuState(
listOf( listOf(
SimpleAction(ACTION_RESEND, R.string.resend, R.drawable.ic_corner_down_right, event.root.eventId), SimpleAction(ACTION_RESEND, R.string.resend, R.drawable.ic_send, event.root.eventId),
//TODO delete icon //TODO delete icon
SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, event.root.eventId) SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, event.root.eventId)
) )
@ -80,6 +80,10 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
this.add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent.body)) this.add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent.body))
} }
if (canReply(event, messageContent)) {
this.add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_reply, event.root.eventId))
}
if (canEdit(event, currentSession.sessionParams.credentials.userId)) { if (canEdit(event, currentSession.sessionParams.credentials.userId)) {
this.add(SimpleAction(ACTION_EDIT, R.string.edit, R.drawable.ic_edit, event.root.eventId)) this.add(SimpleAction(ACTION_EDIT, R.string.edit, R.drawable.ic_edit, event.root.eventId))
} }
@ -93,9 +97,6 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
this.add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_quote, parcel.eventId)) this.add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_quote, parcel.eventId))
} }
if (canReply(event, messageContent)) {
this.add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_corner_down_right))
}
if (canShare(type)) { if (canShare(type)) {
if (messageContent is MessageImageContent) { if (messageContent is MessageImageContent) {
this.add( this.add(

View File

@ -38,6 +38,7 @@ import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.core.extensions.localDateTime import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.core.linkify.VectorLinkify import im.vector.riotredesign.core.linkify.VectorLinkify
import im.vector.riotredesign.core.resources.ColorProvider import im.vector.riotredesign.core.resources.ColorProvider
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.core.utils.DebouncedClickListener import im.vector.riotredesign.core.utils.DebouncedClickListener
import im.vector.riotredesign.features.home.AvatarRenderer import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
@ -52,7 +53,8 @@ import me.gujun.android.span.span
class MessageItemFactory(private val colorProvider: ColorProvider, class MessageItemFactory(private val colorProvider: ColorProvider,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider, private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val timelineDateFormatter: TimelineDateFormatter, private val timelineDateFormatter: TimelineDateFormatter,
private val htmlRenderer: EventHtmlRenderer) { private val htmlRenderer: EventHtmlRenderer,
private val stringProvider: StringProvider) {
fun create(event: TimelineEvent, fun create(event: TimelineEvent,
nextEvent: TimelineEvent?, nextEvent: TimelineEvent?,
@ -100,8 +102,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
val messageContent: MessageContent = val messageContent: MessageContent =
event.annotations?.editSummary?.aggregatedContent?.toModel() event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.content.toModel() ?: event.root.content.toModel()
?: return null ?: //Malformed content, we should echo something on screen
return DefaultItem_().text(stringProvider.getString(R.string.malformed_message))
//TODO this should be filtered as not displayable?
if (messageContent.relatesTo?.type == RelationType.REPLACE) { if (messageContent.relatesTo?.type == RelationType.REPLACE) {
//TODO blank item or ignore?? //TODO blank item or ignore??
// ignore this event // ignore this event

View File

@ -1,22 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp" android:width="22dp"
android:height="22dp" android:height="13dp"
android:viewportWidth="22" android:viewportWidth="22"
android:viewportHeight="22"> android:viewportHeight="13">
<path <path
android:pathData="M14.75,8.5L21,14.75 14.75,21" android:pathData="M5.4444,1l-4.4444,4.3636l4.4444,4.3636"
android:strokeLineJoin="round" android:strokeLineJoin="round"
android:strokeWidth="2" android:strokeWidth="2"
android:fillColor="#00000000" android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E" android:strokeColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeLineCap="round"/> android:strokeLineCap="round"/>
<path <path
android:pathData="M1,1v8.75a5,5 0,0 0,5 5h15" android:pathData="M21,11.9091L21,9.7273C21,7.3173 19.0102,5.3636 16.5556,5.3636L1,5.3636"
android:strokeLineJoin="round" android:strokeLineJoin="round"
android:strokeWidth="2" android:strokeWidth="2"
android:fillColor="#00000000" android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E" android:strokeColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeLineCap="round"/> android:strokeLineCap="round"/>
</vector> </vector>

View File

@ -17,4 +17,7 @@
<string name="event_redacted_by_admin_reason">Event moderated by room admin</string> <string name="event_redacted_by_admin_reason">Event moderated by room admin</string>
<string name="last_edited_info_message">Last edited by %s on %s</string> <string name="last_edited_info_message">Last edited by %s on %s</string>
<string name="malformed_message">Malformed event, cannot display</string>
</resources> </resources>